forked from git.gladyson/openmonetis
refactor: migrate from ESLint to Biome and extract SQL queries to data.ts
- Replace ESLint with Biome for linting and formatting - Configure Biome with tabs, double quotes, and organized imports - Move all SQL/Drizzle queries from page.tsx files to data.ts files - Create new data.ts files for: ajustes, dashboard, relatorios/categorias - Update existing data.ts files: extrato, fatura (add lancamentos queries) - Remove all drizzle-orm imports from page.tsx files - Update README.md with new tooling info Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,148 +1,148 @@
|
||||
"use client";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import {
|
||||
RiArrowLeftRightLine,
|
||||
RiDeleteBin5Line,
|
||||
RiFileList2Line,
|
||||
RiPencilLine,
|
||||
RiInformationLine,
|
||||
RiArrowLeftRightLine,
|
||||
RiDeleteBin5Line,
|
||||
RiFileList2Line,
|
||||
RiInformationLine,
|
||||
RiPencilLine,
|
||||
} from "@remixicon/react";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import MoneyValues from "../money-values";
|
||||
import { Card, CardContent, CardFooter } from "../ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
interface AccountCardProps {
|
||||
accountName: string;
|
||||
accountType: string;
|
||||
balance: number;
|
||||
status?: string;
|
||||
icon?: React.ReactNode;
|
||||
excludeFromBalance?: boolean;
|
||||
excludeInitialBalanceFromIncome?: boolean;
|
||||
onViewStatement?: () => void;
|
||||
onEdit?: () => void;
|
||||
onRemove?: () => void;
|
||||
onTransfer?: () => void;
|
||||
className?: string;
|
||||
accountName: string;
|
||||
accountType: string;
|
||||
balance: number;
|
||||
status?: string;
|
||||
icon?: React.ReactNode;
|
||||
excludeFromBalance?: boolean;
|
||||
excludeInitialBalanceFromIncome?: boolean;
|
||||
onViewStatement?: () => void;
|
||||
onEdit?: () => void;
|
||||
onRemove?: () => void;
|
||||
onTransfer?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AccountCard({
|
||||
accountName,
|
||||
accountType,
|
||||
balance,
|
||||
status,
|
||||
icon,
|
||||
excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome,
|
||||
onViewStatement,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onTransfer,
|
||||
className,
|
||||
accountName,
|
||||
accountType,
|
||||
balance,
|
||||
status,
|
||||
icon,
|
||||
excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome,
|
||||
onViewStatement,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onTransfer,
|
||||
className,
|
||||
}: AccountCardProps) {
|
||||
const isInactive = status?.toLowerCase() === "inativa";
|
||||
const isInactive = status?.toLowerCase() === "inativa";
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: "editar",
|
||||
icon: <RiPencilLine className="size-4" aria-hidden />,
|
||||
onClick: onEdit,
|
||||
variant: "default" as const,
|
||||
},
|
||||
{
|
||||
label: "extrato",
|
||||
icon: <RiFileList2Line className="size-4" aria-hidden />,
|
||||
onClick: onViewStatement,
|
||||
variant: "default" as const,
|
||||
},
|
||||
{
|
||||
label: "transferir",
|
||||
icon: <RiArrowLeftRightLine className="size-4" aria-hidden />,
|
||||
onClick: onTransfer,
|
||||
variant: "default" as const,
|
||||
},
|
||||
{
|
||||
label: "remover",
|
||||
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
|
||||
onClick: onRemove,
|
||||
variant: "destructive" as const,
|
||||
},
|
||||
].filter((action) => typeof action.onClick === "function");
|
||||
const actions = [
|
||||
{
|
||||
label: "editar",
|
||||
icon: <RiPencilLine className="size-4" aria-hidden />,
|
||||
onClick: onEdit,
|
||||
variant: "default" as const,
|
||||
},
|
||||
{
|
||||
label: "extrato",
|
||||
icon: <RiFileList2Line className="size-4" aria-hidden />,
|
||||
onClick: onViewStatement,
|
||||
variant: "default" as const,
|
||||
},
|
||||
{
|
||||
label: "transferir",
|
||||
icon: <RiArrowLeftRightLine className="size-4" aria-hidden />,
|
||||
onClick: onTransfer,
|
||||
variant: "default" as const,
|
||||
},
|
||||
{
|
||||
label: "remover",
|
||||
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
|
||||
onClick: onRemove,
|
||||
variant: "destructive" as const,
|
||||
},
|
||||
].filter((action) => typeof action.onClick === "function");
|
||||
|
||||
return (
|
||||
<Card className={cn("h-full w-96 gap-0", className)}>
|
||||
<CardContent className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
isInactive && "[&_img]:grayscale [&_img]:opacity-40"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{accountName}
|
||||
</h2>
|
||||
return (
|
||||
<Card className={cn("h-full w-96 gap-0", className)}>
|
||||
<CardContent className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
isInactive && "[&_img]:grayscale [&_img]:opacity-40",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{accountName}
|
||||
</h2>
|
||||
|
||||
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RiInformationLine className="size-5 text-muted-foreground hover:text-foreground transition-colors cursor-help" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
{excludeFromBalance && (
|
||||
<p className="text-xs">
|
||||
<strong>Desconsiderado do saldo total:</strong> Esta conta
|
||||
não é incluída no cálculo do saldo total geral.
|
||||
</p>
|
||||
)}
|
||||
{excludeInitialBalanceFromIncome && (
|
||||
<p className="text-xs">
|
||||
<strong>
|
||||
Saldo inicial desconsiderado das receitas:
|
||||
</strong>{" "}
|
||||
O saldo inicial desta conta não é contabilizado como
|
||||
receita nas métricas.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RiInformationLine className="size-5 text-muted-foreground hover:text-foreground transition-colors cursor-help" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
{excludeFromBalance && (
|
||||
<p className="text-xs">
|
||||
<strong>Desconsiderado do saldo total:</strong> Esta conta
|
||||
não é incluída no cálculo do saldo total geral.
|
||||
</p>
|
||||
)}
|
||||
{excludeInitialBalanceFromIncome && (
|
||||
<p className="text-xs">
|
||||
<strong>
|
||||
Saldo inicial desconsiderado das receitas:
|
||||
</strong>{" "}
|
||||
O saldo inicial desta conta não é contabilizado como
|
||||
receita nas métricas.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<MoneyValues amount={balance} className="text-3xl" />
|
||||
<p className="text-sm text-muted-foreground">{accountType}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="space-y-2">
|
||||
<MoneyValues amount={balance} className="text-3xl" />
|
||||
<p className="text-sm text-muted-foreground">{accountType}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{actions.length > 0 ? (
|
||||
<CardFooter className="flex flex-wrap gap-3 px-6 pt-6 text-sm">
|
||||
{actions.map(({ label, icon, onClick, variant }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
|
||||
variant === "destructive" ? "text-destructive" : "text-primary"
|
||||
)}
|
||||
aria-label={`${label} conta`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
{actions.length > 0 ? (
|
||||
<CardFooter className="flex flex-wrap gap-3 px-6 pt-6 text-sm">
|
||||
{actions.map(({ label, icon, onClick, variant }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
|
||||
variant === "destructive" ? "text-destructive" : "text-primary",
|
||||
)}
|
||||
aria-label={`${label} conta`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createAccountAction,
|
||||
updateAccountAction,
|
||||
} from "@/app/(dashboard)/contas/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
createAccountAction,
|
||||
updateAccountAction,
|
||||
} from "@/app/(dashboard)/contas/actions";
|
||||
import { LogoPickerDialog, LogoPickerTrigger } from "@/components/logo-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { useFormState } from "@/hooks/use-form-state";
|
||||
import { useLogoSelection } from "@/hooks/use-logo-selection";
|
||||
@@ -34,237 +33,237 @@ import { AccountFormFields } from "./account-form-fields";
|
||||
import type { Account, AccountFormValues } from "./types";
|
||||
|
||||
const DEFAULT_ACCOUNT_TYPES = [
|
||||
"Conta Corrente",
|
||||
"Conta Poupança",
|
||||
"Carteira Digital",
|
||||
"Conta Investimento",
|
||||
"Pré-Pago | VR/VA",
|
||||
"Conta Corrente",
|
||||
"Conta Poupança",
|
||||
"Carteira Digital",
|
||||
"Conta Investimento",
|
||||
"Pré-Pago | VR/VA",
|
||||
] as const;
|
||||
|
||||
const DEFAULT_ACCOUNT_STATUS = ["Ativa", "Inativa"] as const;
|
||||
|
||||
interface AccountDialogProps {
|
||||
mode: "create" | "update";
|
||||
trigger?: React.ReactNode;
|
||||
logoOptions: string[];
|
||||
account?: Account;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
mode: "create" | "update";
|
||||
trigger?: React.ReactNode;
|
||||
logoOptions: string[];
|
||||
account?: Account;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const buildInitialValues = ({
|
||||
account,
|
||||
logoOptions,
|
||||
accountTypes,
|
||||
accountStatuses,
|
||||
account,
|
||||
logoOptions,
|
||||
accountTypes,
|
||||
accountStatuses,
|
||||
}: {
|
||||
account?: Account;
|
||||
logoOptions: string[];
|
||||
accountTypes: string[];
|
||||
accountStatuses: string[];
|
||||
account?: Account;
|
||||
logoOptions: string[];
|
||||
accountTypes: string[];
|
||||
accountStatuses: string[];
|
||||
}): AccountFormValues => {
|
||||
const fallbackLogo = logoOptions[0] ?? "";
|
||||
const selectedLogo = normalizeLogo(account?.logo) || fallbackLogo;
|
||||
const derivedName = deriveNameFromLogo(selectedLogo);
|
||||
const fallbackLogo = logoOptions[0] ?? "";
|
||||
const selectedLogo = normalizeLogo(account?.logo) || fallbackLogo;
|
||||
const derivedName = deriveNameFromLogo(selectedLogo);
|
||||
|
||||
return {
|
||||
name: account?.name ?? derivedName,
|
||||
accountType: account?.accountType ?? accountTypes[0] ?? "",
|
||||
status: account?.status ?? accountStatuses[0] ?? "",
|
||||
note: account?.note ?? "",
|
||||
logo: selectedLogo,
|
||||
initialBalance: formatInitialBalanceInput(account?.initialBalance ?? 0),
|
||||
excludeFromBalance: account?.excludeFromBalance ?? false,
|
||||
excludeInitialBalanceFromIncome:
|
||||
account?.excludeInitialBalanceFromIncome ?? false,
|
||||
};
|
||||
return {
|
||||
name: account?.name ?? derivedName,
|
||||
accountType: account?.accountType ?? accountTypes[0] ?? "",
|
||||
status: account?.status ?? accountStatuses[0] ?? "",
|
||||
note: account?.note ?? "",
|
||||
logo: selectedLogo,
|
||||
initialBalance: formatInitialBalanceInput(account?.initialBalance ?? 0),
|
||||
excludeFromBalance: account?.excludeFromBalance ?? false,
|
||||
excludeInitialBalanceFromIncome:
|
||||
account?.excludeInitialBalanceFromIncome ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
export function AccountDialog({
|
||||
mode,
|
||||
trigger,
|
||||
logoOptions,
|
||||
account,
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
trigger,
|
||||
logoOptions,
|
||||
account,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AccountDialogProps) {
|
||||
const [logoDialogOpen, setLogoDialogOpen] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [logoDialogOpen, setLogoDialogOpen] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Use controlled state hook for dialog open state
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
false,
|
||||
onOpenChange
|
||||
);
|
||||
// Use controlled state hook for dialog open state
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
false,
|
||||
onOpenChange,
|
||||
);
|
||||
|
||||
const accountTypes = useMemo(() => {
|
||||
const values = new Set<string>(DEFAULT_ACCOUNT_TYPES);
|
||||
if (account?.accountType) {
|
||||
values.add(account.accountType);
|
||||
}
|
||||
return Array.from(values);
|
||||
}, [account?.accountType]);
|
||||
const accountTypes = useMemo(() => {
|
||||
const values = new Set<string>(DEFAULT_ACCOUNT_TYPES);
|
||||
if (account?.accountType) {
|
||||
values.add(account.accountType);
|
||||
}
|
||||
return Array.from(values);
|
||||
}, [account?.accountType]);
|
||||
|
||||
const accountStatuses = useMemo(() => {
|
||||
const values = new Set<string>(DEFAULT_ACCOUNT_STATUS);
|
||||
if (account?.status) {
|
||||
values.add(account.status);
|
||||
}
|
||||
return Array.from(values);
|
||||
}, [account?.status]);
|
||||
const accountStatuses = useMemo(() => {
|
||||
const values = new Set<string>(DEFAULT_ACCOUNT_STATUS);
|
||||
if (account?.status) {
|
||||
values.add(account.status);
|
||||
}
|
||||
return Array.from(values);
|
||||
}, [account?.status]);
|
||||
|
||||
const initialState = useMemo(
|
||||
() =>
|
||||
buildInitialValues({
|
||||
account,
|
||||
logoOptions,
|
||||
accountTypes,
|
||||
accountStatuses,
|
||||
}),
|
||||
[account, logoOptions, accountTypes, accountStatuses]
|
||||
);
|
||||
const initialState = useMemo(
|
||||
() =>
|
||||
buildInitialValues({
|
||||
account,
|
||||
logoOptions,
|
||||
accountTypes,
|
||||
accountStatuses,
|
||||
}),
|
||||
[account, logoOptions, accountTypes, accountStatuses],
|
||||
);
|
||||
|
||||
// Use form state hook for form management
|
||||
const { formState, updateField, updateFields, setFormState } =
|
||||
useFormState<AccountFormValues>(initialState);
|
||||
// Use form state hook for form management
|
||||
const { formState, updateField, updateFields, setFormState } =
|
||||
useFormState<AccountFormValues>(initialState);
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
setFormState(initialState);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [dialogOpen, initialState, setFormState]);
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
setFormState(initialState);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [dialogOpen, initialState, setFormState]);
|
||||
|
||||
// Close logo dialog when main dialog closes
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
setErrorMessage(null);
|
||||
setLogoDialogOpen(false);
|
||||
}
|
||||
}, [dialogOpen]);
|
||||
// Close logo dialog when main dialog closes
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
setErrorMessage(null);
|
||||
setLogoDialogOpen(false);
|
||||
}
|
||||
}, [dialogOpen]);
|
||||
|
||||
// Use logo selection hook
|
||||
const handleLogoSelection = useLogoSelection({
|
||||
mode,
|
||||
currentLogo: formState.logo,
|
||||
currentName: formState.name,
|
||||
onUpdate: (updates) => {
|
||||
updateFields(updates);
|
||||
setLogoDialogOpen(false);
|
||||
},
|
||||
});
|
||||
// Use logo selection hook
|
||||
const handleLogoSelection = useLogoSelection({
|
||||
mode,
|
||||
currentLogo: formState.logo,
|
||||
currentName: formState.name,
|
||||
onUpdate: (updates) => {
|
||||
updateFields(updates);
|
||||
setLogoDialogOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
const handleSubmit = useCallback(
|
||||
(event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (mode === "update" && !account?.id) {
|
||||
const message = "Conta inválida.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
if (mode === "update" && !account?.id) {
|
||||
const message = "Conta inválida.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { ...formState };
|
||||
const payload = { ...formState };
|
||||
|
||||
if (!payload.logo) {
|
||||
setErrorMessage("Selecione um logo.");
|
||||
return;
|
||||
}
|
||||
if (!payload.logo) {
|
||||
setErrorMessage("Selecione um logo.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result =
|
||||
mode === "create"
|
||||
? await createAccountAction(payload)
|
||||
: await updateAccountAction({
|
||||
id: account?.id ?? "",
|
||||
...payload,
|
||||
});
|
||||
startTransition(async () => {
|
||||
const result =
|
||||
mode === "create"
|
||||
? await createAccountAction(payload)
|
||||
: await updateAccountAction({
|
||||
id: account?.id ?? "",
|
||||
...payload,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
setFormState(initialState);
|
||||
return;
|
||||
}
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
setFormState(initialState);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
},
|
||||
[account?.id, formState, initialState, mode, setDialogOpen, setFormState]
|
||||
);
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
},
|
||||
[account?.id, formState, initialState, mode, setDialogOpen, setFormState],
|
||||
);
|
||||
|
||||
const title = mode === "create" ? "Nova conta" : "Editar conta";
|
||||
const description =
|
||||
mode === "create"
|
||||
? "Cadastre uma nova conta para organizar seus lançamentos."
|
||||
: "Atualize as informações da conta selecionada.";
|
||||
const submitLabel = mode === "create" ? "Salvar conta" : "Atualizar conta";
|
||||
const title = mode === "create" ? "Nova conta" : "Editar conta";
|
||||
const description =
|
||||
mode === "create"
|
||||
? "Cadastre uma nova conta para organizar seus lançamentos."
|
||||
: "Atualize as informações da conta selecionada.";
|
||||
const submitLabel = mode === "create" ? "Salvar conta" : "Atualizar conta";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
return (
|
||||
<>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<LogoPickerTrigger
|
||||
selectedLogo={formState.logo}
|
||||
disabled={logoOptions.length === 0}
|
||||
onOpen={() => {
|
||||
if (logoOptions.length > 0) {
|
||||
setLogoDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<LogoPickerTrigger
|
||||
selectedLogo={formState.logo}
|
||||
disabled={logoOptions.length === 0}
|
||||
onOpen={() => {
|
||||
if (logoOptions.length > 0) {
|
||||
setLogoDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AccountFormFields
|
||||
values={formState}
|
||||
accountTypes={accountTypes}
|
||||
accountStatuses={accountStatuses}
|
||||
onChange={updateField}
|
||||
showInitialBalance={mode === "create"}
|
||||
/>
|
||||
<AccountFormFields
|
||||
values={formState}
|
||||
accountTypes={accountTypes}
|
||||
accountStatuses={accountStatuses}
|
||||
onChange={updateField}
|
||||
showInitialBalance={mode === "create"}
|
||||
/>
|
||||
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Salvando..." : submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DialogFooter className="gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Salvando..." : submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<LogoPickerDialog
|
||||
open={logoDialogOpen}
|
||||
logos={logoOptions}
|
||||
value={formState.logo}
|
||||
onOpenChange={setLogoDialogOpen}
|
||||
onSelect={handleLogoSelection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<LogoPickerDialog
|
||||
open={logoDialogOpen}
|
||||
logos={logoOptions}
|
||||
value={formState.logo}
|
||||
onOpenChange={setLogoDialogOpen}
|
||||
onSelect={handleLogoSelection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,145 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { CurrencyInput } from "@/components/ui/currency-input";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { StatusSelectContent } from "./account-select-items";
|
||||
|
||||
import type { AccountFormValues } from "./types";
|
||||
|
||||
interface AccountFormFieldsProps {
|
||||
values: AccountFormValues;
|
||||
accountTypes: string[];
|
||||
accountStatuses: string[];
|
||||
onChange: (field: keyof AccountFormValues, value: string) => void;
|
||||
showInitialBalance?: boolean;
|
||||
values: AccountFormValues;
|
||||
accountTypes: string[];
|
||||
accountStatuses: string[];
|
||||
onChange: (field: keyof AccountFormValues, value: string) => void;
|
||||
showInitialBalance?: boolean;
|
||||
}
|
||||
|
||||
export function AccountFormFields({
|
||||
values,
|
||||
accountTypes,
|
||||
accountStatuses,
|
||||
onChange,
|
||||
showInitialBalance = true,
|
||||
values,
|
||||
accountTypes,
|
||||
accountStatuses,
|
||||
onChange,
|
||||
showInitialBalance = true,
|
||||
}: AccountFormFieldsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="account-name">Nome</Label>
|
||||
<Input
|
||||
id="account-name"
|
||||
value={values.name}
|
||||
onChange={(event) => onChange("name", event.target.value)}
|
||||
placeholder="Ex.: Nubank"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="account-name">Nome</Label>
|
||||
<Input
|
||||
id="account-name"
|
||||
value={values.name}
|
||||
onChange={(event) => onChange("name", event.target.value)}
|
||||
placeholder="Ex.: Nubank"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="account-type">Tipo de conta</Label>
|
||||
<Select
|
||||
value={values.accountType}
|
||||
onValueChange={(value) => onChange("accountType", value)}
|
||||
>
|
||||
<SelectTrigger id="account-type" className="w-full">
|
||||
<SelectValue placeholder="Selecione o tipo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accountTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="account-type">Tipo de conta</Label>
|
||||
<Select
|
||||
value={values.accountType}
|
||||
onValueChange={(value) => onChange("accountType", value)}
|
||||
>
|
||||
<SelectTrigger id="account-type" className="w-full">
|
||||
<SelectValue placeholder="Selecione o tipo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accountTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="account-status">Status</Label>
|
||||
<Select
|
||||
value={values.status}
|
||||
onValueChange={(value) => onChange("status", value)}
|
||||
>
|
||||
<SelectTrigger id="account-status" className="w-full">
|
||||
<SelectValue placeholder="Selecione o status">
|
||||
{values.status && <StatusSelectContent label={values.status} />}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accountStatuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<StatusSelectContent label={status} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="account-status">Status</Label>
|
||||
<Select
|
||||
value={values.status}
|
||||
onValueChange={(value) => onChange("status", value)}
|
||||
>
|
||||
<SelectTrigger id="account-status" className="w-full">
|
||||
<SelectValue placeholder="Selecione o status">
|
||||
{values.status && <StatusSelectContent label={values.status} />}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accountStatuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<StatusSelectContent label={status} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{showInitialBalance ? (
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="account-initial-balance">Saldo inicial</Label>
|
||||
<CurrencyInput
|
||||
id="account-initial-balance"
|
||||
value={values.initialBalance}
|
||||
onValueChange={(value) => onChange("initialBalance", value)}
|
||||
placeholder="R$ 0,00"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showInitialBalance ? (
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="account-initial-balance">Saldo inicial</Label>
|
||||
<CurrencyInput
|
||||
id="account-initial-balance"
|
||||
value={values.initialBalance}
|
||||
onValueChange={(value) => onChange("initialBalance", value)}
|
||||
placeholder="R$ 0,00"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="account-note">Anotação</Label>
|
||||
<Textarea
|
||||
id="account-note"
|
||||
value={values.note}
|
||||
onChange={(event) => onChange("note", event.target.value)}
|
||||
placeholder="Informações adicionais sobre a conta"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="account-note">Anotação</Label>
|
||||
<Textarea
|
||||
id="account-note"
|
||||
value={values.note}
|
||||
onChange={(event) => onChange("note", event.target.value)}
|
||||
placeholder="Informações adicionais sobre a conta"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="exclude-from-balance"
|
||||
checked={values.excludeFromBalance === true || values.excludeFromBalance === "true"}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("excludeFromBalance", !!checked ? "true" : "false")
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="exclude-from-balance"
|
||||
className="cursor-pointer text-sm font-normal leading-tight"
|
||||
>
|
||||
Desconsiderar do saldo total (útil para contas de investimento ou
|
||||
reserva)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="exclude-from-balance"
|
||||
checked={
|
||||
values.excludeFromBalance === true ||
|
||||
values.excludeFromBalance === "true"
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("excludeFromBalance", checked ? "true" : "false")
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="exclude-from-balance"
|
||||
className="cursor-pointer text-sm font-normal leading-tight"
|
||||
>
|
||||
Desconsiderar do saldo total (útil para contas de investimento ou
|
||||
reserva)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="exclude-initial-balance-from-income"
|
||||
checked={values.excludeInitialBalanceFromIncome === true || values.excludeInitialBalanceFromIncome === "true"}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("excludeInitialBalanceFromIncome", !!checked ? "true" : "false")
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="exclude-initial-balance-from-income"
|
||||
className="cursor-pointer text-sm font-normal leading-tight"
|
||||
>
|
||||
Desconsiderar o saldo inicial ao calcular o total de receitas
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="exclude-initial-balance-from-income"
|
||||
checked={
|
||||
values.excludeInitialBalanceFromIncome === true ||
|
||||
values.excludeInitialBalanceFromIncome === "true"
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange(
|
||||
"excludeInitialBalanceFromIncome",
|
||||
checked ? "true" : "false",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="exclude-initial-balance-from-income"
|
||||
className="cursor-pointer text-sm font-normal leading-tight"
|
||||
>
|
||||
Desconsiderar o saldo inicial ao calcular o total de receitas
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
import DotIcon from "@/components/dot-icon";
|
||||
|
||||
export function StatusSelectContent({ label }: { label: string }) {
|
||||
const isActive = label === "Ativa";
|
||||
const isActive = label === "Ativa";
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<DotIcon
|
||||
bg_dot={
|
||||
isActive
|
||||
? "bg-emerald-600 dark:bg-emerald-300"
|
||||
: "bg-slate-400 dark:bg-slate-500"
|
||||
}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<DotIcon
|
||||
bg_dot={
|
||||
isActive
|
||||
? "bg-emerald-600 dark:bg-emerald-300"
|
||||
: "bg-slate-400 dark:bg-slate-500"
|
||||
}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,217 +1,217 @@
|
||||
"use client";
|
||||
import { RiInformationLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import MoneyValues from "@/components/money-values";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { RiInformationLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
|
||||
type DetailValue = string | number | ReactNode;
|
||||
|
||||
type AccountStatementCardProps = {
|
||||
accountName: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
periodLabel: string;
|
||||
currentBalance: number;
|
||||
openingBalance: number;
|
||||
totalIncomes: number;
|
||||
totalExpenses: number;
|
||||
logo?: string | null;
|
||||
actions?: React.ReactNode;
|
||||
accountName: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
periodLabel: string;
|
||||
currentBalance: number;
|
||||
openingBalance: number;
|
||||
totalIncomes: number;
|
||||
totalExpenses: number;
|
||||
logo?: string | null;
|
||||
actions?: React.ReactNode;
|
||||
};
|
||||
|
||||
const resolveLogoPath = (logo?: string | null) => {
|
||||
if (!logo) return null;
|
||||
if (
|
||||
logo.startsWith("http://") ||
|
||||
logo.startsWith("https://") ||
|
||||
logo.startsWith("data:")
|
||||
) {
|
||||
return logo;
|
||||
}
|
||||
if (!logo) return null;
|
||||
if (
|
||||
logo.startsWith("http://") ||
|
||||
logo.startsWith("https://") ||
|
||||
logo.startsWith("data:")
|
||||
) {
|
||||
return logo;
|
||||
}
|
||||
|
||||
return logo.startsWith("/") ? logo : `/logos/${logo}`;
|
||||
return logo.startsWith("/") ? logo : `/logos/${logo}`;
|
||||
};
|
||||
|
||||
const getAccountStatusBadgeVariant = (
|
||||
status: string
|
||||
status: string,
|
||||
): "success" | "secondary" => {
|
||||
const normalizedStatus = status.toLowerCase();
|
||||
if (normalizedStatus === "ativa") {
|
||||
return "success";
|
||||
}
|
||||
return "outline";
|
||||
const normalizedStatus = status.toLowerCase();
|
||||
if (normalizedStatus === "ativa") {
|
||||
return "success";
|
||||
}
|
||||
return "outline";
|
||||
};
|
||||
|
||||
export function AccountStatementCard({
|
||||
accountName,
|
||||
accountType,
|
||||
status,
|
||||
periodLabel,
|
||||
currentBalance,
|
||||
openingBalance,
|
||||
totalIncomes,
|
||||
totalExpenses,
|
||||
logo,
|
||||
actions,
|
||||
accountName,
|
||||
accountType,
|
||||
status,
|
||||
periodLabel,
|
||||
currentBalance,
|
||||
openingBalance,
|
||||
totalIncomes,
|
||||
totalExpenses,
|
||||
logo,
|
||||
actions,
|
||||
}: AccountStatementCardProps) {
|
||||
const logoPath = useMemo(() => resolveLogoPath(logo), [logo]);
|
||||
const logoPath = useMemo(() => resolveLogoPath(logo), [logo]);
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
value.toLocaleString("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
});
|
||||
const formatCurrency = (value: number) =>
|
||||
value.toLocaleString("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="border">
|
||||
<CardHeader className="flex flex-col gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{logoPath ? (
|
||||
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border/60 bg-background">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt={`Logo da conta ${accountName}`}
|
||||
width={48}
|
||||
height={48}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
return (
|
||||
<Card className="border">
|
||||
<CardHeader className="flex flex-col gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{logoPath ? (
|
||||
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border/60 bg-background">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt={`Logo da conta ${accountName}`}
|
||||
width={48}
|
||||
height={48}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex w-full items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-semibold text-foreground">
|
||||
{accountName}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Extrato de {periodLabel}
|
||||
</p>
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="flex w-full items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-semibold text-foreground">
|
||||
{accountName}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Extrato de {periodLabel}
|
||||
</p>
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4 border-t border-border/60 border-dashed pt-4">
|
||||
{/* Composição do Saldo */}
|
||||
<div className="space-y-3">
|
||||
<DetailItem
|
||||
label="Saldo no início do período"
|
||||
value={<MoneyValues amount={openingBalance} className="text-2xl" />}
|
||||
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
|
||||
/>
|
||||
<CardContent className="space-y-4 border-t border-border/60 border-dashed pt-4">
|
||||
{/* Composição do Saldo */}
|
||||
<div className="space-y-3">
|
||||
<DetailItem
|
||||
label="Saldo no início do período"
|
||||
value={<MoneyValues amount={openingBalance} className="text-2xl" />}
|
||||
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<DetailItem
|
||||
label="Entradas"
|
||||
value={
|
||||
<span className="font-medium text-emerald-600">
|
||||
{formatCurrency(totalIncomes)}
|
||||
</span>
|
||||
}
|
||||
tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
|
||||
/>
|
||||
<DetailItem
|
||||
label="Saídas"
|
||||
value={
|
||||
<span className="font-medium text-destructive">
|
||||
{formatCurrency(totalExpenses)}
|
||||
</span>
|
||||
}
|
||||
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<DetailItem
|
||||
label="Entradas"
|
||||
value={
|
||||
<span className="font-medium text-emerald-600">
|
||||
{formatCurrency(totalIncomes)}
|
||||
</span>
|
||||
}
|
||||
tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
|
||||
/>
|
||||
<DetailItem
|
||||
label="Saídas"
|
||||
value={
|
||||
<span className="font-medium text-destructive">
|
||||
{formatCurrency(totalExpenses)}
|
||||
</span>
|
||||
}
|
||||
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
|
||||
/>
|
||||
|
||||
<DetailItem
|
||||
label="Resultado do período"
|
||||
value={
|
||||
<MoneyValues
|
||||
amount={totalIncomes - totalExpenses}
|
||||
className={cn(
|
||||
"font-semibold text-xl",
|
||||
totalIncomes - totalExpenses >= 0
|
||||
? "text-emerald-600"
|
||||
: "text-destructive"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
tooltip="Diferença entre entradas e saídas do mês; positivo indica saldo crescente."
|
||||
/>
|
||||
</div>
|
||||
<DetailItem
|
||||
label="Resultado do período"
|
||||
value={
|
||||
<MoneyValues
|
||||
amount={totalIncomes - totalExpenses}
|
||||
className={cn(
|
||||
"font-semibold text-xl",
|
||||
totalIncomes - totalExpenses >= 0
|
||||
? "text-emerald-600"
|
||||
: "text-destructive",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
tooltip="Diferença entre entradas e saídas do mês; positivo indica saldo crescente."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Saldo Atual - Destaque Principal */}
|
||||
<DetailItem
|
||||
label="Saldo ao final do período"
|
||||
value={<MoneyValues amount={currentBalance} className="text-2xl" />}
|
||||
tooltip="Saldo inicial do período + entradas - saídas realizadas neste mês."
|
||||
/>
|
||||
</div>
|
||||
{/* Saldo Atual - Destaque Principal */}
|
||||
<DetailItem
|
||||
label="Saldo ao final do período"
|
||||
value={<MoneyValues amount={currentBalance} className="text-2xl" />}
|
||||
tooltip="Saldo inicial do período + entradas - saídas realizadas neste mês."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Informações da Conta */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 pt-2 border-t border-border/60 border-dashed">
|
||||
<DetailItem
|
||||
label="Tipo da conta"
|
||||
value={accountType}
|
||||
tooltip="Classificação definida na criação da conta (corrente, poupança, etc.)."
|
||||
/>
|
||||
<DetailItem
|
||||
label="Status da conta"
|
||||
value={
|
||||
<div className="flex items-center">
|
||||
<Badge
|
||||
variant={getAccountStatusBadgeVariant(status)}
|
||||
className="text-xs"
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
tooltip="Indica se a conta está ativa para lançamentos ou foi desativada."
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
{/* Informações da Conta */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 pt-2 border-t border-border/60 border-dashed">
|
||||
<DetailItem
|
||||
label="Tipo da conta"
|
||||
value={accountType}
|
||||
tooltip="Classificação definida na criação da conta (corrente, poupança, etc.)."
|
||||
/>
|
||||
<DetailItem
|
||||
label="Status da conta"
|
||||
value={
|
||||
<div className="flex items-center">
|
||||
<Badge
|
||||
variant={getAccountStatusBadgeVariant(status)}
|
||||
className="text-xs"
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
tooltip="Indica se a conta está ativa para lançamentos ou foi desativada."
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailItem({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
tooltip,
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
tooltip,
|
||||
}: {
|
||||
label: string;
|
||||
value: DetailValue;
|
||||
className?: string;
|
||||
tooltip?: string;
|
||||
label: string;
|
||||
value: DetailValue;
|
||||
className?: string;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("space-y-1", className)}>
|
||||
<span className="flex items-center gap-1 text-xs font-medium uppercase text-muted-foreground/80">
|
||||
{label}
|
||||
{tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="size-3.5 cursor-help text-muted-foreground/60" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="max-w-xs text-xs"
|
||||
>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="text-base text-foreground">{value}</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={cn("space-y-1", className)}>
|
||||
<span className="flex items-center gap-1 text-xs font-medium uppercase text-muted-foreground/80">
|
||||
{label}
|
||||
{tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="size-3.5 cursor-help text-muted-foreground/60" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="max-w-xs text-xs"
|
||||
>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="text-base text-foreground">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,214 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddCircleLine, RiBankLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { deleteAccountAction } from "@/app/(dashboard)/contas/actions";
|
||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
||||
import { AccountCard } from "@/components/contas/account-card";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getCurrentPeriod } from "@/lib/utils/period";
|
||||
import { RiAddCircleLine, RiBankLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Card } from "../ui/card";
|
||||
import { AccountDialog } from "./account-dialog";
|
||||
import { TransferDialog } from "./transfer-dialog";
|
||||
import type { Account } from "./types";
|
||||
|
||||
interface AccountsPageProps {
|
||||
accounts: Account[];
|
||||
logoOptions: string[];
|
||||
isInativos?: boolean;
|
||||
accounts: Account[];
|
||||
logoOptions: string[];
|
||||
isInativos?: boolean;
|
||||
}
|
||||
|
||||
const resolveLogoSrc = (logo: string | null) => {
|
||||
if (!logo) {
|
||||
return undefined;
|
||||
}
|
||||
if (!logo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
|
||||
return `/logos/${fileName}`;
|
||||
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
|
||||
return `/logos/${fileName}`;
|
||||
};
|
||||
|
||||
export function AccountsPage({ accounts, logoOptions, isInativos = false }: AccountsPageProps) {
|
||||
const router = useRouter();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
|
||||
const [removeOpen, setRemoveOpen] = useState(false);
|
||||
const [accountToRemove, setAccountToRemove] = useState<Account | null>(null);
|
||||
const [transferOpen, setTransferOpen] = useState(false);
|
||||
const [transferFromAccount, setTransferFromAccount] =
|
||||
useState<Account | null>(null);
|
||||
export function AccountsPage({
|
||||
accounts,
|
||||
logoOptions,
|
||||
isInativos = false,
|
||||
}: AccountsPageProps) {
|
||||
const router = useRouter();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
|
||||
const [removeOpen, setRemoveOpen] = useState(false);
|
||||
const [accountToRemove, setAccountToRemove] = useState<Account | null>(null);
|
||||
const [transferOpen, setTransferOpen] = useState(false);
|
||||
const [transferFromAccount, setTransferFromAccount] =
|
||||
useState<Account | null>(null);
|
||||
|
||||
const hasAccounts = accounts.length > 0;
|
||||
const hasAccounts = accounts.length > 0;
|
||||
|
||||
const orderedAccounts = useMemo(() => {
|
||||
return [...accounts].sort((a, b) => {
|
||||
// Coloca inativas no final
|
||||
const aIsInactive = a.status?.toLowerCase() === "inativa";
|
||||
const bIsInactive = b.status?.toLowerCase() === "inativa";
|
||||
const orderedAccounts = useMemo(() => {
|
||||
return [...accounts].sort((a, b) => {
|
||||
// Coloca inativas no final
|
||||
const aIsInactive = a.status?.toLowerCase() === "inativa";
|
||||
const bIsInactive = b.status?.toLowerCase() === "inativa";
|
||||
|
||||
if (aIsInactive && !bIsInactive) return 1;
|
||||
if (!aIsInactive && bIsInactive) return -1;
|
||||
if (aIsInactive && !bIsInactive) return 1;
|
||||
if (!aIsInactive && bIsInactive) return -1;
|
||||
|
||||
// Mesma ordem alfabética dentro de cada grupo
|
||||
return a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" });
|
||||
});
|
||||
}, [accounts]);
|
||||
// Mesma ordem alfabética dentro de cada grupo
|
||||
return a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" });
|
||||
});
|
||||
}, [accounts]);
|
||||
|
||||
const handleEdit = useCallback((account: Account) => {
|
||||
setSelectedAccount(account);
|
||||
setEditOpen(true);
|
||||
}, []);
|
||||
const handleEdit = useCallback((account: Account) => {
|
||||
setSelectedAccount(account);
|
||||
setEditOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
||||
setEditOpen(open);
|
||||
if (!open) {
|
||||
setSelectedAccount(null);
|
||||
}
|
||||
}, []);
|
||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
||||
setEditOpen(open);
|
||||
if (!open) {
|
||||
setSelectedAccount(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveRequest = useCallback((account: Account) => {
|
||||
setAccountToRemove(account);
|
||||
setRemoveOpen(true);
|
||||
}, []);
|
||||
const handleRemoveRequest = useCallback((account: Account) => {
|
||||
setAccountToRemove(account);
|
||||
setRemoveOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleRemoveOpenChange = useCallback((open: boolean) => {
|
||||
setRemoveOpen(open);
|
||||
if (!open) {
|
||||
setAccountToRemove(null);
|
||||
}
|
||||
}, []);
|
||||
const handleRemoveOpenChange = useCallback((open: boolean) => {
|
||||
setRemoveOpen(open);
|
||||
if (!open) {
|
||||
setAccountToRemove(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveConfirm = useCallback(async () => {
|
||||
if (!accountToRemove) {
|
||||
return;
|
||||
}
|
||||
const handleRemoveConfirm = useCallback(async () => {
|
||||
if (!accountToRemove) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deleteAccountAction({ id: accountToRemove.id });
|
||||
const result = await deleteAccountAction({ id: accountToRemove.id });
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
return;
|
||||
}
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
}, [accountToRemove]);
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
}, [accountToRemove]);
|
||||
|
||||
const handleTransferRequest = useCallback((account: Account) => {
|
||||
setTransferFromAccount(account);
|
||||
setTransferOpen(true);
|
||||
}, []);
|
||||
const handleTransferRequest = useCallback((account: Account) => {
|
||||
setTransferFromAccount(account);
|
||||
setTransferOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleTransferOpenChange = useCallback((open: boolean) => {
|
||||
setTransferOpen(open);
|
||||
if (!open) {
|
||||
setTransferFromAccount(null);
|
||||
}
|
||||
}, []);
|
||||
const handleTransferOpenChange = useCallback((open: boolean) => {
|
||||
setTransferOpen(open);
|
||||
if (!open) {
|
||||
setTransferFromAccount(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeTitle = accountToRemove
|
||||
? `Remover conta "${accountToRemove.name}"?`
|
||||
: "Remover conta?";
|
||||
const removeTitle = accountToRemove
|
||||
? `Remover conta "${accountToRemove.name}"?`
|
||||
: "Remover conta?";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex justify-start">
|
||||
<AccountDialog
|
||||
mode="create"
|
||||
logoOptions={logoOptions}
|
||||
trigger={
|
||||
<Button>
|
||||
<RiAddCircleLine className="size-4" />
|
||||
Nova conta
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex justify-start">
|
||||
<AccountDialog
|
||||
mode="create"
|
||||
logoOptions={logoOptions}
|
||||
trigger={
|
||||
<Button>
|
||||
<RiAddCircleLine className="size-4" />
|
||||
Nova conta
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasAccounts ? (
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{orderedAccounts.map((account) => {
|
||||
const logoSrc = resolveLogoSrc(account.logo);
|
||||
{hasAccounts ? (
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{orderedAccounts.map((account) => {
|
||||
const logoSrc = resolveLogoSrc(account.logo);
|
||||
|
||||
return (
|
||||
<AccountCard
|
||||
key={account.id}
|
||||
accountName={account.name}
|
||||
accountType={`${account.accountType}`}
|
||||
balance={account.balance ?? account.initialBalance ?? 0}
|
||||
status={account.status}
|
||||
excludeFromBalance={account.excludeFromBalance}
|
||||
excludeInitialBalanceFromIncome={
|
||||
account.excludeInitialBalanceFromIncome
|
||||
}
|
||||
icon={
|
||||
logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={`Logo da conta ${account.name}`}
|
||||
width={42}
|
||||
height={42}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onEdit={() => handleEdit(account)}
|
||||
onRemove={() => handleRemoveRequest(account)}
|
||||
onTransfer={() => handleTransferRequest(account)}
|
||||
onViewStatement={() =>
|
||||
router.push(`/contas/${account.id}/extrato`)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
||||
<EmptyState
|
||||
media={<RiBankLine className="size-6 text-primary" />}
|
||||
title={isInativos ? "Nenhuma conta inativa" : "Nenhuma conta cadastrada"}
|
||||
description={isInativos ? "Não há contas inativas no momento." : "Cadastre sua primeira conta para começar a organizar os lançamentos."}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<AccountCard
|
||||
key={account.id}
|
||||
accountName={account.name}
|
||||
accountType={`${account.accountType}`}
|
||||
balance={account.balance ?? account.initialBalance ?? 0}
|
||||
status={account.status}
|
||||
excludeFromBalance={account.excludeFromBalance}
|
||||
excludeInitialBalanceFromIncome={
|
||||
account.excludeInitialBalanceFromIncome
|
||||
}
|
||||
icon={
|
||||
logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={`Logo da conta ${account.name}`}
|
||||
width={42}
|
||||
height={42}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onEdit={() => handleEdit(account)}
|
||||
onRemove={() => handleRemoveRequest(account)}
|
||||
onTransfer={() => handleTransferRequest(account)}
|
||||
onViewStatement={() =>
|
||||
router.push(`/contas/${account.id}/extrato`)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
||||
<EmptyState
|
||||
media={<RiBankLine className="size-6 text-primary" />}
|
||||
title={
|
||||
isInativos
|
||||
? "Nenhuma conta inativa"
|
||||
: "Nenhuma conta cadastrada"
|
||||
}
|
||||
description={
|
||||
isInativos
|
||||
? "Não há contas inativas no momento."
|
||||
: "Cadastre sua primeira conta para começar a organizar os lançamentos."
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AccountDialog
|
||||
mode="update"
|
||||
logoOptions={logoOptions}
|
||||
account={selectedAccount ?? undefined}
|
||||
open={editOpen && !!selectedAccount}
|
||||
onOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
<AccountDialog
|
||||
mode="update"
|
||||
logoOptions={logoOptions}
|
||||
account={selectedAccount ?? undefined}
|
||||
open={editOpen && !!selectedAccount}
|
||||
onOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
|
||||
<ConfirmActionDialog
|
||||
open={removeOpen && !!accountToRemove}
|
||||
onOpenChange={handleRemoveOpenChange}
|
||||
title={removeTitle}
|
||||
description="Ao remover esta conta, todos os dados relacionados a ela serão perdidos."
|
||||
confirmLabel="Remover conta"
|
||||
pendingLabel="Removendo..."
|
||||
confirmVariant="destructive"
|
||||
onConfirm={handleRemoveConfirm}
|
||||
/>
|
||||
<ConfirmActionDialog
|
||||
open={removeOpen && !!accountToRemove}
|
||||
onOpenChange={handleRemoveOpenChange}
|
||||
title={removeTitle}
|
||||
description="Ao remover esta conta, todos os dados relacionados a ela serão perdidos."
|
||||
confirmLabel="Remover conta"
|
||||
pendingLabel="Removendo..."
|
||||
confirmVariant="destructive"
|
||||
onConfirm={handleRemoveConfirm}
|
||||
/>
|
||||
|
||||
{transferFromAccount && (
|
||||
<TransferDialog
|
||||
accounts={accounts.map((a) => ({
|
||||
...a,
|
||||
balance: a.balance ?? a.initialBalance ?? 0,
|
||||
excludeFromBalance: a.excludeFromBalance ?? false,
|
||||
}))}
|
||||
fromAccountId={transferFromAccount.id}
|
||||
currentPeriod={getCurrentPeriod()}
|
||||
open={transferOpen}
|
||||
onOpenChange={handleTransferOpenChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
{transferFromAccount && (
|
||||
<TransferDialog
|
||||
accounts={accounts.map((a) => ({
|
||||
...a,
|
||||
balance: a.balance ?? a.initialBalance ?? 0,
|
||||
excludeFromBalance: a.excludeFromBalance ?? false,
|
||||
}))}
|
||||
fromAccountId={transferFromAccount.id}
|
||||
currentPeriod={getCurrentPeriod()}
|
||||
open={transferOpen}
|
||||
onOpenChange={handleTransferOpenChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { transferBetweenAccountsAction } from "@/app/(dashboard)/contas/actions";
|
||||
import type { AccountData } from "@/app/(dashboard)/contas/data";
|
||||
import { ContaCartaoSelectContent } from "@/components/lancamentos/select-items";
|
||||
@@ -8,250 +10,248 @@ import { Button } from "@/components/ui/button";
|
||||
import { CurrencyInput } from "@/components/ui/currency-input";
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { getTodayDateString } from "@/lib/utils/date";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface TransferDialogProps {
|
||||
trigger?: React.ReactNode;
|
||||
accounts: AccountData[];
|
||||
fromAccountId: string;
|
||||
currentPeriod: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
trigger?: React.ReactNode;
|
||||
accounts: AccountData[];
|
||||
fromAccountId: string;
|
||||
currentPeriod: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function TransferDialog({
|
||||
trigger,
|
||||
accounts,
|
||||
fromAccountId,
|
||||
currentPeriod,
|
||||
open,
|
||||
onOpenChange,
|
||||
trigger,
|
||||
accounts,
|
||||
fromAccountId,
|
||||
currentPeriod,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TransferDialogProps) {
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
false,
|
||||
onOpenChange
|
||||
);
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
false,
|
||||
onOpenChange,
|
||||
);
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [toAccountId, setToAccountId] = useState("");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [date, setDate] = useState(getTodayDateString());
|
||||
const [period, setPeriod] = useState(currentPeriod);
|
||||
// Form state
|
||||
const [toAccountId, setToAccountId] = useState("");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [date, setDate] = useState(getTodayDateString());
|
||||
const [period, setPeriod] = useState(currentPeriod);
|
||||
|
||||
// Available destination accounts (exclude source account)
|
||||
const availableAccounts = useMemo(
|
||||
() => accounts.filter((account) => account.id !== fromAccountId),
|
||||
[accounts, fromAccountId]
|
||||
);
|
||||
// Available destination accounts (exclude source account)
|
||||
const availableAccounts = useMemo(
|
||||
() => accounts.filter((account) => account.id !== fromAccountId),
|
||||
[accounts, fromAccountId],
|
||||
);
|
||||
|
||||
// Source account info
|
||||
const fromAccount = useMemo(
|
||||
() => accounts.find((account) => account.id === fromAccountId),
|
||||
[accounts, fromAccountId]
|
||||
);
|
||||
// Source account info
|
||||
const fromAccount = useMemo(
|
||||
() => accounts.find((account) => account.id === fromAccountId),
|
||||
[accounts, fromAccountId],
|
||||
);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (!toAccountId) {
|
||||
setErrorMessage("Selecione a conta de destino.");
|
||||
return;
|
||||
}
|
||||
if (!toAccountId) {
|
||||
setErrorMessage("Selecione a conta de destino.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (toAccountId === fromAccountId) {
|
||||
setErrorMessage("Selecione uma conta de destino diferente da origem.");
|
||||
return;
|
||||
}
|
||||
if (toAccountId === fromAccountId) {
|
||||
setErrorMessage("Selecione uma conta de destino diferente da origem.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!amount || parseFloat(amount.replace(",", ".")) <= 0) {
|
||||
setErrorMessage("Informe um valor válido maior que zero.");
|
||||
return;
|
||||
}
|
||||
if (!amount || parseFloat(amount.replace(",", ".")) <= 0) {
|
||||
setErrorMessage("Informe um valor válido maior que zero.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await transferBetweenAccountsAction({
|
||||
fromAccountId,
|
||||
toAccountId,
|
||||
amount,
|
||||
date: new Date(date),
|
||||
period,
|
||||
});
|
||||
startTransition(async () => {
|
||||
const result = await transferBetweenAccountsAction({
|
||||
fromAccountId,
|
||||
toAccountId,
|
||||
amount,
|
||||
date: new Date(date),
|
||||
period,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
// Reset form
|
||||
setToAccountId("");
|
||||
setAmount("");
|
||||
setDate(getTodayDateString());
|
||||
setPeriod(currentPeriod);
|
||||
return;
|
||||
}
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
// Reset form
|
||||
setToAccountId("");
|
||||
setAmount("");
|
||||
setDate(getTodayDateString());
|
||||
setPeriod(currentPeriod);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
};
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Transferir entre contas</DialogTitle>
|
||||
<DialogDescription>
|
||||
Registre uma transferência de valores entre suas contas.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Transferir entre contas</DialogTitle>
|
||||
<DialogDescription>
|
||||
Registre uma transferência de valores entre suas contas.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="transfer-date">Data da transferência</Label>
|
||||
<DatePicker
|
||||
id="transfer-date"
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="transfer-date">Data da transferência</Label>
|
||||
<DatePicker
|
||||
id="transfer-date"
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="transfer-period">Período</Label>
|
||||
<PeriodPicker
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="transfer-period">Período</Label>
|
||||
<PeriodPicker
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="transfer-amount">Valor</Label>
|
||||
<CurrencyInput
|
||||
id="transfer-amount"
|
||||
value={amount}
|
||||
onValueChange={setAmount}
|
||||
placeholder="R$ 0,00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="transfer-amount">Valor</Label>
|
||||
<CurrencyInput
|
||||
id="transfer-amount"
|
||||
value={amount}
|
||||
onValueChange={setAmount}
|
||||
placeholder="R$ 0,00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="from-account">Conta de origem</Label>
|
||||
<Select value={fromAccountId} disabled>
|
||||
<SelectTrigger id="from-account" className="w-full">
|
||||
<SelectValue>
|
||||
{fromAccount && (
|
||||
<ContaCartaoSelectContent
|
||||
label={fromAccount.name}
|
||||
logo={fromAccount.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromAccount && (
|
||||
<SelectItem value={fromAccount.id}>
|
||||
<ContaCartaoSelectContent
|
||||
label={fromAccount.name}
|
||||
logo={fromAccount.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="from-account">Conta de origem</Label>
|
||||
<Select value={fromAccountId} disabled>
|
||||
<SelectTrigger id="from-account" className="w-full">
|
||||
<SelectValue>
|
||||
{fromAccount && (
|
||||
<ContaCartaoSelectContent
|
||||
label={fromAccount.name}
|
||||
logo={fromAccount.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromAccount && (
|
||||
<SelectItem value={fromAccount.id}>
|
||||
<ContaCartaoSelectContent
|
||||
label={fromAccount.name}
|
||||
logo={fromAccount.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="to-account">Conta de destino</Label>
|
||||
{availableAccounts.length === 0 ? (
|
||||
<div className="rounded-md border border-border bg-muted p-3 text-sm text-muted-foreground">
|
||||
É necessário ter mais de uma conta cadastrada para realizar
|
||||
transferências.
|
||||
</div>
|
||||
) : (
|
||||
<Select value={toAccountId} onValueChange={setToAccountId}>
|
||||
<SelectTrigger id="to-account" className="w-full">
|
||||
<SelectValue placeholder="Selecione a conta de destino">
|
||||
{toAccountId &&
|
||||
(() => {
|
||||
const selectedAccount = availableAccounts.find(
|
||||
(acc) => acc.id === toAccountId,
|
||||
);
|
||||
return selectedAccount ? (
|
||||
<ContaCartaoSelectContent
|
||||
label={selectedAccount.name}
|
||||
logo={selectedAccount.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-full">
|
||||
{availableAccounts.map((account) => (
|
||||
<SelectItem key={account.id} value={account.id}>
|
||||
<ContaCartaoSelectContent
|
||||
label={account.name}
|
||||
logo={account.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="to-account">Conta de destino</Label>
|
||||
{availableAccounts.length === 0 ? (
|
||||
<div className="rounded-md border border-border bg-muted p-3 text-sm text-muted-foreground">
|
||||
É necessário ter mais de uma conta cadastrada para realizar
|
||||
transferências.
|
||||
</div>
|
||||
) : (
|
||||
<Select value={toAccountId} onValueChange={setToAccountId}>
|
||||
<SelectTrigger id="to-account" className="w-full">
|
||||
<SelectValue placeholder="Selecione a conta de destino">
|
||||
{toAccountId &&
|
||||
(() => {
|
||||
const selectedAccount = availableAccounts.find(
|
||||
(acc) => acc.id === toAccountId,
|
||||
);
|
||||
return selectedAccount ? (
|
||||
<ContaCartaoSelectContent
|
||||
label={selectedAccount.name}
|
||||
logo={selectedAccount.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-full">
|
||||
{availableAccounts.map((account) => (
|
||||
<SelectItem key={account.id} value={account.id}>
|
||||
<ContaCartaoSelectContent
|
||||
label={account.name}
|
||||
logo={account.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || availableAccounts.length === 0}
|
||||
>
|
||||
{isPending ? "Processando..." : "Confirmar transferência"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
<DialogFooter className="gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || availableAccounts.length === 0}
|
||||
>
|
||||
{isPending ? "Processando..." : "Confirmar transferência"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
export type Account = {
|
||||
id: string;
|
||||
name: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
note: string | null;
|
||||
logo: string | null;
|
||||
initialBalance: number;
|
||||
balance?: number | null;
|
||||
excludeFromBalance?: boolean;
|
||||
excludeInitialBalanceFromIncome?: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
note: string | null;
|
||||
logo: string | null;
|
||||
initialBalance: number;
|
||||
balance?: number | null;
|
||||
excludeFromBalance?: boolean;
|
||||
excludeInitialBalanceFromIncome?: boolean;
|
||||
};
|
||||
|
||||
export type AccountFormValues = {
|
||||
name: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
note: string;
|
||||
logo: string;
|
||||
initialBalance: string;
|
||||
excludeFromBalance: boolean;
|
||||
excludeInitialBalanceFromIncome: boolean;
|
||||
name: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
note: string;
|
||||
logo: string;
|
||||
initialBalance: string;
|
||||
excludeFromBalance: boolean;
|
||||
excludeInitialBalanceFromIncome: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user