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:
Felipe Coutinho
2026-01-27 13:15:37 +00:00
parent 8ffe61c59b
commit a7f63fb77a
442 changed files with 66141 additions and 69292 deletions

View File

@@ -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>
);
}

View File

@@ -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}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
)}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
};