mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
refactor(core): move app para src e padroniza estrutura
This commit is contained in:
152
src/features/accounts/components/account-card.tsx
Normal file
152
src/features/accounts/components/account-card.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
import {
|
||||
RiArrowLeftRightLine,
|
||||
RiDeleteBin5Line,
|
||||
RiFileList2Line,
|
||||
RiInformationLine,
|
||||
RiPencilLine,
|
||||
} from "@remixicon/react";
|
||||
import type React from "react";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Card, CardContent, CardFooter } from "@/shared/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function AccountCard({
|
||||
accountName,
|
||||
accountType,
|
||||
balance,
|
||||
status,
|
||||
icon,
|
||||
excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome,
|
||||
onViewStatement,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onTransfer,
|
||||
className,
|
||||
}: AccountCardProps) {
|
||||
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");
|
||||
|
||||
return (
|
||||
<Card className={cn("h-full w-full 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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
313
src/features/accounts/components/account-dialog.tsx
Normal file
313
src/features/accounts/components/account-dialog.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createAccountAction,
|
||||
updateAccountAction,
|
||||
} from "@/features/accounts/actions";
|
||||
import {
|
||||
LogoPickerDialog,
|
||||
LogoPickerTrigger,
|
||||
} from "@/shared/components/logo-picker";
|
||||
import { useLogoSelection } from "@/shared/components/logo-picker/use-logo-selection";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
||||
import { useFormState } from "@/shared/hooks/use-form-state";
|
||||
import { deriveNameFromLogo, normalizeLogo } from "@/shared/lib/logo";
|
||||
import {
|
||||
formatInitialBalanceInput,
|
||||
normalizeDecimalInput,
|
||||
} from "@/shared/utils/currency";
|
||||
|
||||
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",
|
||||
] 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;
|
||||
}
|
||||
|
||||
const buildInitialValues = ({
|
||||
account,
|
||||
logoOptions,
|
||||
accountTypes,
|
||||
accountStatuses,
|
||||
}: {
|
||||
account?: Account;
|
||||
logoOptions: string[];
|
||||
accountTypes: string[];
|
||||
accountStatuses: string[];
|
||||
}): AccountFormValues => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
export function AccountDialog({
|
||||
mode,
|
||||
trigger,
|
||||
logoOptions,
|
||||
account,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AccountDialogProps) {
|
||||
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,
|
||||
);
|
||||
|
||||
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 initialState = useMemo(
|
||||
() =>
|
||||
buildInitialValues({
|
||||
account,
|
||||
logoOptions,
|
||||
accountTypes,
|
||||
accountStatuses,
|
||||
}),
|
||||
[account, logoOptions, accountTypes, accountStatuses],
|
||||
);
|
||||
|
||||
// Use form state hook for form management
|
||||
const { formState, resetForm, updateField, updateFields } =
|
||||
useFormState<AccountFormValues>(initialState);
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
resetForm(initialState);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [dialogOpen, initialState, resetForm]);
|
||||
|
||||
// Close logo dialog when main dialog closes
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
setErrorMessage(null);
|
||||
setLogoDialogOpen(false);
|
||||
}
|
||||
}, [dialogOpen]);
|
||||
|
||||
type AccountCreatePayload = Parameters<typeof createAccountAction>[0];
|
||||
|
||||
// Use logo selection hook
|
||||
const handleLogoSelection = useLogoSelection({
|
||||
mode,
|
||||
currentLogo: formState.logo,
|
||||
currentName: formState.name,
|
||||
onUpdate: (updates) => {
|
||||
updateFields(updates);
|
||||
// Delay closing to avoid race condition on mobile
|
||||
requestAnimationFrame(() => {
|
||||
setLogoDialogOpen(false);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
const accountId = account?.id;
|
||||
|
||||
if (mode === "update" && !accountId) {
|
||||
const message = "Conta inválida.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: AccountCreatePayload = {
|
||||
name: formState.name.trim(),
|
||||
accountType: formState.accountType,
|
||||
status: formState.status,
|
||||
note: formState.note.trim() || null,
|
||||
logo: formState.logo,
|
||||
initialBalance: Number(normalizeDecimalInput(formState.initialBalance)),
|
||||
excludeFromBalance: formState.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome:
|
||||
formState.excludeInitialBalanceFromIncome,
|
||||
};
|
||||
|
||||
if (!payload.logo) {
|
||||
setErrorMessage("Selecione um logo.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
if (mode === "create") {
|
||||
const result = await createAccountAction(payload);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
resetForm(initialState);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accountId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await updateAccountAction({
|
||||
id: accountId,
|
||||
...payload,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
resetForm(initialState);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
};
|
||||
|
||||
const title = mode === "create" ? "Nova conta" : "Editar conta";
|
||||
const description =
|
||||
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 handleMainDialogOpenChange = (open: boolean) => {
|
||||
if (!open && logoDialogOpen) {
|
||||
return;
|
||||
}
|
||||
setDialogOpen(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={dialogOpen} onOpenChange={handleMainDialogOpenChange}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent
|
||||
className="sm:max-w-xl"
|
||||
onPointerDownOutside={(e) => {
|
||||
if (logoDialogOpen) e.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (logoDialogOpen) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
<AccountFormFields
|
||||
values={formState}
|
||||
accountTypes={accountTypes}
|
||||
accountStatuses={accountStatuses}
|
||||
onChange={updateField}
|
||||
showInitialBalance={mode === "create"}
|
||||
/>
|
||||
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
154
src/features/accounts/components/account-form-fields.tsx
Normal file
154
src/features/accounts/components/account-form-fields.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { Textarea } from "@/shared/components/ui/textarea";
|
||||
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;
|
||||
}
|
||||
|
||||
export function AccountFormFields({
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
{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-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>
|
||||
);
|
||||
}
|
||||
16
src/features/accounts/components/account-select-items.tsx
Normal file
16
src/features/accounts/components/account-select-items.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import StatusDot from "@/shared/components/status-dot";
|
||||
|
||||
export function StatusSelectContent({ label }: { label: string }) {
|
||||
const isActive = label === "Ativa";
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusDot
|
||||
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
205
src/features/accounts/components/account-statement-card.tsx
Normal file
205
src/features/accounts/components/account-statement-card.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
import { RiInformationLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import type { ReactNode } from "react";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const getAccountStatusBadgeVariant = (
|
||||
status: string,
|
||||
): "success" | "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,
|
||||
}: AccountStatementCardProps) {
|
||||
const logoPath = resolveLogoSrc(logo);
|
||||
|
||||
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-full 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>
|
||||
|
||||
<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-success">
|
||||
{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-success"
|
||||
: "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>
|
||||
|
||||
{/* 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: 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>
|
||||
);
|
||||
}
|
||||
240
src/features/accounts/components/accounts-page.tsx
Normal file
240
src/features/accounts/components/accounts-page.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddCircleLine, RiBankLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { deleteAccountAction } from "@/features/accounts/actions";
|
||||
import { AccountCard } from "@/features/accounts/components/account-card";
|
||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||
import { EmptyState } from "@/shared/components/empty-state";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card } from "@/shared/components/ui/card";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/shared/components/ui/tabs";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { getCurrentPeriod } from "@/shared/utils/period";
|
||||
import { AccountDialog } from "./account-dialog";
|
||||
import { TransferDialog } from "./transfer-dialog";
|
||||
import type { Account } from "./types";
|
||||
|
||||
interface AccountsPageProps {
|
||||
accounts: Account[];
|
||||
archivedAccounts: Account[];
|
||||
logoOptions: string[];
|
||||
}
|
||||
|
||||
export function AccountsPage({
|
||||
accounts,
|
||||
archivedAccounts,
|
||||
logoOptions,
|
||||
}: AccountsPageProps) {
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState("ativos");
|
||||
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 sortAccounts = (list: Account[]) =>
|
||||
[...list].sort((a, b) =>
|
||||
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
|
||||
);
|
||||
|
||||
const orderedAccounts = sortAccounts(accounts);
|
||||
const orderedArchivedAccounts = sortAccounts(archivedAccounts);
|
||||
|
||||
const handleEdit = (account: Account) => {
|
||||
setSelectedAccount(account);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const handleEditOpenChange = (open: boolean) => {
|
||||
setEditOpen(open);
|
||||
if (!open) {
|
||||
setSelectedAccount(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRequest = (account: Account) => {
|
||||
setAccountToRemove(account);
|
||||
setRemoveOpen(true);
|
||||
};
|
||||
|
||||
const handleRemoveOpenChange = (open: boolean) => {
|
||||
setRemoveOpen(open);
|
||||
if (!open) {
|
||||
setAccountToRemove(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveConfirm = async () => {
|
||||
if (!accountToRemove) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deleteAccountAction({ id: accountToRemove.id });
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
};
|
||||
|
||||
const handleTransferRequest = (account: Account) => {
|
||||
setTransferFromAccount(account);
|
||||
setTransferOpen(true);
|
||||
};
|
||||
|
||||
const handleTransferOpenChange = (open: boolean) => {
|
||||
setTransferOpen(open);
|
||||
if (!open) {
|
||||
setTransferFromAccount(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTitle = accountToRemove
|
||||
? `Remover conta "${accountToRemove.name}"?`
|
||||
: "Remover conta?";
|
||||
|
||||
const renderAccountList = (list: Account[], isArchived: boolean) => {
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
||||
<EmptyState
|
||||
media={<RiBankLine className="size-6 text-primary" />}
|
||||
title={
|
||||
isArchived
|
||||
? "Nenhuma conta arquivada"
|
||||
: "Nenhuma conta cadastrada"
|
||||
}
|
||||
description={
|
||||
isArchived
|
||||
? "As contas arquivadas aparecerão aqui."
|
||||
: "Cadastre sua primeira conta para começar a organizar os lançamentos."
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{list.map((account) => {
|
||||
const logoSrc = resolveLogoSrc(account.logo) ?? undefined;
|
||||
|
||||
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-full"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onEdit={() => handleEdit(account)}
|
||||
onRemove={() => handleRemoveRequest(account)}
|
||||
onTransfer={() => handleTransferRequest(account)}
|
||||
onViewStatement={() =>
|
||||
router.push(`/accounts/${account.id}/statement`)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex">
|
||||
<AccountDialog
|
||||
mode="create"
|
||||
logoOptions={logoOptions}
|
||||
trigger={
|
||||
<Button className="w-full sm:w-auto">
|
||||
<RiAddCircleLine className="size-4" />
|
||||
Nova conta
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="ativos">Ativas</TabsTrigger>
|
||||
<TabsTrigger value="arquivados">Arquivadas</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ativos" className="mt-4">
|
||||
{renderAccountList(orderedAccounts, false)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="arquivados" className="mt-4">
|
||||
{renderAccountList(orderedArchivedAccounts, true)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
{transferFromAccount && (
|
||||
<TransferDialog
|
||||
accounts={accounts.map((a) => ({
|
||||
...a,
|
||||
balance: a.balance ?? a.initialBalance ?? 0,
|
||||
excludeFromBalance: a.excludeFromBalance ?? false,
|
||||
excludeInitialBalanceFromIncome:
|
||||
a.excludeInitialBalanceFromIncome ?? false,
|
||||
}))}
|
||||
fromAccountId={transferFromAccount.id}
|
||||
currentPeriod={getCurrentPeriod()}
|
||||
open={transferOpen}
|
||||
onOpenChange={handleTransferOpenChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
253
src/features/accounts/components/transfer-dialog.tsx
Normal file
253
src/features/accounts/components/transfer-dialog.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { transferBetweenAccountsAction } from "@/features/accounts/actions";
|
||||
import type { AccountData } from "@/features/accounts/queries";
|
||||
import { ContaCartaoSelectContent } from "@/features/transactions/components/select-items";
|
||||
import { PeriodPicker } from "@/shared/components/period-picker";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||
import { DatePicker } from "@/shared/components/ui/date-picker";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
||||
import { getTodayDateString } from "@/shared/utils/date";
|
||||
|
||||
interface TransferDialogProps {
|
||||
trigger?: React.ReactNode;
|
||||
accounts: AccountData[];
|
||||
fromAccountId: string;
|
||||
currentPeriod: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function TransferDialog({
|
||||
trigger,
|
||||
accounts,
|
||||
fromAccountId,
|
||||
currentPeriod,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TransferDialogProps) {
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
false,
|
||||
onOpenChange,
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
// Available destination accounts (exclude source account)
|
||||
const availableAccounts = accounts.filter(
|
||||
(account) => account.id !== fromAccountId,
|
||||
);
|
||||
|
||||
// Source account info
|
||||
const fromAccount = accounts.find((account) => account.id === fromAccountId);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (!toAccountId) {
|
||||
setErrorMessage("Selecione a conta de destino.");
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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 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="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>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
23
src/features/accounts/components/types.ts
Normal file
23
src/features/accounts/components/types.ts
Normal file
@@ -0,0 +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;
|
||||
};
|
||||
|
||||
export type AccountFormValues = {
|
||||
name: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
note: string;
|
||||
logo: string;
|
||||
initialBalance: string;
|
||||
excludeFromBalance: boolean;
|
||||
excludeInitialBalanceFromIncome: boolean;
|
||||
};
|
||||
Reference in New Issue
Block a user