mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
refactor(core): move app para src e padroniza estrutura
This commit is contained in:
394
src/features/accounts/actions.ts
Normal file
394
src/features/accounts/actions.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { categorias, contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
INITIAL_BALANCE_CATEGORY_NAME,
|
||||
INITIAL_BALANCE_CONDITION,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
INITIAL_BALANCE_PAYMENT_METHOD,
|
||||
INITIAL_BALANCE_TRANSACTION_TYPE,
|
||||
} from "@/shared/lib/accounts/constants";
|
||||
import {
|
||||
type ActionResult,
|
||||
handleActionError,
|
||||
revalidateForEntity,
|
||||
} from "@/shared/lib/actions/helpers";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
|
||||
import {
|
||||
TRANSFER_CATEGORY_NAME,
|
||||
TRANSFER_CONDITION,
|
||||
TRANSFER_ESTABLISHMENT_ENTRADA,
|
||||
TRANSFER_ESTABLISHMENT_SAIDA,
|
||||
TRANSFER_PAYMENT_METHOD,
|
||||
} from "@/shared/lib/transfers/constants";
|
||||
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
|
||||
import { getTodayInfo } from "@/shared/utils/date";
|
||||
import { normalizeFilePath } from "@/shared/utils/string";
|
||||
|
||||
const accountBaseSchema = z.object({
|
||||
name: z
|
||||
.string({ message: "Informe o nome da conta." })
|
||||
.trim()
|
||||
.min(1, "Informe o nome da conta."),
|
||||
accountType: z
|
||||
.string({ message: "Informe o tipo da conta." })
|
||||
.trim()
|
||||
.min(1, "Informe o tipo da conta."),
|
||||
status: z
|
||||
.string({ message: "Informe o status da conta." })
|
||||
.trim()
|
||||
.min(1, "Informe o status da conta."),
|
||||
note: noteSchema,
|
||||
logo: z
|
||||
.string({ message: "Selecione um logo." })
|
||||
.trim()
|
||||
.min(1, "Selecione um logo."),
|
||||
initialBalance: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
|
||||
.refine(
|
||||
(value) => !Number.isNaN(Number.parseFloat(value)),
|
||||
"Informe um saldo inicial válido.",
|
||||
)
|
||||
.transform((value) => Number.parseFloat(value)),
|
||||
excludeFromBalance: z
|
||||
.union([z.boolean(), z.string()])
|
||||
.transform((value) => value === true || value === "true"),
|
||||
excludeInitialBalanceFromIncome: z
|
||||
.union([z.boolean(), z.string()])
|
||||
.transform((value) => value === true || value === "true"),
|
||||
});
|
||||
|
||||
const createAccountSchema = accountBaseSchema;
|
||||
const updateAccountSchema = accountBaseSchema.extend({
|
||||
id: uuidSchema("Conta"),
|
||||
});
|
||||
const deleteAccountSchema = z.object({
|
||||
id: uuidSchema("Conta"),
|
||||
});
|
||||
|
||||
type AccountCreateInput = z.infer<typeof createAccountSchema>;
|
||||
type AccountUpdateInput = z.infer<typeof updateAccountSchema>;
|
||||
type AccountDeleteInput = z.infer<typeof deleteAccountSchema>;
|
||||
|
||||
export async function createAccountAction(
|
||||
input: AccountCreateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = createAccountSchema.parse(input);
|
||||
|
||||
const logoFile = normalizeFilePath(data.logo);
|
||||
|
||||
const normalizedInitialBalance = Math.abs(data.initialBalance);
|
||||
const hasInitialBalance = normalizedInitialBalance > 0;
|
||||
|
||||
await db.transaction(async (tx: typeof db) => {
|
||||
const [createdAccount] = await tx
|
||||
.insert(contas)
|
||||
.values({
|
||||
name: data.name,
|
||||
accountType: data.accountType,
|
||||
status: data.status,
|
||||
note: data.note ?? null,
|
||||
logo: logoFile,
|
||||
initialBalance: formatDecimalForDbRequired(data.initialBalance),
|
||||
excludeFromBalance: data.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
|
||||
userId: user.id,
|
||||
})
|
||||
.returning({ id: contas.id, name: contas.name });
|
||||
|
||||
if (!createdAccount) {
|
||||
throw new Error("Não foi possível criar a conta.");
|
||||
}
|
||||
|
||||
if (!hasInitialBalance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [category, adminPagador] = await Promise.all([
|
||||
tx.query.categorias.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(categorias.userId, user.id),
|
||||
eq(categorias.name, INITIAL_BALANCE_CATEGORY_NAME),
|
||||
),
|
||||
}),
|
||||
tx.query.pagadores.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(pagadores.userId, user.id),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!category) {
|
||||
throw new Error(
|
||||
'Categoria "Saldo inicial" não encontrada. Crie-a antes de definir um saldo inicial.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!adminPagador) {
|
||||
throw new Error(
|
||||
"Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.",
|
||||
);
|
||||
}
|
||||
|
||||
const { date, period } = getTodayInfo();
|
||||
|
||||
await tx.insert(lancamentos).values({
|
||||
condition: INITIAL_BALANCE_CONDITION,
|
||||
name: `Saldo inicial - ${createdAccount.name}`,
|
||||
paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD,
|
||||
note: INITIAL_BALANCE_NOTE,
|
||||
amount: formatDecimalForDbRequired(normalizedInitialBalance),
|
||||
purchaseDate: date,
|
||||
transactionType: INITIAL_BALANCE_TRANSACTION_TYPE,
|
||||
period,
|
||||
isSettled: true,
|
||||
userId: user.id,
|
||||
contaId: createdAccount.id,
|
||||
categoriaId: category.id,
|
||||
pagadorId: adminPagador.id,
|
||||
});
|
||||
});
|
||||
|
||||
revalidateForEntity("contas");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Conta criada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAccountAction(
|
||||
input: AccountUpdateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = updateAccountSchema.parse(input);
|
||||
|
||||
const logoFile = normalizeFilePath(data.logo);
|
||||
|
||||
const [updated] = await db
|
||||
.update(contas)
|
||||
.set({
|
||||
name: data.name,
|
||||
accountType: data.accountType,
|
||||
status: data.status,
|
||||
note: data.note ?? null,
|
||||
logo: logoFile,
|
||||
initialBalance: formatDecimalForDbRequired(data.initialBalance),
|
||||
excludeFromBalance: data.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
|
||||
})
|
||||
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Conta não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("contas");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Conta atualizada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAccountAction(
|
||||
input: AccountDeleteInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = deleteAccountSchema.parse(input);
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(contas)
|
||||
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
|
||||
.returning({ id: contas.id });
|
||||
|
||||
if (!deleted) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Conta não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("contas");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Conta removida com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer between accounts
|
||||
const transferSchema = z.object({
|
||||
fromAccountId: uuidSchema("Conta de origem"),
|
||||
toAccountId: uuidSchema("Conta de destino"),
|
||||
amount: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
|
||||
.refine(
|
||||
(value) => !Number.isNaN(Number.parseFloat(value)),
|
||||
"Informe um valor válido.",
|
||||
)
|
||||
.transform((value) => Number.parseFloat(value))
|
||||
.refine((value) => value > 0, "O valor deve ser maior que zero."),
|
||||
date: z.coerce.date({ message: "Informe uma data válida." }),
|
||||
period: z
|
||||
.string({ message: "Informe o período." })
|
||||
.trim()
|
||||
.min(1, "Informe o período."),
|
||||
});
|
||||
|
||||
type TransferInput = z.infer<typeof transferSchema>;
|
||||
|
||||
export async function transferBetweenAccountsAction(
|
||||
input: TransferInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = transferSchema.parse(input);
|
||||
|
||||
// Validate that accounts are different
|
||||
if (data.fromAccountId === data.toAccountId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "A conta de origem e destino devem ser diferentes.",
|
||||
};
|
||||
}
|
||||
|
||||
// Generate a unique transfer ID to link both transactions
|
||||
const transferId = crypto.randomUUID();
|
||||
|
||||
await db.transaction(async (tx: typeof db) => {
|
||||
// Verify both accounts exist and belong to the user
|
||||
const [fromAccount, toAccount] = await Promise.all([
|
||||
tx.query.contas.findFirst({
|
||||
columns: { id: true, name: true },
|
||||
where: and(
|
||||
eq(contas.id, data.fromAccountId),
|
||||
eq(contas.userId, user.id),
|
||||
),
|
||||
}),
|
||||
tx.query.contas.findFirst({
|
||||
columns: { id: true, name: true },
|
||||
where: and(
|
||||
eq(contas.id, data.toAccountId),
|
||||
eq(contas.userId, user.id),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!fromAccount) {
|
||||
throw new Error("Conta de origem não encontrada.");
|
||||
}
|
||||
|
||||
if (!toAccount) {
|
||||
throw new Error("Conta de destino não encontrada.");
|
||||
}
|
||||
|
||||
// Get the transfer category
|
||||
const transferCategory = await tx.query.categorias.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(categorias.userId, user.id),
|
||||
eq(categorias.name, TRANSFER_CATEGORY_NAME),
|
||||
),
|
||||
});
|
||||
|
||||
if (!transferCategory) {
|
||||
throw new Error(
|
||||
`Categoria "${TRANSFER_CATEGORY_NAME}" não encontrada. Por favor, crie esta categoria antes de fazer transferências.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the admin payer
|
||||
const adminPagador = await tx.query.pagadores.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(pagadores.userId, user.id),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
});
|
||||
|
||||
if (!adminPagador) {
|
||||
throw new Error(
|
||||
"Pagador administrador não encontrado. Por favor, crie um pagador admin.",
|
||||
);
|
||||
}
|
||||
|
||||
const transferNote = `de ${fromAccount.name} -> ${toAccount.name}`;
|
||||
|
||||
// Create outgoing transaction (transfer from source account)
|
||||
await tx.insert(lancamentos).values({
|
||||
condition: TRANSFER_CONDITION,
|
||||
name: TRANSFER_ESTABLISHMENT_SAIDA,
|
||||
paymentMethod: TRANSFER_PAYMENT_METHOD,
|
||||
note: transferNote,
|
||||
amount: formatDecimalForDbRequired(-Math.abs(data.amount)),
|
||||
purchaseDate: data.date,
|
||||
transactionType: "Transferência",
|
||||
period: data.period,
|
||||
isSettled: true,
|
||||
userId: user.id,
|
||||
contaId: fromAccount.id,
|
||||
categoriaId: transferCategory.id,
|
||||
pagadorId: adminPagador.id,
|
||||
transferId,
|
||||
});
|
||||
|
||||
// Create incoming transaction (transfer to destination account)
|
||||
await tx.insert(lancamentos).values({
|
||||
condition: TRANSFER_CONDITION,
|
||||
name: TRANSFER_ESTABLISHMENT_ENTRADA,
|
||||
paymentMethod: TRANSFER_PAYMENT_METHOD,
|
||||
note: transferNote,
|
||||
amount: formatDecimalForDbRequired(Math.abs(data.amount)),
|
||||
purchaseDate: data.date,
|
||||
transactionType: "Transferência",
|
||||
period: data.period,
|
||||
isSettled: true,
|
||||
userId: user.id,
|
||||
contaId: toAccount.id,
|
||||
categoriaId: transferCategory.id,
|
||||
pagadorId: adminPagador.id,
|
||||
transferId,
|
||||
});
|
||||
});
|
||||
|
||||
revalidateForEntity("contas");
|
||||
revalidateForEntity("lancamentos");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Transferência registrada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
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;
|
||||
};
|
||||
188
src/features/accounts/queries.ts
Normal file
188
src/features/accounts/queries.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { and, eq, ilike, not, sql } from "drizzle-orm";
|
||||
import { contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { loadLogoOptions } from "@/shared/lib/logo/options";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||
|
||||
export type AccountData = {
|
||||
id: string;
|
||||
name: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
note: string | null;
|
||||
logo: string | null;
|
||||
initialBalance: number;
|
||||
balance: number;
|
||||
excludeFromBalance: boolean;
|
||||
excludeInitialBalanceFromIncome: boolean;
|
||||
};
|
||||
|
||||
export async function fetchAccountsForUser(
|
||||
userId: string,
|
||||
): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> {
|
||||
const [accountRows, logoOptions] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: contas.id,
|
||||
name: contas.name,
|
||||
accountType: contas.accountType,
|
||||
status: contas.status,
|
||||
note: contas.note,
|
||||
logo: contas.logo,
|
||||
initialBalance: contas.initialBalance,
|
||||
excludeFromBalance: contas.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
|
||||
balanceMovements: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||
else ${lancamentos.amount}
|
||||
end
|
||||
),
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(contas)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.contaId, contas.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.isSettled, true),
|
||||
),
|
||||
)
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(contas.userId, userId),
|
||||
not(ilike(contas.status, "inativa")),
|
||||
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
contas.id,
|
||||
contas.name,
|
||||
contas.accountType,
|
||||
contas.status,
|
||||
contas.note,
|
||||
contas.logo,
|
||||
contas.initialBalance,
|
||||
contas.excludeFromBalance,
|
||||
contas.excludeInitialBalanceFromIncome,
|
||||
),
|
||||
loadLogoOptions(),
|
||||
]);
|
||||
|
||||
const accounts = accountRows.map((account) => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
accountType: account.accountType,
|
||||
status: account.status,
|
||||
note: account.note,
|
||||
logo: account.logo,
|
||||
initialBalance: Number(account.initialBalance ?? 0),
|
||||
balance:
|
||||
Number(account.initialBalance ?? 0) +
|
||||
Number(account.balanceMovements ?? 0),
|
||||
excludeFromBalance: account.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
|
||||
}));
|
||||
|
||||
return { accounts, logoOptions };
|
||||
}
|
||||
|
||||
export async function fetchInativosForUser(
|
||||
userId: string,
|
||||
): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> {
|
||||
const [accountRows, logoOptions] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: contas.id,
|
||||
name: contas.name,
|
||||
accountType: contas.accountType,
|
||||
status: contas.status,
|
||||
note: contas.note,
|
||||
logo: contas.logo,
|
||||
initialBalance: contas.initialBalance,
|
||||
excludeFromBalance: contas.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
|
||||
balanceMovements: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||
else ${lancamentos.amount}
|
||||
end
|
||||
),
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(contas)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.contaId, contas.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.isSettled, true),
|
||||
),
|
||||
)
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(contas.userId, userId),
|
||||
ilike(contas.status, "inativa"),
|
||||
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
contas.id,
|
||||
contas.name,
|
||||
contas.accountType,
|
||||
contas.status,
|
||||
contas.note,
|
||||
contas.logo,
|
||||
contas.initialBalance,
|
||||
contas.excludeFromBalance,
|
||||
contas.excludeInitialBalanceFromIncome,
|
||||
),
|
||||
loadLogoOptions(),
|
||||
]);
|
||||
|
||||
const accounts = accountRows.map((account) => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
accountType: account.accountType,
|
||||
status: account.status,
|
||||
note: account.note,
|
||||
logo: account.logo,
|
||||
initialBalance: Number(account.initialBalance ?? 0),
|
||||
balance:
|
||||
Number(account.initialBalance ?? 0) +
|
||||
Number(account.balanceMovements ?? 0),
|
||||
excludeFromBalance: account.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
|
||||
}));
|
||||
|
||||
return { accounts, logoOptions };
|
||||
}
|
||||
|
||||
export async function fetchAllAccountsForUser(userId: string): Promise<{
|
||||
activeAccounts: AccountData[];
|
||||
archivedAccounts: AccountData[];
|
||||
logoOptions: LogoOption[];
|
||||
}> {
|
||||
const [activeData, archivedData] = await Promise.all([
|
||||
fetchAccountsForUser(userId),
|
||||
fetchInativosForUser(userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
activeAccounts: activeData.accounts,
|
||||
archivedAccounts: archivedData.accounts,
|
||||
logoOptions: activeData.logoOptions,
|
||||
};
|
||||
}
|
||||
151
src/features/accounts/statement-queries.ts
Normal file
151
src/features/accounts/statement-queries.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { and, desc, eq, lt, type SQL, sql } from "drizzle-orm";
|
||||
import { contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||
|
||||
export type AccountSummaryData = {
|
||||
openingBalance: number;
|
||||
currentBalance: number;
|
||||
totalIncomes: number;
|
||||
totalExpenses: number;
|
||||
};
|
||||
|
||||
export async function fetchAccountData(userId: string, contaId: string) {
|
||||
const account = await db.query.contas.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
accountType: true,
|
||||
status: true,
|
||||
initialBalance: true,
|
||||
logo: true,
|
||||
note: true,
|
||||
},
|
||||
where: and(eq(contas.id, contaId), eq(contas.userId, userId)),
|
||||
});
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
export async function fetchAccountSummary(
|
||||
userId: string,
|
||||
contaId: string,
|
||||
selectedPeriod: string,
|
||||
): Promise<AccountSummaryData> {
|
||||
const [periodSummary] = await db
|
||||
.select({
|
||||
netAmount: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||
else ${lancamentos.amount}
|
||||
end
|
||||
),
|
||||
0
|
||||
)
|
||||
`,
|
||||
incomes: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||
when ${lancamentos.transactionType} = 'Receita' then ${lancamentos.amount}
|
||||
else 0
|
||||
end
|
||||
),
|
||||
0
|
||||
)
|
||||
`,
|
||||
expenses: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||
when ${lancamentos.transactionType} = 'Despesa' then ${lancamentos.amount}
|
||||
else 0
|
||||
end
|
||||
),
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.contaId, contaId),
|
||||
eq(lancamentos.period, selectedPeriod),
|
||||
eq(lancamentos.isSettled, true),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
);
|
||||
|
||||
const [previousRow] = await db
|
||||
.select({
|
||||
previousMovements: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||
else ${lancamentos.amount}
|
||||
end
|
||||
),
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.contaId, contaId),
|
||||
lt(lancamentos.period, selectedPeriod),
|
||||
eq(lancamentos.isSettled, true),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
);
|
||||
|
||||
const account = await fetchAccountData(userId, contaId);
|
||||
if (!account) {
|
||||
throw new Error("Account not found");
|
||||
}
|
||||
|
||||
const initialBalance = Number(account.initialBalance ?? 0);
|
||||
const previousMovements = Number(previousRow?.previousMovements ?? 0);
|
||||
const openingBalance = initialBalance + previousMovements;
|
||||
const netAmount = Number(periodSummary?.netAmount ?? 0);
|
||||
const totalIncomes = Number(periodSummary?.incomes ?? 0);
|
||||
const totalExpenses = Math.abs(Number(periodSummary?.expenses ?? 0));
|
||||
const currentBalance = openingBalance + netAmount;
|
||||
|
||||
return {
|
||||
openingBalance,
|
||||
currentBalance,
|
||||
totalIncomes,
|
||||
totalExpenses,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAccountLancamentos(
|
||||
filters: SQL[],
|
||||
settledOnly = true,
|
||||
) {
|
||||
const allFilters = settledOnly
|
||||
? [...filters, eq(lancamentos.isSettled, true)]
|
||||
: filters;
|
||||
|
||||
return db.query.lancamentos.findMany({
|
||||
where: and(...allFilters),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
orderBy: desc(lancamentos.purchaseDate),
|
||||
});
|
||||
}
|
||||
17
src/features/auth/components/auth-error-alert.tsx
Normal file
17
src/features/auth/components/auth-error-alert.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { RiTerminalLine } from "@remixicon/react";
|
||||
import { Alert, AlertDescription } from "@/shared/components/ui/alert";
|
||||
|
||||
interface AuthErrorAlertProps {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export function AuthErrorAlert({ error }: AuthErrorAlertProps) {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<Alert className="mt-2 border border-destructive" variant="destructive">
|
||||
<RiTerminalLine className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
15
src/features/auth/components/auth-header.tsx
Normal file
15
src/features/auth/components/auth-header.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
interface AuthHeaderProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function AuthHeader({ title }: AuthHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5")}>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-card-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/features/auth/components/auth-sidebar.tsx
Normal file
19
src/features/auth/components/auth-sidebar.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
function AuthSidebar() {
|
||||
return (
|
||||
<div className="relative hidden flex-col overflow-hidden bg-primary md:flex">
|
||||
<div className="relative flex flex-1 flex-col justify-between p-8">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-3xl font-semibold leading-tight">
|
||||
Controle suas finanças com clareza e foco diário.
|
||||
</h2>
|
||||
<p className="text-sm opacity-90">
|
||||
Centralize despesas, organize cartões e acompanhe metas mensais em
|
||||
um painel inteligente feito para o seu dia a dia.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthSidebar;
|
||||
54
src/features/auth/components/google-auth-button.tsx
Normal file
54
src/features/auth/components/google-auth-button.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { RiLoader4Line } from "@remixicon/react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
interface GoogleAuthButtonProps {
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export function GoogleAuthButton({
|
||||
onClick,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
text = "Continuar com Google",
|
||||
}: GoogleAuthButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span>{text}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
248
src/features/auth/components/login-form.tsx
Normal file
248
src/features/auth/components/login-form.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
import { RiFingerprintLine, RiLoader4Line } from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Logo } from "@/shared/components/logo";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
} from "@/shared/components/ui/field";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { authClient, googleSignInAvailable } from "@/shared/lib/auth/client";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import { AuthErrorAlert } from "./auth-error-alert";
|
||||
import { AuthHeader } from "./auth-header";
|
||||
import AuthSidebar from "./auth-sidebar";
|
||||
import { GoogleAuthButton } from "./google-auth-button";
|
||||
|
||||
type DivProps = React.ComponentProps<"div">;
|
||||
|
||||
export function LoginForm({ className, ...props }: DivProps) {
|
||||
const router = useRouter();
|
||||
const isGoogleAvailable = googleSignInAvailable;
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [loadingEmail, setLoadingEmail] = useState(false);
|
||||
const [loadingGoogle, setLoadingGoogle] = useState(false);
|
||||
const [loadingPasskey, setLoadingPasskey] = useState(false);
|
||||
const [passkeySupported, setPasskeySupported] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (typeof PublicKeyCredential === "undefined") return;
|
||||
|
||||
setPasskeySupported(true);
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
await authClient.signIn.email(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
callbackURL: "/dashboard",
|
||||
rememberMe: false,
|
||||
},
|
||||
{
|
||||
onRequest: () => {
|
||||
setError("");
|
||||
setLoadingEmail(true);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setLoadingEmail(false);
|
||||
toast.success("Login realizado com sucesso!");
|
||||
router.replace("/dashboard");
|
||||
},
|
||||
onError: (ctx) => {
|
||||
if (
|
||||
ctx.error.status === 500 &&
|
||||
ctx.error.statusText === "Internal Server Error"
|
||||
) {
|
||||
toast.error(
|
||||
"Ocorreu uma falha na requisição. Tente novamente mais tarde.",
|
||||
);
|
||||
}
|
||||
|
||||
setError(ctx.error.message);
|
||||
setLoadingEmail(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGoogle() {
|
||||
if (!isGoogleAvailable) {
|
||||
setError("Login com Google não está disponível no momento.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ativa loading antes de iniciar o fluxo OAuth
|
||||
setError("");
|
||||
setLoadingGoogle(true);
|
||||
|
||||
// OAuth redirect - o loading permanece até a página ser redirecionada
|
||||
await authClient.signIn.social(
|
||||
{
|
||||
provider: "google",
|
||||
callbackURL: "/dashboard",
|
||||
},
|
||||
{
|
||||
onError: (ctx) => {
|
||||
// Só desativa loading se houver erro
|
||||
setError(ctx.error.message);
|
||||
setLoadingGoogle(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePasskey() {
|
||||
setError("");
|
||||
setLoadingPasskey(true);
|
||||
|
||||
const { error: passkeyError } = await authClient.signIn.passkey({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
setLoadingPasskey(false);
|
||||
router.replace("/dashboard");
|
||||
},
|
||||
onError: (ctx) => {
|
||||
setError(ctx.error.message);
|
||||
setLoadingPasskey(false);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (passkeyError) {
|
||||
setError(passkeyError.message || "Erro ao entrar com passkey.");
|
||||
setLoadingPasskey(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Logo className="mb-2" />
|
||||
<Card className="overflow-hidden p-0">
|
||||
<CardContent className="grid gap-0 p-0 md:grid-cols-[1.05fr_0.95fr]">
|
||||
<form
|
||||
className="flex flex-col gap-6 p-6 md:p-8"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<FieldGroup className="gap-4">
|
||||
<AuthHeader title="Entrar no OpenMonetis" />
|
||||
|
||||
<AuthErrorAlert error={error} />
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">E-mail</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Digite seu e-mail"
|
||||
autoComplete="username webauthn"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<div className="flex items-center">
|
||||
<FieldLabel htmlFor="password">Senha</FieldLabel>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
placeholder="Digite sua senha"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loadingEmail || loadingGoogle || loadingPasskey}
|
||||
className="w-full"
|
||||
>
|
||||
{loadingEmail ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Entrar"
|
||||
)}
|
||||
</Button>
|
||||
</Field>
|
||||
|
||||
<FieldSeparator className="my-2 *:data-[slot=field-separator-content]:bg-card">
|
||||
Ou continue com
|
||||
</FieldSeparator>
|
||||
|
||||
<Field>
|
||||
<GoogleAuthButton
|
||||
onClick={handleGoogle}
|
||||
loading={loadingGoogle}
|
||||
disabled={
|
||||
loadingEmail ||
|
||||
loadingGoogle ||
|
||||
loadingPasskey ||
|
||||
!isGoogleAvailable
|
||||
}
|
||||
text="Entrar com Google"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{passkeySupported && (
|
||||
<Field>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={handlePasskey}
|
||||
disabled={loadingEmail || loadingGoogle || loadingPasskey}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{loadingPasskey ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiFingerprintLine className="h-5 w-5" />
|
||||
)}
|
||||
<span>Entrar com passkey</span>
|
||||
</Button>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<FieldDescription className="text-center">
|
||||
Não tem uma conta?{" "}
|
||||
<a href="/signup" className="underline underline-offset-4">
|
||||
Inscreva-se
|
||||
</a>
|
||||
</FieldDescription>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<AuthSidebar />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<FieldDescription className="text-center">
|
||||
<a href="/" className="underline underline-offset-4">
|
||||
Voltar para o site
|
||||
</a>
|
||||
</FieldDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
src/features/auth/components/signup-form.tsx
Normal file
291
src/features/auth/components/signup-form.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
import { RiCheckLine, RiCloseLine, RiLoader4Line } from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Logo } from "@/shared/components/logo";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
} from "@/shared/components/ui/field";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { authClient, googleSignInAvailable } from "@/shared/lib/auth/client";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import { AuthErrorAlert } from "./auth-error-alert";
|
||||
import { AuthHeader } from "./auth-header";
|
||||
import AuthSidebar from "./auth-sidebar";
|
||||
import { GoogleAuthButton } from "./google-auth-button";
|
||||
|
||||
interface PasswordValidation {
|
||||
hasLowercase: boolean;
|
||||
hasUppercase: boolean;
|
||||
hasNumber: boolean;
|
||||
hasSpecial: boolean;
|
||||
hasMinLength: boolean;
|
||||
hasMaxLength: boolean;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
function validatePassword(password: string): PasswordValidation {
|
||||
const hasLowercase = /[a-z]/.test(password);
|
||||
const hasUppercase = /[A-Z]/.test(password);
|
||||
const hasNumber = /\d/.test(password);
|
||||
const hasSpecial = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password);
|
||||
const hasMinLength = password.length >= 7;
|
||||
const hasMaxLength = password.length <= 23;
|
||||
|
||||
return {
|
||||
hasLowercase,
|
||||
hasUppercase,
|
||||
hasNumber,
|
||||
hasSpecial,
|
||||
hasMinLength,
|
||||
hasMaxLength,
|
||||
isValid:
|
||||
hasLowercase &&
|
||||
hasUppercase &&
|
||||
hasNumber &&
|
||||
hasSpecial &&
|
||||
hasMinLength &&
|
||||
hasMaxLength,
|
||||
};
|
||||
}
|
||||
|
||||
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs transition-colors",
|
||||
met ? "text-success" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{met ? (
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DivProps = React.ComponentProps<"div">;
|
||||
|
||||
export function SignupForm({ className, ...props }: DivProps) {
|
||||
const router = useRouter();
|
||||
const isGoogleAvailable = googleSignInAvailable;
|
||||
|
||||
const [fullname, setFullname] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [loadingEmail, setLoadingEmail] = useState(false);
|
||||
const [loadingGoogle, setLoadingGoogle] = useState(false);
|
||||
|
||||
const passwordValidation = validatePassword(password);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!passwordValidation.isValid) {
|
||||
setError("A senha não atende aos requisitos de segurança.");
|
||||
return;
|
||||
}
|
||||
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
name: fullname,
|
||||
},
|
||||
{
|
||||
onRequest: () => {
|
||||
setError("");
|
||||
setLoadingEmail(true);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setLoadingEmail(false);
|
||||
toast.success("Conta criada com sucesso!");
|
||||
router.replace("/dashboard");
|
||||
},
|
||||
onError: (ctx) => {
|
||||
setError(ctx.error.message);
|
||||
setLoadingEmail(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGoogle() {
|
||||
if (!isGoogleAvailable) {
|
||||
setError("Login com Google não está disponível no momento.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ativa loading antes de iniciar o fluxo OAuth
|
||||
setError("");
|
||||
setLoadingGoogle(true);
|
||||
|
||||
// OAuth redirect - o loading permanece até a página ser redirecionada
|
||||
await authClient.signIn.social(
|
||||
{
|
||||
provider: "google",
|
||||
callbackURL: "/dashboard",
|
||||
},
|
||||
{
|
||||
onError: (ctx) => {
|
||||
// Só desativa loading se houver erro
|
||||
setError(ctx.error.message);
|
||||
setLoadingGoogle(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Logo className="mb-2" />
|
||||
<Card className="overflow-hidden p-0">
|
||||
<CardContent className="grid gap-0 p-0 md:grid-cols-[1.05fr_0.95fr]">
|
||||
<form
|
||||
className="flex flex-col gap-6 p-6 md:p-8"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<FieldGroup className="gap-4">
|
||||
<AuthHeader title="Criar sua conta" />
|
||||
|
||||
<AuthErrorAlert error={error} />
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="name">Nome completo</FieldLabel>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Digite seu nome"
|
||||
autoComplete="name"
|
||||
required
|
||||
value={fullname}
|
||||
onChange={(e) => setFullname(e.target.value)}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">E-mail</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Digite seu e-mail"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">Senha</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
placeholder="Crie uma senha forte"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
aria-invalid={
|
||||
!!error ||
|
||||
(password.length > 0 && !passwordValidation.isValid)
|
||||
}
|
||||
maxLength={23}
|
||||
/>
|
||||
{password.length > 0 && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasMinLength}
|
||||
label="Mínimo 7 caracteres"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasMaxLength}
|
||||
label="Máximo 23 caracteres"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasLowercase}
|
||||
label="Letra minúscula"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasUppercase}
|
||||
label="Letra maiúscula"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasNumber}
|
||||
label="Número"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasSpecial}
|
||||
label="Caractere especial"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
loadingEmail ||
|
||||
loadingGoogle ||
|
||||
(password.length > 0 && !passwordValidation.isValid)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{loadingEmail ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Criar conta"
|
||||
)}
|
||||
</Button>
|
||||
</Field>
|
||||
|
||||
<FieldSeparator className="my-2 *:data-[slot=field-separator-content]:bg-card">
|
||||
Ou continue com
|
||||
</FieldSeparator>
|
||||
|
||||
<Field>
|
||||
<GoogleAuthButton
|
||||
onClick={handleGoogle}
|
||||
loading={loadingGoogle}
|
||||
disabled={loadingEmail || loadingGoogle || !isGoogleAvailable}
|
||||
text="Continuar com Google"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<FieldDescription className="text-center">
|
||||
Já tem uma conta?{" "}
|
||||
<a href="/login" className="underline underline-offset-4">
|
||||
Entrar
|
||||
</a>
|
||||
</FieldDescription>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<AuthSidebar />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<FieldDescription className="text-center">
|
||||
<a href="/" className="underline underline-offset-4">
|
||||
Voltar para o site
|
||||
</a>
|
||||
</FieldDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
src/features/budgets/actions.ts
Normal file
272
src/features/budgets/actions.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { categorias, orcamentos } from "@/db/schema";
|
||||
import {
|
||||
handleActionError,
|
||||
revalidateForEntity,
|
||||
} from "@/shared/lib/actions/helpers";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { periodSchema, uuidSchema } from "@/shared/lib/schemas/common";
|
||||
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||
import {
|
||||
formatDecimalForDbRequired,
|
||||
normalizeDecimalInput,
|
||||
} from "@/shared/utils/currency";
|
||||
import { getPreviousPeriod } from "@/shared/utils/period";
|
||||
|
||||
const budgetBaseSchema = z.object({
|
||||
categoriaId: uuidSchema("Categoria"),
|
||||
period: periodSchema,
|
||||
amount: z
|
||||
.string({ message: "Informe o valor limite." })
|
||||
.trim()
|
||||
.min(1, "Informe o valor limite.")
|
||||
.transform((value) => normalizeDecimalInput(value))
|
||||
.refine(
|
||||
(value) => !Number.isNaN(Number.parseFloat(value)),
|
||||
"Informe um valor limite válido.",
|
||||
)
|
||||
.transform((value) => Number.parseFloat(value))
|
||||
.refine(
|
||||
(value) => value >= 0,
|
||||
"O valor limite deve ser maior ou igual a zero.",
|
||||
),
|
||||
});
|
||||
|
||||
const createBudgetSchema = budgetBaseSchema;
|
||||
const updateBudgetSchema = budgetBaseSchema.extend({
|
||||
id: uuidSchema("Orçamento"),
|
||||
});
|
||||
const deleteBudgetSchema = z.object({
|
||||
id: uuidSchema("Orçamento"),
|
||||
});
|
||||
|
||||
type BudgetCreateInput = z.input<typeof createBudgetSchema>;
|
||||
type BudgetUpdateInput = z.input<typeof updateBudgetSchema>;
|
||||
type BudgetDeleteInput = z.input<typeof deleteBudgetSchema>;
|
||||
type BudgetCopyRow = {
|
||||
categoriaId: string | null;
|
||||
amount: unknown;
|
||||
};
|
||||
|
||||
const ensureCategory = async (userId: string, categoriaId: string) => {
|
||||
const category = await db.query.categorias.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
type: true,
|
||||
},
|
||||
where: and(eq(categorias.id, categoriaId), eq(categorias.userId, userId)),
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
throw new Error("Categoria não encontrada.");
|
||||
}
|
||||
|
||||
if (category.type !== "despesa") {
|
||||
throw new Error("Selecione uma categoria de despesa.");
|
||||
}
|
||||
};
|
||||
|
||||
export async function createBudgetAction(
|
||||
input: BudgetCreateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = createBudgetSchema.parse(input);
|
||||
|
||||
await ensureCategory(user.id, data.categoriaId);
|
||||
|
||||
const duplicateConditions = [
|
||||
eq(orcamentos.userId, user.id),
|
||||
eq(orcamentos.period, data.period),
|
||||
eq(orcamentos.categoriaId, data.categoriaId),
|
||||
] as const;
|
||||
|
||||
const duplicate = await db.query.orcamentos.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(...duplicateConditions),
|
||||
});
|
||||
|
||||
if (duplicate) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Já existe um orçamento para esta categoria no período selecionado.",
|
||||
};
|
||||
}
|
||||
|
||||
await db.insert(orcamentos).values({
|
||||
amount: formatDecimalForDbRequired(data.amount),
|
||||
period: data.period,
|
||||
userId: user.id,
|
||||
categoriaId: data.categoriaId,
|
||||
});
|
||||
|
||||
revalidateForEntity("orcamentos");
|
||||
|
||||
return { success: true, message: "Orçamento criado com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateBudgetAction(
|
||||
input: BudgetUpdateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = updateBudgetSchema.parse(input);
|
||||
|
||||
await ensureCategory(user.id, data.categoriaId);
|
||||
|
||||
const duplicateConditions = [
|
||||
eq(orcamentos.userId, user.id),
|
||||
eq(orcamentos.period, data.period),
|
||||
eq(orcamentos.categoriaId, data.categoriaId),
|
||||
ne(orcamentos.id, data.id),
|
||||
] as const;
|
||||
|
||||
const duplicate = await db.query.orcamentos.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(...duplicateConditions),
|
||||
});
|
||||
|
||||
if (duplicate) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Já existe um orçamento para esta categoria no período selecionado.",
|
||||
};
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(orcamentos)
|
||||
.set({
|
||||
amount: formatDecimalForDbRequired(data.amount),
|
||||
period: data.period,
|
||||
categoriaId: data.categoriaId,
|
||||
})
|
||||
.where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id)))
|
||||
.returning({ id: orcamentos.id });
|
||||
|
||||
if (!updated) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Orçamento não encontrado.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("orcamentos");
|
||||
|
||||
return { success: true, message: "Orçamento atualizado com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteBudgetAction(
|
||||
input: BudgetDeleteInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = deleteBudgetSchema.parse(input);
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(orcamentos)
|
||||
.where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id)))
|
||||
.returning({ id: orcamentos.id });
|
||||
|
||||
if (!deleted) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Orçamento não encontrado.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("orcamentos");
|
||||
|
||||
return { success: true, message: "Orçamento removido com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
const duplicatePreviousMonthSchema = z.object({
|
||||
period: periodSchema,
|
||||
});
|
||||
|
||||
type DuplicatePreviousMonthInput = z.input<typeof duplicatePreviousMonthSchema>;
|
||||
|
||||
export async function duplicatePreviousMonthBudgetsAction(
|
||||
input: DuplicatePreviousMonthInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = duplicatePreviousMonthSchema.parse(input);
|
||||
|
||||
// Calcular mês anterior
|
||||
const previousPeriod = getPreviousPeriod(data.period);
|
||||
|
||||
// Buscar orçamentos do mês anterior
|
||||
const previousBudgets = (await db.query.orcamentos.findMany({
|
||||
where: and(
|
||||
eq(orcamentos.userId, user.id),
|
||||
eq(orcamentos.period, previousPeriod),
|
||||
),
|
||||
})) as BudgetCopyRow[];
|
||||
|
||||
if (previousBudgets.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Não foram encontrados orçamentos no mês anterior.",
|
||||
};
|
||||
}
|
||||
|
||||
// Buscar orçamentos existentes do mês atual
|
||||
const currentBudgets = (await db.query.orcamentos.findMany({
|
||||
where: and(
|
||||
eq(orcamentos.userId, user.id),
|
||||
eq(orcamentos.period, data.period),
|
||||
),
|
||||
})) as BudgetCopyRow[];
|
||||
|
||||
// Filtrar para evitar duplicatas
|
||||
const existingCategoryIds = new Set(
|
||||
currentBudgets.map((b) => b.categoriaId),
|
||||
);
|
||||
|
||||
const budgetsToCopy = previousBudgets.filter(
|
||||
(b) => b.categoriaId && !existingCategoryIds.has(b.categoriaId),
|
||||
);
|
||||
|
||||
if (budgetsToCopy.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Todas as categorias do mês anterior já possuem orçamento neste mês.",
|
||||
};
|
||||
}
|
||||
|
||||
// Inserir novos orçamentos
|
||||
await db.insert(orcamentos).values(
|
||||
budgetsToCopy.map((b) => ({
|
||||
amount: b.amount,
|
||||
period: data.period,
|
||||
userId: user.id,
|
||||
categoriaId: b.categoriaId as string,
|
||||
})),
|
||||
);
|
||||
|
||||
revalidateForEntity("orcamentos");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${budgetsToCopy.length} orçamento${budgetsToCopy.length > 1 ? "s" : ""} duplicado${budgetsToCopy.length > 1 ? "s" : ""} com sucesso.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
124
src/features/budgets/components/budget-card.tsx
Normal file
124
src/features/budgets/components/budget-card.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiDeleteBin5Line,
|
||||
RiFileList2Line,
|
||||
RiPencilLine,
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import { CategoryIconBadge } from "@/features/categories/components/category-icon-badge";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Card, CardContent, CardFooter } from "@/shared/components/ui/card";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import type { Budget } from "./types";
|
||||
|
||||
interface BudgetCardProps {
|
||||
budget: Budget;
|
||||
colorIndex: number;
|
||||
periodLabel: string;
|
||||
onEdit: (budget: Budget) => void;
|
||||
onRemove: (budget: Budget) => void;
|
||||
}
|
||||
|
||||
const buildUsagePercent = (spent: number, limit: number) => {
|
||||
if (limit <= 0) {
|
||||
return spent > 0 ? 100 : 0;
|
||||
}
|
||||
const percent = (spent / limit) * 100;
|
||||
return Math.min(Math.max(percent, 0), 100);
|
||||
};
|
||||
|
||||
const formatCategoryName = (budget: Budget) =>
|
||||
budget.category?.name ?? "Categoria removida";
|
||||
|
||||
export function BudgetCard({
|
||||
budget,
|
||||
colorIndex,
|
||||
periodLabel,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: BudgetCardProps) {
|
||||
const { amount: limit, spent } = budget;
|
||||
const exceeded = spent > limit && limit >= 0;
|
||||
const difference = Math.abs(spent - limit);
|
||||
const usagePercent = buildUsagePercent(spent, limit);
|
||||
|
||||
return (
|
||||
<Card className="flex h-full flex-col">
|
||||
<CardContent className="flex h-full flex-col gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CategoryIconBadge
|
||||
icon={budget.category?.icon ?? undefined}
|
||||
name={formatCategoryName(budget)}
|
||||
colorIndex={colorIndex}
|
||||
size="lg"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold leading-tight">
|
||||
{formatCategoryName(budget)}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Orçamento de {periodLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="flex items-baseline justify-between text-sm">
|
||||
<span className="text-muted-foreground">Gasto até agora</span>
|
||||
<MoneyValues
|
||||
amount={spent}
|
||||
className={cn(exceeded && "text-destructive")}
|
||||
/>
|
||||
</div>
|
||||
<Progress
|
||||
value={usagePercent}
|
||||
className={cn("h-2", exceeded && "bg-destructive/20!")}
|
||||
/>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-1 text-sm">
|
||||
<span className="text-muted-foreground">Limite</span>
|
||||
<MoneyValues amount={limit} className="text-foreground" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{exceeded ? (
|
||||
<div className="text-xs text-destructive">
|
||||
Excedeu em <MoneyValues amount={difference} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-success">
|
||||
Restam <MoneyValues amount={Math.max(limit - spent, 0)} />{" "}
|
||||
disponíveis.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-wrap gap-3 px-5 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(budget)}
|
||||
className="flex items-center gap-1 text-primary font-medium transition-opacity hover:opacity-80"
|
||||
>
|
||||
<RiPencilLine className="size-4" aria-hidden /> editar
|
||||
</button>
|
||||
{budget.category && (
|
||||
<Link
|
||||
href={`/categories/${budget.category.id}`}
|
||||
className="flex items-center gap-1 text-primary font-medium transition-opacity hover:opacity-80"
|
||||
>
|
||||
<RiFileList2Line className="size-4" aria-hidden /> detalhes
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(budget)}
|
||||
className="flex items-center gap-1 text-destructive font-medium transition-opacity hover:opacity-80"
|
||||
>
|
||||
<RiDeleteBin5Line className="size-4" aria-hidden /> remover
|
||||
</button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
295
src/features/budgets/components/budget-dialog.tsx
Normal file
295
src/features/budgets/components/budget-dialog.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createBudgetAction,
|
||||
updateBudgetAction,
|
||||
} from "@/features/budgets/actions";
|
||||
import { CategoryIcon } from "@/features/categories/components/category-icon";
|
||||
import { PeriodPicker } from "@/shared/components/period-picker";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
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 { Slider } from "@/shared/components/ui/slider";
|
||||
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
||||
import { useFormState } from "@/shared/hooks/use-form-state";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
|
||||
import type { Budget, BudgetCategory, BudgetFormValues } from "./types";
|
||||
|
||||
interface BudgetDialogProps {
|
||||
mode: "create" | "update";
|
||||
trigger?: React.ReactNode;
|
||||
budget?: Budget;
|
||||
categories: BudgetCategory[];
|
||||
defaultPeriod: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const buildInitialValues = ({
|
||||
budget,
|
||||
defaultPeriod,
|
||||
}: {
|
||||
budget?: Budget;
|
||||
defaultPeriod: string;
|
||||
}): BudgetFormValues => ({
|
||||
categoriaId: budget?.category?.id ?? "",
|
||||
period: budget?.period ?? defaultPeriod,
|
||||
amount: budget ? (Math.round(budget.amount * 100) / 100).toFixed(2) : "",
|
||||
});
|
||||
|
||||
export function BudgetDialog({
|
||||
mode,
|
||||
trigger,
|
||||
budget,
|
||||
categories,
|
||||
defaultPeriod,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: BudgetDialogProps) {
|
||||
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 initialState = useMemo(
|
||||
() =>
|
||||
buildInitialValues({
|
||||
budget,
|
||||
defaultPeriod,
|
||||
}),
|
||||
[budget, defaultPeriod],
|
||||
);
|
||||
|
||||
// Use form state hook for form management
|
||||
const { formState, resetForm, updateField } =
|
||||
useFormState<BudgetFormValues>(initialState);
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
resetForm(initialState);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [dialogOpen, initialState, resetForm]);
|
||||
|
||||
// Clear error when dialog closes
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [dialogOpen]);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (mode === "update" && !budget?.id) {
|
||||
const message = "Orçamento inválido.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.categoriaId.length === 0) {
|
||||
const message = "Selecione uma categoria.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.period.length === 0) {
|
||||
const message = "Informe o período.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.amount.length === 0) {
|
||||
const message = "Informe o valor limite.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
categoriaId: formState.categoriaId,
|
||||
period: formState.period,
|
||||
amount: formState.amount,
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const result =
|
||||
mode === "create"
|
||||
? await createBudgetAction(payload)
|
||||
: await updateBudgetAction({
|
||||
id: budget?.id ?? "",
|
||||
...payload,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
resetForm(initialState);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
};
|
||||
|
||||
const title = mode === "create" ? "Novo orçamento" : "Editar orçamento";
|
||||
const description =
|
||||
mode === "create"
|
||||
? "Defina um limite de gastos para acompanhar suas despesas."
|
||||
: "Atualize os detalhes do orçamento selecionado.";
|
||||
const submitLabel =
|
||||
mode === "create" ? "Salvar orçamento" : "Atualizar orçamento";
|
||||
const disabled = categories.length === 0;
|
||||
const parsedAmount = Number.parseFloat(formState.amount);
|
||||
const sliderValue = Number.isFinite(parsedAmount)
|
||||
? Math.max(0, parsedAmount)
|
||||
: 0;
|
||||
const baseForSlider = Math.max(budget?.spent ?? 0, sliderValue, 1000);
|
||||
const sliderMax = Math.max(
|
||||
1000,
|
||||
Math.ceil((baseForSlider * 1.5) / 100) * 100,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{disabled ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-dashed bg-muted/10 p-4 text-sm text-muted-foreground">
|
||||
Cadastre pelo menos uma categoria de despesa para criar um
|
||||
orçamento.
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="budget-category">Categoria</Label>
|
||||
<Select
|
||||
value={formState.categoriaId}
|
||||
onValueChange={(value) => updateField("categoriaId", value)}
|
||||
>
|
||||
<SelectTrigger id="budget-category" className="w-full">
|
||||
<SelectValue placeholder="Selecione uma categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
<CategoryIcon
|
||||
name={category.icon ?? undefined}
|
||||
className="size-4"
|
||||
/>
|
||||
<span>{category.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="budget-period">Período</Label>
|
||||
<PeriodPicker
|
||||
value={formState.period}
|
||||
onChange={(value) => updateField("period", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="budget-amount">Valor limite</Label>
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Limite atual</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatCurrency(sliderValue)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Slider
|
||||
id="budget-amount"
|
||||
value={[sliderValue]}
|
||||
min={0}
|
||||
max={sliderMax}
|
||||
step={10}
|
||||
onValueChange={(value) =>
|
||||
updateField("amount", value[0]?.toFixed(2) ?? "0.00")
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{formatCurrency(0)}</span>
|
||||
<span>{formatCurrency(sliderMax)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
189
src/features/budgets/components/budgets-page.tsx
Normal file
189
src/features/budgets/components/budgets-page.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddCircleLine, RiFileCopyLine, RiFundsLine } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
deleteBudgetAction,
|
||||
duplicatePreviousMonthBudgetsAction,
|
||||
} from "@/features/budgets/actions";
|
||||
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 { BudgetCard } from "./budget-card";
|
||||
import { BudgetDialog } from "./budget-dialog";
|
||||
import type { Budget, BudgetCategory } from "./types";
|
||||
|
||||
interface BudgetsPageProps {
|
||||
budgets: Budget[];
|
||||
categories: BudgetCategory[];
|
||||
selectedPeriod: string;
|
||||
periodLabel: string;
|
||||
}
|
||||
|
||||
export function BudgetsPage({
|
||||
budgets,
|
||||
categories,
|
||||
selectedPeriod,
|
||||
periodLabel,
|
||||
}: BudgetsPageProps) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
|
||||
const [removeOpen, setRemoveOpen] = useState(false);
|
||||
const [budgetToRemove, setBudgetToRemove] = useState<Budget | null>(null);
|
||||
const [duplicateOpen, setDuplicateOpen] = useState(false);
|
||||
|
||||
const hasBudgets = budgets.length > 0;
|
||||
|
||||
const handleEdit = (budget: Budget) => {
|
||||
setSelectedBudget(budget);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const handleEditOpenChange = (open: boolean) => {
|
||||
setEditOpen(open);
|
||||
if (!open) {
|
||||
setSelectedBudget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRequest = (budget: Budget) => {
|
||||
setBudgetToRemove(budget);
|
||||
setRemoveOpen(true);
|
||||
};
|
||||
|
||||
const handleRemoveOpenChange = (open: boolean) => {
|
||||
setRemoveOpen(open);
|
||||
if (!open) {
|
||||
setBudgetToRemove(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveConfirm = async () => {
|
||||
if (!budgetToRemove) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deleteBudgetAction({ id: budgetToRemove.id });
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
};
|
||||
|
||||
const handleDuplicateConfirm = async () => {
|
||||
const result = await duplicatePreviousMonthBudgetsAction({
|
||||
period: selectedPeriod,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDuplicateOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
};
|
||||
|
||||
const removeTitle = budgetToRemove
|
||||
? `Remover orçamento de "${
|
||||
budgetToRemove.category?.name ?? "categoria removida"
|
||||
}"?`
|
||||
: "Remover orçamento?";
|
||||
|
||||
const emptyDescription =
|
||||
categories.length === 0
|
||||
? "Cadastre uma categoria de despesa para começar a planejar seus gastos."
|
||||
: "Crie seu primeiro orçamento para controlar os gastos por categoria.";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:gap-3">
|
||||
<BudgetDialog
|
||||
mode="create"
|
||||
categories={categories}
|
||||
defaultPeriod={selectedPeriod}
|
||||
trigger={
|
||||
<Button
|
||||
disabled={categories.length === 0}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<RiAddCircleLine className="size-4" />
|
||||
Novo orçamento
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={categories.length === 0}
|
||||
onClick={() => setDuplicateOpen(true)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<RiFileCopyLine className="size-4" />
|
||||
Copiar orçamentos do último mês
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasBudgets ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{budgets.map((budget, index) => (
|
||||
<BudgetCard
|
||||
key={budget.id}
|
||||
budget={budget}
|
||||
colorIndex={index}
|
||||
periodLabel={periodLabel}
|
||||
onEdit={handleEdit}
|
||||
onRemove={handleRemoveRequest}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
||||
<EmptyState
|
||||
media={<RiFundsLine className="size-6 text-primary" />}
|
||||
title="Nenhum orçamento cadastrado"
|
||||
description={emptyDescription}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BudgetDialog
|
||||
mode="update"
|
||||
budget={selectedBudget ?? undefined}
|
||||
categories={categories}
|
||||
defaultPeriod={selectedPeriod}
|
||||
open={editOpen && !!selectedBudget}
|
||||
onOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
|
||||
<ConfirmActionDialog
|
||||
open={removeOpen && !!budgetToRemove}
|
||||
onOpenChange={handleRemoveOpenChange}
|
||||
title={removeTitle}
|
||||
description="Esta ação remove o limite configurado para a categoria selecionada."
|
||||
confirmLabel="Remover orçamento"
|
||||
pendingLabel="Removendo..."
|
||||
confirmVariant="destructive"
|
||||
onConfirm={handleRemoveConfirm}
|
||||
/>
|
||||
|
||||
<ConfirmActionDialog
|
||||
open={duplicateOpen}
|
||||
onOpenChange={setDuplicateOpen}
|
||||
title="Copiar orçamentos do último mês?"
|
||||
description="Isso copiará os limites definidos no mês anterior para as categorias que ainda não possuem orçamento neste mês."
|
||||
confirmLabel="Copiar orçamentos"
|
||||
pendingLabel="Copiando..."
|
||||
onConfirm={handleDuplicateConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
src/features/budgets/components/types.ts
Normal file
20
src/features/budgets/components/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type BudgetCategory = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
export type Budget = {
|
||||
id: string;
|
||||
amount: number;
|
||||
spent: number;
|
||||
period: string;
|
||||
createdAt: string;
|
||||
category: BudgetCategory | null;
|
||||
};
|
||||
|
||||
export type BudgetFormValues = {
|
||||
categoriaId: string;
|
||||
period: string;
|
||||
amount: string;
|
||||
};
|
||||
136
src/features/budgets/queries.ts
Normal file
136
src/features/budgets/queries.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { and, asc, eq, inArray, isNull, or, sql, sum } from "drizzle-orm";
|
||||
import {
|
||||
categorias,
|
||||
lancamentos,
|
||||
type Orcamento,
|
||||
orcamentos,
|
||||
pagadores,
|
||||
} from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||
|
||||
const toNumber = (value: string | number | null | undefined) => {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export type BudgetData = {
|
||||
id: string;
|
||||
amount: number;
|
||||
spent: number;
|
||||
period: string;
|
||||
createdAt: string;
|
||||
category: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type CategoryOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
export async function fetchBudgetsForUser(
|
||||
userId: string,
|
||||
selectedPeriod: string,
|
||||
): Promise<{
|
||||
budgets: BudgetData[];
|
||||
categoriesOptions: CategoryOption[];
|
||||
}> {
|
||||
const [budgetRows, categoryRows] = await Promise.all([
|
||||
db.query.orcamentos.findMany({
|
||||
where: and(
|
||||
eq(orcamentos.userId, userId),
|
||||
eq(orcamentos.period, selectedPeriod),
|
||||
),
|
||||
with: {
|
||||
categoria: true,
|
||||
},
|
||||
}),
|
||||
db.query.categorias.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
icon: true,
|
||||
},
|
||||
where: and(eq(categorias.userId, userId), eq(categorias.type, "despesa")),
|
||||
orderBy: asc(categorias.name),
|
||||
}),
|
||||
]);
|
||||
|
||||
const categoryIds = budgetRows
|
||||
.map((budget: Orcamento) => budget.categoriaId)
|
||||
.filter((id: string | null): id is string => Boolean(id));
|
||||
|
||||
let totalsByCategory = new Map<string, number>();
|
||||
|
||||
if (categoryIds.length > 0) {
|
||||
const totals = await db
|
||||
.select({
|
||||
categoriaId: lancamentos.categoriaId,
|
||||
totalAmount: sum(lancamentos.amount).as("totalAmount"),
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, selectedPeriod),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
inArray(lancamentos.categoriaId, categoryIds),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.categoriaId);
|
||||
|
||||
totalsByCategory = new Map(
|
||||
totals.map(
|
||||
(row: { categoriaId: string | null; totalAmount: string | null }) => [
|
||||
row.categoriaId ?? "",
|
||||
Math.abs(toNumber(row.totalAmount)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const budgets = budgetRows
|
||||
.map((budget: Orcamento) => ({
|
||||
id: budget.id,
|
||||
amount: toNumber(budget.amount),
|
||||
spent: totalsByCategory.get(budget.categoriaId ?? "") ?? 0,
|
||||
period: budget.period,
|
||||
createdAt: budget.createdAt.toISOString(),
|
||||
category: budget.categoria
|
||||
? {
|
||||
id: budget.categoria.id,
|
||||
name: budget.categoria.name,
|
||||
icon: budget.categoria.icon,
|
||||
}
|
||||
: null,
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
(a.category?.name ?? "").localeCompare(b.category?.name ?? "", "pt-BR", {
|
||||
sensitivity: "base",
|
||||
}),
|
||||
);
|
||||
|
||||
const categoriesOptions = categoryRows.map((category) => ({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
icon: category.icon,
|
||||
}));
|
||||
|
||||
return { budgets, categoriesOptions };
|
||||
}
|
||||
42
src/features/calendar/components/calendar-grid.tsx
Normal file
42
src/features/calendar/components/calendar-grid.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { DayCell } from "@/features/calendar/components/day-cell";
|
||||
|
||||
import type { CalendarDay } from "@/shared/lib/types/calendar";
|
||||
import { WEEK_DAYS_SHORT } from "@/shared/utils/calendar";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
type CalendarGridProps = {
|
||||
days: CalendarDay[];
|
||||
onSelectDay: (day: CalendarDay) => void;
|
||||
onCreateDay: (day: CalendarDay) => void;
|
||||
};
|
||||
|
||||
export function CalendarGrid({
|
||||
days,
|
||||
onSelectDay,
|
||||
onCreateDay,
|
||||
}: CalendarGridProps) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none">
|
||||
<div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{WEEK_DAYS_SHORT.map((dayName) => (
|
||||
<span key={dayName} className="px-3 py-2 text-center text-primary">
|
||||
{dayName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-px bg-border/60 px-px pb-px pt-px">
|
||||
{days.map((day) => (
|
||||
<div
|
||||
key={day.date}
|
||||
className={cn("h-[150px] bg-card p-0.5", !day.isCurrentMonth && "")}
|
||||
>
|
||||
<DayCell day={day} onSelect={onSelectDay} onCreate={onCreateDay} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/features/calendar/components/calendar-legend.tsx
Normal file
34
src/features/calendar/components/calendar-legend.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell";
|
||||
import StatusDot from "@/shared/components/status-dot";
|
||||
import { Card } from "@/shared/components/ui/card";
|
||||
import type { CalendarEvent } from "@/shared/lib/types/calendar";
|
||||
|
||||
const LEGEND_ITEMS: Array<{
|
||||
type?: CalendarEvent["type"];
|
||||
label: string;
|
||||
dotColor?: string;
|
||||
}> = [
|
||||
{ type: "lancamento", label: "Lançamentos" },
|
||||
{ type: "boleto", label: "Boleto com vencimento" },
|
||||
{ type: "cartao", label: "Vencimento de cartão" },
|
||||
{ label: "Pagamento fatura", dotColor: "bg-success" },
|
||||
];
|
||||
|
||||
export function CalendarLegend() {
|
||||
return (
|
||||
<Card className="flex flex-row gap-2 p-2 text-sm">
|
||||
{LEGEND_ITEMS.map((item, index) => {
|
||||
const dotColor =
|
||||
item.dotColor || (item.type ? EVENT_TYPE_STYLES[item.type].dot : "");
|
||||
return (
|
||||
<span key={item.type || index} className="flex items-center gap-2">
|
||||
<StatusDot color={dotColor} />
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
185
src/features/calendar/components/day-cell.tsx
Normal file
185
src/features/calendar/components/day-cell.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddLine } from "@remixicon/react";
|
||||
import type { KeyboardEvent, MouseEvent } from "react";
|
||||
import type { CalendarDay, CalendarEvent } from "@/shared/lib/types/calendar";
|
||||
import { currencyFormatter } from "@/shared/utils/currency";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
type DayCellProps = {
|
||||
day: CalendarDay;
|
||||
onSelect: (day: CalendarDay) => void;
|
||||
onCreate: (day: CalendarDay) => void;
|
||||
};
|
||||
|
||||
export const EVENT_TYPE_STYLES: Record<
|
||||
CalendarEvent["type"],
|
||||
{ wrapper: string; dot: string; accent?: string }
|
||||
> = {
|
||||
lancamento: {
|
||||
wrapper:
|
||||
"bg-warning/10 text-warning dark:bg-warning/5 dark:text-warning border-l-4 border-warning",
|
||||
dot: "bg-warning",
|
||||
},
|
||||
boleto: {
|
||||
wrapper:
|
||||
"bg-info/10 text-info dark:bg-info/5 dark:text-info border-l-4 border-info",
|
||||
dot: "bg-info",
|
||||
},
|
||||
cartao: {
|
||||
wrapper:
|
||||
"bg-violet-100 text-violet-600 dark:bg-violet-900/10 dark:text-violet-50 border-l-4 border-violet-500",
|
||||
dot: "bg-violet-600",
|
||||
},
|
||||
};
|
||||
|
||||
const eventStyles = EVENT_TYPE_STYLES;
|
||||
|
||||
const formatCurrencyValue = (value: number | null | undefined) =>
|
||||
currencyFormatter.format(Math.abs(value ?? 0));
|
||||
|
||||
const formatAmount = (event: Extract<CalendarEvent, { type: "lancamento" }>) =>
|
||||
formatCurrencyValue(event.lancamento.amount);
|
||||
|
||||
const buildEventLabel = (event: CalendarEvent) => {
|
||||
switch (event.type) {
|
||||
case "lancamento": {
|
||||
return event.lancamento.name;
|
||||
}
|
||||
case "boleto": {
|
||||
return event.lancamento.name;
|
||||
}
|
||||
case "cartao": {
|
||||
return event.card.name;
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const buildEventComplement = (event: CalendarEvent) => {
|
||||
switch (event.type) {
|
||||
case "lancamento": {
|
||||
return formatAmount(event);
|
||||
}
|
||||
case "boleto": {
|
||||
return formatCurrencyValue(event.lancamento.amount);
|
||||
}
|
||||
case "cartao": {
|
||||
if (event.card.totalDue !== null) {
|
||||
return formatCurrencyValue(event.card.totalDue);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isPagamentoFatura = (event: CalendarEvent) => {
|
||||
return (
|
||||
event.type === "lancamento" &&
|
||||
event.lancamento.name.startsWith("Pagamento fatura -")
|
||||
);
|
||||
};
|
||||
|
||||
const getEventStyle = (event: CalendarEvent) => {
|
||||
if (isPagamentoFatura(event)) {
|
||||
return {
|
||||
wrapper:
|
||||
"bg-success/10 text-success dark:bg-success/5 dark:text-success border-l-4 border-success",
|
||||
dot: "bg-success",
|
||||
};
|
||||
}
|
||||
return eventStyles[event.type];
|
||||
};
|
||||
|
||||
const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
|
||||
const complement = buildEventComplement(event);
|
||||
const label = buildEventLabel(event);
|
||||
const style = getEventStyle(event);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded p-1 text-xs",
|
||||
style.wrapper,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
{complement ? (
|
||||
<span
|
||||
className={cn("shrink-0 font-semibold", style.accent ?? "text-xs")}
|
||||
>
|
||||
{complement}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
||||
const previewEvents = day.events.slice(0, 3);
|
||||
const hasOverflow = day.events.length > 3;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Enter" || event.key === " " || event.key === "Space") {
|
||||
event.preventDefault();
|
||||
onSelect(day);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onCreate(day);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(day)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border border-transparent bg-card/80 p-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-primary/10",
|
||||
!day.isCurrentMonth && "opacity-60",
|
||||
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold leading-none",
|
||||
day.isToday
|
||||
? "text-orange-100 bg-primary size-5 rounded-full flex items-center justify-center"
|
||||
: "text-foreground/90",
|
||||
)}
|
||||
>
|
||||
{day.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateClick}
|
||||
className="flex size-6 items-center justify-center rounded-full border bg-muted text-muted-foreground transition-colors hover:bg-primary/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||
aria-label={`Criar lançamento em ${day.date}`}
|
||||
>
|
||||
<RiAddLine className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
{previewEvents.map((event) => (
|
||||
<DayEventPreview key={event.id} event={event} />
|
||||
))}
|
||||
|
||||
{hasOverflow ? (
|
||||
<span className="text-xs font-medium text-primary/80">
|
||||
+ ver mais
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
src/features/calendar/components/event-modal.tsx
Normal file
211
src/features/calendar/components/event-modal.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card } from "@/shared/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import type { CalendarDay, CalendarEvent } from "@/shared/lib/types/calendar";
|
||||
import { friendlyDate, parseLocalDateString } from "@/shared/utils/date";
|
||||
import { formatFinancialDateLabel } from "@/shared/utils/financial-dates";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
type EventModalProps = {
|
||||
open: boolean;
|
||||
day: CalendarDay | null;
|
||||
onClose: () => void;
|
||||
onCreate: (date: string) => void;
|
||||
};
|
||||
|
||||
const EventCard = ({
|
||||
children,
|
||||
type,
|
||||
isPagamentoFatura = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
type: CalendarEvent["type"];
|
||||
isPagamentoFatura?: boolean;
|
||||
}) => {
|
||||
const style = isPagamentoFatura
|
||||
? { dot: "bg-success" }
|
||||
: EVENT_TYPE_STYLES[type];
|
||||
return (
|
||||
<Card className="flex flex-row gap-2 p-3 mb-1">
|
||||
<span
|
||||
className={cn("mt-1 size-3 shrink-0 rounded-full", style.dot)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">{children}</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLancamento = (
|
||||
event: Extract<CalendarEvent, { type: "lancamento" }>,
|
||||
) => {
|
||||
const isReceita = event.lancamento.transactionType === "Receita";
|
||||
const isPagamentoFatura =
|
||||
event.lancamento.name.startsWith("Pagamento fatura -");
|
||||
|
||||
return (
|
||||
<EventCard type="lancamento" isPagamentoFatura={isPagamentoFatura}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className={`text-sm font-semibold leading-tight ${
|
||||
isPagamentoFatura && "text-success"
|
||||
}`}
|
||||
>
|
||||
{event.lancamento.name}
|
||||
</span>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<Badge variant={"outline"}>{event.lancamento.condition}</Badge>
|
||||
<Badge variant={"outline"}>{event.lancamento.paymentMethod}</Badge>
|
||||
<Badge variant={"outline"}>{event.lancamento.categoriaName}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold whitespace-nowrap",
|
||||
isReceita ? "text-success" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
<MoneyValues
|
||||
showPositiveSign
|
||||
className="text-base"
|
||||
amount={event.lancamento.amount}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</EventCard>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
|
||||
const isPaid = Boolean(event.lancamento.isSettled);
|
||||
const dueDate = event.lancamento.dueDate;
|
||||
const dueDateLabel = formatFinancialDateLabel(dueDate, "Vence em", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<EventCard type="boleto">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="text-sm font-semibold leading-tight">
|
||||
{event.lancamento.name}
|
||||
</span>
|
||||
|
||||
{dueDateLabel && (
|
||||
<span className="text-xs text-muted-foreground leading-tight">
|
||||
{dueDateLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
|
||||
</div>
|
||||
<span className="font-semibold">
|
||||
<MoneyValues amount={event.lancamento.amount} />
|
||||
</span>
|
||||
</div>
|
||||
</EventCard>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCard = (event: Extract<CalendarEvent, { type: "cartao" }>) => (
|
||||
<EventCard type="cartao">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="text-sm font-semibold leading-tight">
|
||||
Vencimento Fatura - {event.card.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Badge variant={"outline"}>{event.card.status ?? "Fatura"}</Badge>
|
||||
</div>
|
||||
{event.card.totalDue !== null ? (
|
||||
<span className="font-semibold">
|
||||
<MoneyValues amount={event.card.totalDue} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</EventCard>
|
||||
);
|
||||
|
||||
const renderEvent = (event: CalendarEvent) => {
|
||||
switch (event.type) {
|
||||
case "lancamento":
|
||||
return renderLancamento(event);
|
||||
case "boleto":
|
||||
return renderBoleto(event);
|
||||
case "cartao":
|
||||
return renderCard(event);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function EventModal({ open, day, onClose, onCreate }: EventModalProps) {
|
||||
const formattedDate = !day
|
||||
? ""
|
||||
: friendlyDate(parseLocalDateString(day.date));
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!day) return;
|
||||
onClose();
|
||||
onCreate(day.date);
|
||||
};
|
||||
|
||||
const description = day?.events.length
|
||||
? "Confira os lançamentos e vencimentos cadastrados para este dia."
|
||||
: "Nenhum lançamento encontrado para este dia. Você pode criar um novo lançamento agora.";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formattedDate}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[380px] space-y-2 overflow-y-auto pr-2">
|
||||
{day?.events.length ? (
|
||||
day.events.map((event) => (
|
||||
<div key={event.id}>{renderEvent(event)}</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/60 bg-muted/30 p-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum lançamento ou vencimento registrado. Clique em{" "}
|
||||
<span className="font-medium text-primary">Novo lançamento</span>{" "}
|
||||
para começar.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!day}>
|
||||
Novo lançamento
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
112
src/features/calendar/components/monthly-calendar.tsx
Normal file
112
src/features/calendar/components/monthly-calendar.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { CalendarGrid } from "@/features/calendar/components/calendar-grid";
|
||||
import { CalendarLegend } from "@/features/calendar/components/calendar-legend";
|
||||
import { EventModal } from "@/features/calendar/components/event-modal";
|
||||
import { LancamentoDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
||||
import type {
|
||||
CalendarDay,
|
||||
CalendarEvent,
|
||||
CalendarFormOptions,
|
||||
CalendarPeriod,
|
||||
} from "@/shared/lib/types/calendar";
|
||||
import { buildCalendarDays } from "@/shared/utils/calendar";
|
||||
import { parsePeriod } from "@/shared/utils/period";
|
||||
|
||||
type MonthlyCalendarProps = {
|
||||
period: CalendarPeriod;
|
||||
events: CalendarEvent[];
|
||||
formOptions: CalendarFormOptions;
|
||||
};
|
||||
|
||||
export function MonthlyCalendar({
|
||||
period,
|
||||
events,
|
||||
formOptions,
|
||||
}: MonthlyCalendarProps) {
|
||||
const { year, month } = parsePeriod(period.period);
|
||||
const monthIndex = month - 1;
|
||||
|
||||
const eventsByDay = useMemo(() => {
|
||||
const map = new Map<string, CalendarEvent[]>();
|
||||
events.forEach((event) => {
|
||||
const list = map.get(event.date) ?? [];
|
||||
list.push(event);
|
||||
map.set(event.date, list);
|
||||
});
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
const days = useMemo(
|
||||
() => buildCalendarDays({ year, monthIndex, events: eventsByDay }),
|
||||
[eventsByDay, monthIndex, year],
|
||||
);
|
||||
|
||||
const [selectedDay, setSelectedDay] = useState<CalendarDay | null>(null);
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createDate, setCreateDate] = useState<string | null>(null);
|
||||
|
||||
const handleOpenCreate = (date: string) => {
|
||||
setCreateDate(date);
|
||||
setModalOpen(false);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const handleDaySelect = (day: CalendarDay) => {
|
||||
setSelectedDay(day);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateFromCell = (day: CalendarDay) => {
|
||||
handleOpenCreate(day.date);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setModalOpen(false);
|
||||
setSelectedDay(null);
|
||||
};
|
||||
|
||||
const handleCreateDialogChange = (open: boolean) => {
|
||||
setCreateOpen(open);
|
||||
if (!open) {
|
||||
setCreateDate(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<CalendarLegend />
|
||||
<CalendarGrid
|
||||
days={days}
|
||||
onSelectDay={handleDaySelect}
|
||||
onCreateDay={handleCreateFromCell}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EventModal
|
||||
open={isModalOpen}
|
||||
day={selectedDay}
|
||||
onClose={handleModalClose}
|
||||
onCreate={handleOpenCreate}
|
||||
/>
|
||||
|
||||
<LancamentoDialog
|
||||
mode="create"
|
||||
open={createOpen}
|
||||
onOpenChange={handleCreateDialogChange}
|
||||
pagadorOptions={formOptions.pagadorOptions}
|
||||
splitPagadorOptions={formOptions.splitPagadorOptions}
|
||||
defaultPagadorId={formOptions.defaultPagadorId}
|
||||
contaOptions={formOptions.contaOptions}
|
||||
cartaoOptions={formOptions.cartaoOptions}
|
||||
categoriaOptions={formOptions.categoriaOptions}
|
||||
estabelecimentos={formOptions.estabelecimentos}
|
||||
defaultPeriod={period.period}
|
||||
defaultPurchaseDate={createDate ?? undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
206
src/features/calendar/queries.ts
Normal file
206
src/features/calendar/queries.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { and, eq, gte, lte, ne, or } from "drizzle-orm";
|
||||
import { cartoes, lancamentos } from "@/db/schema";
|
||||
import {
|
||||
buildOptionSets,
|
||||
buildSluggedFilters,
|
||||
mapLancamentosData,
|
||||
} from "@/features/transactions/page-helpers";
|
||||
import {
|
||||
fetchLancamentoFilterSources,
|
||||
fetchRecentEstablishments,
|
||||
} from "@/features/transactions/queries";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||
import type { CalendarData, CalendarEvent } from "@/shared/lib/types/calendar";
|
||||
import { formatDateKey } from "@/shared/utils/calendar";
|
||||
import { parsePeriod } from "@/shared/utils/period";
|
||||
|
||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||
const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência";
|
||||
|
||||
const clampDayInMonth = (year: number, monthIndex: number, day: number) => {
|
||||
const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
|
||||
if (day < 1) return 1;
|
||||
if (day > lastDay) return lastDay;
|
||||
return day;
|
||||
};
|
||||
|
||||
const isWithinRange = (value: string | null, start: string, end: string) => {
|
||||
if (!value) return false;
|
||||
return value >= start && value <= end;
|
||||
};
|
||||
|
||||
type FetchCalendarDataParams = {
|
||||
userId: string;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export const fetchCalendarData = async ({
|
||||
userId,
|
||||
period,
|
||||
}: FetchCalendarDataParams): Promise<CalendarData> => {
|
||||
const { year, month } = parsePeriod(period);
|
||||
const monthIndex = month - 1;
|
||||
const rangeStart = new Date(Date.UTC(year, monthIndex, 1));
|
||||
const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0));
|
||||
const rangeStartKey = formatDateKey(rangeStart);
|
||||
const rangeEndKey = formatDateKey(rangeEnd);
|
||||
|
||||
const [lancamentoRows, cardRows, filterSources] = await Promise.all([
|
||||
db.query.lancamentos.findMany({
|
||||
where: and(
|
||||
eq(lancamentos.userId, userId),
|
||||
ne(lancamentos.transactionType, TRANSACTION_TYPE_TRANSFERENCIA),
|
||||
or(
|
||||
// Lançamentos cuja data de compra esteja no período do calendário
|
||||
and(
|
||||
gte(lancamentos.purchaseDate, rangeStart),
|
||||
lte(lancamentos.purchaseDate, rangeEnd),
|
||||
),
|
||||
// Boletos cuja data de vencimento esteja no período do calendário
|
||||
and(
|
||||
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
gte(lancamentos.dueDate, rangeStart),
|
||||
lte(lancamentos.dueDate, rangeEnd),
|
||||
),
|
||||
// Lançamentos de cartão do período (para calcular totais de vencimento)
|
||||
and(
|
||||
eq(lancamentos.period, period),
|
||||
ne(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
),
|
||||
),
|
||||
),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
}),
|
||||
db.query.cartoes.findMany({
|
||||
where: eq(cartoes.userId, userId),
|
||||
}),
|
||||
fetchLancamentoFilterSources(userId),
|
||||
]);
|
||||
|
||||
const lancamentosData = mapLancamentosData(lancamentoRows);
|
||||
const events: CalendarEvent[] = [];
|
||||
|
||||
const cardTotals = new Map<string, number>();
|
||||
for (const item of lancamentosData) {
|
||||
if (
|
||||
!item.cartaoId ||
|
||||
item.period !== period ||
|
||||
item.pagadorRole !== PAGADOR_ROLE_ADMIN
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const amount = Math.abs(item.amount ?? 0);
|
||||
cardTotals.set(
|
||||
item.cartaoId,
|
||||
(cardTotals.get(item.cartaoId) ?? 0) + amount,
|
||||
);
|
||||
}
|
||||
|
||||
for (const item of lancamentosData) {
|
||||
const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO;
|
||||
const isAdminPagador = item.pagadorRole === PAGADOR_ROLE_ADMIN;
|
||||
|
||||
// Para boletos, exibir apenas na data de vencimento e apenas se for pagador admin
|
||||
if (isBoleto) {
|
||||
if (
|
||||
isAdminPagador &&
|
||||
item.dueDate &&
|
||||
isWithinRange(item.dueDate, rangeStartKey, rangeEndKey)
|
||||
) {
|
||||
events.push({
|
||||
id: `${item.id}:boleto`,
|
||||
type: "boleto",
|
||||
date: item.dueDate,
|
||||
lancamento: item,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Para outros tipos de lançamento, exibir na data de compra
|
||||
if (!isAdminPagador) {
|
||||
continue;
|
||||
}
|
||||
const purchaseDateKey = item.purchaseDate.slice(0, 10);
|
||||
if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) {
|
||||
events.push({
|
||||
id: item.id,
|
||||
type: "lancamento",
|
||||
date: purchaseDateKey,
|
||||
lancamento: item,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exibir vencimentos apenas de cartões com lançamentos do pagador admin
|
||||
for (const card of cardRows) {
|
||||
if (!cardTotals.has(card.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10);
|
||||
if (Number.isNaN(dueDayNumber)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber);
|
||||
const dueDateKey = formatDateKey(
|
||||
new Date(Date.UTC(year, monthIndex, normalizedDay)),
|
||||
);
|
||||
|
||||
events.push({
|
||||
id: `${card.id}:cartao`,
|
||||
type: "cartao",
|
||||
date: dueDateKey,
|
||||
card: {
|
||||
id: card.id,
|
||||
name: card.name,
|
||||
dueDay: card.dueDay,
|
||||
closingDay: card.closingDay,
|
||||
brand: card.brand ?? null,
|
||||
status: card.status,
|
||||
logo: card.logo ?? null,
|
||||
totalDue: cardTotals.get(card.id) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const typePriority: Record<CalendarEvent["type"], number> = {
|
||||
lancamento: 0,
|
||||
boleto: 1,
|
||||
cartao: 2,
|
||||
};
|
||||
|
||||
events.sort((a, b) => {
|
||||
if (a.date === b.date) {
|
||||
return typePriority[a.type] - typePriority[b.type];
|
||||
}
|
||||
return a.date.localeCompare(b.date);
|
||||
});
|
||||
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
const optionSets = buildOptionSets({
|
||||
...sluggedFilters,
|
||||
pagadorRows: filterSources.pagadorRows,
|
||||
});
|
||||
|
||||
const estabelecimentos = await fetchRecentEstablishments(userId);
|
||||
|
||||
return {
|
||||
events,
|
||||
formOptions: {
|
||||
pagadorOptions: optionSets.pagadorOptions,
|
||||
splitPagadorOptions: optionSets.splitPagadorOptions,
|
||||
defaultPagadorId: optionSets.defaultPagadorId,
|
||||
contaOptions: optionSets.contaOptions,
|
||||
cartaoOptions: optionSets.cartaoOptions,
|
||||
categoriaOptions: optionSets.categoriaOptions,
|
||||
estabelecimentos,
|
||||
},
|
||||
};
|
||||
};
|
||||
168
src/features/cards/actions.ts
Normal file
168
src/features/cards/actions.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { cartoes, contas } from "@/db/schema";
|
||||
import {
|
||||
type ActionResult,
|
||||
handleActionError,
|
||||
revalidateForEntity,
|
||||
} from "@/shared/lib/actions/helpers";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import {
|
||||
dayOfMonthSchema,
|
||||
noteSchema,
|
||||
optionalDecimalSchema,
|
||||
uuidSchema,
|
||||
} from "@/shared/lib/schemas/common";
|
||||
import { formatDecimalForDb } from "@/shared/utils/currency";
|
||||
import { normalizeFilePath } from "@/shared/utils/string";
|
||||
|
||||
const cardBaseSchema = z.object({
|
||||
name: z
|
||||
.string({ message: "Informe o nome do cartão." })
|
||||
.trim()
|
||||
.min(1, "Informe o nome do cartão."),
|
||||
brand: z
|
||||
.string({ message: "Informe a bandeira." })
|
||||
.trim()
|
||||
.min(1, "Informe a bandeira."),
|
||||
status: z
|
||||
.string({ message: "Informe o status do cartão." })
|
||||
.trim()
|
||||
.min(1, "Informe o status do cartão."),
|
||||
closingDay: dayOfMonthSchema,
|
||||
dueDay: dayOfMonthSchema,
|
||||
note: noteSchema,
|
||||
limit: optionalDecimalSchema,
|
||||
logo: z
|
||||
.string({ message: "Selecione um logo." })
|
||||
.trim()
|
||||
.min(1, "Selecione um logo."),
|
||||
contaId: uuidSchema("Conta"),
|
||||
});
|
||||
|
||||
const createCardSchema = cardBaseSchema;
|
||||
const updateCardSchema = cardBaseSchema.extend({
|
||||
id: uuidSchema("Cartão"),
|
||||
});
|
||||
const deleteCardSchema = z.object({
|
||||
id: uuidSchema("Cartão"),
|
||||
});
|
||||
|
||||
type CardCreateInput = z.infer<typeof createCardSchema>;
|
||||
type CardUpdateInput = z.infer<typeof updateCardSchema>;
|
||||
type CardDeleteInput = z.infer<typeof deleteCardSchema>;
|
||||
|
||||
async function assertAccountOwnership(userId: string, contaId: string) {
|
||||
const account = await db.query.contas.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(eq(contas.id, contaId), eq(contas.userId, userId)),
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new Error("Conta vinculada não encontrada.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCardAction(
|
||||
input: CardCreateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = createCardSchema.parse(input);
|
||||
|
||||
await assertAccountOwnership(user.id, data.contaId);
|
||||
|
||||
const logoFile = normalizeFilePath(data.logo);
|
||||
|
||||
await db.insert(cartoes).values({
|
||||
name: data.name,
|
||||
brand: data.brand,
|
||||
status: data.status,
|
||||
closingDay: data.closingDay,
|
||||
dueDay: data.dueDay,
|
||||
note: data.note ?? null,
|
||||
limit: formatDecimalForDb(data.limit),
|
||||
logo: logoFile,
|
||||
contaId: data.contaId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
revalidateForEntity("cartoes");
|
||||
|
||||
return { success: true, message: "Cartão criado com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCardAction(
|
||||
input: CardUpdateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = updateCardSchema.parse(input);
|
||||
|
||||
await assertAccountOwnership(user.id, data.contaId);
|
||||
|
||||
const logoFile = normalizeFilePath(data.logo);
|
||||
|
||||
const [updated] = await db
|
||||
.update(cartoes)
|
||||
.set({
|
||||
name: data.name,
|
||||
brand: data.brand,
|
||||
status: data.status,
|
||||
closingDay: data.closingDay,
|
||||
dueDay: data.dueDay,
|
||||
note: data.note ?? null,
|
||||
limit: formatDecimalForDb(data.limit),
|
||||
logo: logoFile,
|
||||
contaId: data.contaId,
|
||||
})
|
||||
.where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id)))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Cartão não encontrado.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("cartoes");
|
||||
|
||||
return { success: true, message: "Cartão atualizado com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCardAction(
|
||||
input: CardDeleteInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = deleteCardSchema.parse(input);
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(cartoes)
|
||||
.where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id)))
|
||||
.returning({ id: cartoes.id });
|
||||
|
||||
if (!deleted) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Cartão não encontrado.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("cartoes");
|
||||
|
||||
return { success: true, message: "Cartão removido com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
277
src/features/cards/components/card-dialog.tsx
Normal file
277
src/features/cards/components/card-dialog.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { createCardAction, updateCardAction } from "@/features/cards/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 {
|
||||
DEFAULT_CARD_BRANDS,
|
||||
DEFAULT_CARD_STATUS,
|
||||
} from "@/shared/lib/cards/constants";
|
||||
import { deriveNameFromLogo, normalizeLogo } from "@/shared/lib/logo";
|
||||
import {
|
||||
formatLimitInput,
|
||||
normalizeDecimalInput,
|
||||
} from "@/shared/utils/currency";
|
||||
import { CardFormFields } from "./card-form-fields";
|
||||
import type { Card, CardFormValues } from "./types";
|
||||
|
||||
type AccountOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
};
|
||||
|
||||
interface CardDialogProps {
|
||||
mode: "create" | "update";
|
||||
trigger?: React.ReactNode;
|
||||
logoOptions: string[];
|
||||
accounts: AccountOption[];
|
||||
card?: Card;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const buildInitialValues = ({
|
||||
card,
|
||||
logoOptions,
|
||||
accounts,
|
||||
}: {
|
||||
card?: Card;
|
||||
logoOptions: string[];
|
||||
accounts: AccountOption[];
|
||||
}): CardFormValues => {
|
||||
const fallbackLogo = logoOptions[0] ?? "";
|
||||
const selectedLogo = normalizeLogo(card?.logo) || fallbackLogo;
|
||||
const derivedName = deriveNameFromLogo(selectedLogo);
|
||||
|
||||
return {
|
||||
name: card?.name ?? derivedName,
|
||||
brand: card?.brand ?? DEFAULT_CARD_BRANDS[0],
|
||||
status: card?.status ?? DEFAULT_CARD_STATUS[0],
|
||||
closingDay: card?.closingDay ?? "01",
|
||||
dueDay: card?.dueDay ?? "10",
|
||||
limit: formatLimitInput(card?.limit ?? null),
|
||||
note: card?.note ?? "",
|
||||
logo: selectedLogo,
|
||||
contaId: card?.contaId ?? accounts[0]?.id ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
export function CardDialog({
|
||||
mode,
|
||||
trigger,
|
||||
logoOptions,
|
||||
accounts,
|
||||
card,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CardDialogProps) {
|
||||
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 initialState = useMemo(
|
||||
() => buildInitialValues({ card, logoOptions, accounts }),
|
||||
[card, logoOptions, accounts],
|
||||
);
|
||||
|
||||
// Use form state hook for form management
|
||||
const { formState, resetForm, updateField, updateFields } =
|
||||
useFormState<CardFormValues>(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]);
|
||||
|
||||
// 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);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
type CardCreatePayload = Parameters<typeof createCardAction>[0];
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (mode === "update" && !card?.id) {
|
||||
const message = "Cartão inválido.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formState.contaId) {
|
||||
const message = "Selecione a conta vinculada.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawLimit = normalizeDecimalInput(formState.limit);
|
||||
const payload: CardCreatePayload = {
|
||||
name: formState.name.trim(),
|
||||
brand: formState.brand,
|
||||
status: formState.status,
|
||||
closingDay: formState.closingDay,
|
||||
dueDay: formState.dueDay,
|
||||
limit: rawLimit ? Number(rawLimit) : null,
|
||||
note: formState.note.trim() || null,
|
||||
logo: formState.logo,
|
||||
contaId: formState.contaId,
|
||||
};
|
||||
|
||||
if (!payload.logo) {
|
||||
const message = "Selecione um logo.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result =
|
||||
mode === "create"
|
||||
? await createCardAction(payload)
|
||||
: await updateCardAction({
|
||||
id: card?.id ?? "",
|
||||
...payload,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
resetForm(initialState);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
};
|
||||
|
||||
const title = mode === "create" ? "Novo cartão" : "Editar cartão";
|
||||
const description =
|
||||
mode === "create"
|
||||
? "Inclua um novo cartão de crédito para acompanhar seus gastos."
|
||||
: "Atualize as informações do cartão selecionado.";
|
||||
const submitLabel = mode === "create" ? "Salvar cartão" : "Atualizar cartão";
|
||||
|
||||
const handleMainDialogOpenChange = (open: boolean) => {
|
||||
if (!open && logoDialogOpen) {
|
||||
return;
|
||||
}
|
||||
setDialogOpen(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={dialogOpen} onOpenChange={handleMainDialogOpenChange}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent
|
||||
className=""
|
||||
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}>
|
||||
<LogoPickerTrigger
|
||||
selectedLogo={formState.logo}
|
||||
disabled={logoOptions.length === 0}
|
||||
helperText="Clique para escolher o logo do cartão"
|
||||
onOpen={() => {
|
||||
if (logoOptions.length > 0) {
|
||||
setLogoDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardFormFields
|
||||
values={formState}
|
||||
accountOptions={accounts}
|
||||
onChange={updateField}
|
||||
/>
|
||||
|
||||
{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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
215
src/features/cards/components/card-form-fields.tsx
Normal file
215
src/features/cards/components/card-form-fields.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
DAYS_IN_MONTH,
|
||||
DEFAULT_CARD_BRANDS,
|
||||
DEFAULT_CARD_STATUS,
|
||||
} from "@/shared/lib/cards/constants";
|
||||
import {
|
||||
AccountSelectContent,
|
||||
BrandSelectContent,
|
||||
StatusSelectContent,
|
||||
} from "./card-select-items";
|
||||
import type { CardFormValues } from "./types";
|
||||
|
||||
interface AccountOption {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
}
|
||||
|
||||
interface CardFormFieldsProps {
|
||||
values: CardFormValues;
|
||||
accountOptions: AccountOption[];
|
||||
onChange: (field: keyof CardFormValues, value: string) => void;
|
||||
}
|
||||
|
||||
const ensureOption = (options: string[], value: string) => {
|
||||
if (!value) {
|
||||
return options;
|
||||
}
|
||||
return options.includes(value) ? options : [value, ...options];
|
||||
};
|
||||
|
||||
export function CardFormFields({
|
||||
values,
|
||||
accountOptions,
|
||||
onChange,
|
||||
}: CardFormFieldsProps) {
|
||||
const brands = ensureOption(
|
||||
DEFAULT_CARD_BRANDS as unknown as string[],
|
||||
values.brand,
|
||||
);
|
||||
const statuses = ensureOption(
|
||||
DEFAULT_CARD_STATUS as unknown as string[],
|
||||
values.status,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-name">Nome do cartão</Label>
|
||||
<Input
|
||||
id="card-name"
|
||||
value={values.name}
|
||||
onChange={(event) => onChange("name", event.target.value)}
|
||||
placeholder="Ex.: Nubank Platinum"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-brand">Bandeira</Label>
|
||||
<Select
|
||||
value={values.brand}
|
||||
onValueChange={(value) => onChange("brand", value)}
|
||||
>
|
||||
<SelectTrigger id="card-brand" className="w-full">
|
||||
<SelectValue placeholder="Selecione a bandeira">
|
||||
{values.brand && <BrandSelectContent label={values.brand} />}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{brands.map((brand) => (
|
||||
<SelectItem key={brand} value={brand}>
|
||||
<BrandSelectContent label={brand} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-status">Status</Label>
|
||||
<Select
|
||||
value={values.status}
|
||||
onValueChange={(value) => onChange("status", value)}
|
||||
>
|
||||
<SelectTrigger id="card-status" className="w-full">
|
||||
<SelectValue placeholder="Selecione o status">
|
||||
{values.status && <StatusSelectContent label={values.status} />}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<StatusSelectContent label={status} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-limit">Limite (R$)</Label>
|
||||
<CurrencyInput
|
||||
id="card-limit"
|
||||
value={values.limit}
|
||||
onValueChange={(value) => onChange("limit", value)}
|
||||
placeholder="R$ 0,00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-closing-day">Dia de fechamento</Label>
|
||||
<Select
|
||||
value={values.closingDay}
|
||||
onValueChange={(value) => onChange("closingDay", value)}
|
||||
>
|
||||
<SelectTrigger id="card-closing-day" className="w-full">
|
||||
<SelectValue placeholder="Dia de fechamento" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAYS_IN_MONTH.map((day) => (
|
||||
<SelectItem key={day} value={day}>
|
||||
Dia {day}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-due-day">Dia de vencimento</Label>
|
||||
<Select
|
||||
value={values.dueDay}
|
||||
onValueChange={(value) => onChange("dueDay", value)}
|
||||
>
|
||||
<SelectTrigger id="card-due-day" className="w-full">
|
||||
<SelectValue placeholder="Dia de vencimento" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAYS_IN_MONTH.map((day) => (
|
||||
<SelectItem key={day} value={day}>
|
||||
Dia {day}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="card-account">Conta vinculada</Label>
|
||||
<Select
|
||||
value={values.contaId}
|
||||
onValueChange={(value) => onChange("contaId", value)}
|
||||
disabled={accountOptions.length === 0}
|
||||
>
|
||||
<SelectTrigger id="card-account" className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
accountOptions.length === 0
|
||||
? "Cadastre uma conta primeiro"
|
||||
: "Selecione a conta"
|
||||
}
|
||||
>
|
||||
{values.contaId &&
|
||||
(() => {
|
||||
const selectedAccount = accountOptions.find(
|
||||
(acc) => acc.id === values.contaId,
|
||||
);
|
||||
return selectedAccount ? (
|
||||
<AccountSelectContent
|
||||
label={selectedAccount.name}
|
||||
logo={selectedAccount.logo}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accountOptions.map((account) => (
|
||||
<SelectItem key={account.id} value={account.id}>
|
||||
<AccountSelectContent
|
||||
label={account.name}
|
||||
logo={account.logo}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="card-note">Anotação</Label>
|
||||
<Textarea
|
||||
id="card-note"
|
||||
value={values.note}
|
||||
onChange={(event) => onChange("note", event.target.value)}
|
||||
placeholder="Observações sobre este cartão"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
264
src/features/cards/components/card-item.tsx
Normal file
264
src/features/cards/components/card-item.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiChat3Line,
|
||||
RiDeleteBin5Line,
|
||||
RiFileList2Line,
|
||||
RiPencilLine,
|
||||
} from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/shared/components/ui/card";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { resolveCardBrandAsset } from "@/shared/lib/cards/brand-assets";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
interface CardItemProps {
|
||||
name: string;
|
||||
brand: string;
|
||||
status: string;
|
||||
closingDay: string;
|
||||
dueDay: string;
|
||||
limit: number | null;
|
||||
limitInUse?: number | null;
|
||||
limitAvailable?: number | null;
|
||||
contaName: string;
|
||||
logo?: string | null;
|
||||
note?: string | null;
|
||||
onEdit?: () => void;
|
||||
onInvoice?: () => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
const formatDay = (value: string) => value.padStart(2, "0");
|
||||
|
||||
export function CardItem({
|
||||
name,
|
||||
brand,
|
||||
status,
|
||||
closingDay,
|
||||
dueDay,
|
||||
limit,
|
||||
limitInUse,
|
||||
limitAvailable,
|
||||
contaName: _contaName,
|
||||
logo,
|
||||
note,
|
||||
onEdit,
|
||||
onInvoice,
|
||||
onRemove,
|
||||
}: CardItemProps) {
|
||||
void _contaName;
|
||||
|
||||
const limitTotal = limit ?? null;
|
||||
const used =
|
||||
limitInUse ??
|
||||
(limitTotal !== null && limitAvailable != null
|
||||
? Math.max(limitTotal - limitAvailable, 0)
|
||||
: limitTotal !== null
|
||||
? 0
|
||||
: null);
|
||||
|
||||
const available =
|
||||
limitAvailable ??
|
||||
(limitTotal !== null && used !== null
|
||||
? Math.max(limitTotal - used, 0)
|
||||
: null);
|
||||
|
||||
const usagePercent =
|
||||
limitTotal && limitTotal > 0 && used !== null
|
||||
? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
|
||||
: 0;
|
||||
|
||||
const logoPath = resolveLogoSrc(logo);
|
||||
const brandAsset = resolveCardBrandAsset(brand);
|
||||
const isInactive = status?.toLowerCase() === "inativo";
|
||||
const metrics =
|
||||
limitTotal === null || used === null || available === null
|
||||
? null
|
||||
: [
|
||||
{ label: "Limite Total", value: limitTotal },
|
||||
{ label: "Em uso", value: used },
|
||||
{ label: "Disponível", value: available },
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: "editar",
|
||||
icon: <RiPencilLine className="size-4" aria-hidden />,
|
||||
onClick: onEdit,
|
||||
className: "text-primary",
|
||||
},
|
||||
{
|
||||
label: "ver fatura",
|
||||
icon: <RiFileList2Line className="size-4" aria-hidden />,
|
||||
onClick: onInvoice,
|
||||
className: "text-primary",
|
||||
},
|
||||
{
|
||||
label: "remover",
|
||||
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
|
||||
onClick: onRemove,
|
||||
className: "text-destructive",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col p-6 w-full">
|
||||
<CardHeader className="space-y-2 px-0 pb-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
{logoPath ? (
|
||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt={`Logo do cartão ${name}`}
|
||||
width={42}
|
||||
height={42}
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
isInactive && "grayscale opacity-40",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base">
|
||||
{name}
|
||||
</h3>
|
||||
{note ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground/70 transition-colors hover:text-foreground"
|
||||
aria-label="Observações do cartão"
|
||||
>
|
||||
<RiChat3Line className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
{note}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{status ? (
|
||||
<span className="text-xs tracking-wide text-muted-foreground">
|
||||
{status}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{brandAsset ? (
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<Image
|
||||
src={brandAsset}
|
||||
alt={`Bandeira ${brand}`}
|
||||
width={36}
|
||||
height={36}
|
||||
className={cn(
|
||||
"h-5 w-auto rounded",
|
||||
isInactive && "grayscale opacity-40",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{brand}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-y border-dashed py-3 text-xs font-medium text-muted-foreground sm:text-sm">
|
||||
<span>
|
||||
Fecha dia{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatDay(closingDay)}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Vence dia{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatDay(dueDay)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-1 flex-col gap-5 px-0">
|
||||
{metrics ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
<MoneyValues amount={metrics[0].value} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{metrics[0].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||
<span className="size-2 rounded-full bg-primary" />
|
||||
<MoneyValues amount={metrics[1].value} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{metrics[1].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
<MoneyValues amount={metrics[2].value} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{metrics[2].label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress value={usagePercent} className="h-3" />
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ainda não há limite registrado para este cartão.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 text-sm">
|
||||
{actions.map(({ label, icon, onClick, className }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
67
src/features/cards/components/card-select-items.tsx
Normal file
67
src/features/cards/components/card-select-items.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { RiBankLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import StatusDot from "@/shared/components/status-dot";
|
||||
import { resolveCardBrandLogoSrc } from "@/shared/lib/cards/brand-assets";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
|
||||
type SelectItemContentProps = {
|
||||
label: string;
|
||||
logo?: string | null;
|
||||
};
|
||||
|
||||
export function BrandSelectContent({ label }: { label: string }) {
|
||||
const logoSrc = resolveCardBrandLogoSrc(label);
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={`Logo ${label}`}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<RiBankLine className="size-5 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusSelectContent({ label }: { label: string }) {
|
||||
const isActive = label === "Ativo";
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusDot
|
||||
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountSelectContent({ label, logo }: SelectItemContentProps) {
|
||||
const logoSrc = resolveLogoSrc(logo);
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={`Logo de ${label}`}
|
||||
width={20}
|
||||
height={20}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<RiBankLine className="size-4 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
211
src/features/cards/components/cards-page.tsx
Normal file
211
src/features/cards/components/cards-page.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddCircleLine, RiBankCard2Line } from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { deleteCardAction } from "@/features/cards/actions";
|
||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||
import { EmptyState } from "@/shared/components/empty-state";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card as UiCard } from "@/shared/components/ui/card";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/shared/components/ui/tabs";
|
||||
import { CardDialog } from "./card-dialog";
|
||||
import { CardItem } from "./card-item";
|
||||
import type { Card as CreditCard } from "./types";
|
||||
|
||||
type AccountOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
};
|
||||
|
||||
interface CardsPageProps {
|
||||
cards: CreditCard[];
|
||||
archivedCards: CreditCard[];
|
||||
accounts: AccountOption[];
|
||||
logoOptions: string[];
|
||||
}
|
||||
|
||||
export function CardsPage({
|
||||
cards,
|
||||
archivedCards,
|
||||
accounts,
|
||||
logoOptions,
|
||||
}: CardsPageProps) {
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState("ativos");
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [selectedCard, setSelectedCard] = useState<CreditCard | null>(null);
|
||||
const [removeOpen, setRemoveOpen] = useState(false);
|
||||
const [cardToRemove, setCardToRemove] = useState<CreditCard | null>(null);
|
||||
|
||||
const orderedCards = useMemo(
|
||||
() =>
|
||||
[...cards].sort((a, b) =>
|
||||
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
|
||||
),
|
||||
[cards],
|
||||
);
|
||||
const orderedArchivedCards = useMemo(
|
||||
() =>
|
||||
[...archivedCards].sort((a, b) =>
|
||||
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
|
||||
),
|
||||
[archivedCards],
|
||||
);
|
||||
|
||||
const handleEdit = (card: CreditCard) => {
|
||||
setSelectedCard(card);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const handleEditOpenChange = (open: boolean) => {
|
||||
setEditOpen(open);
|
||||
if (!open) {
|
||||
setSelectedCard(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRequest = (card: CreditCard) => {
|
||||
setCardToRemove(card);
|
||||
setRemoveOpen(true);
|
||||
};
|
||||
|
||||
const handleInvoice = (card: CreditCard) => {
|
||||
router.push(`/cards/${card.id}/invoice`);
|
||||
};
|
||||
|
||||
const handleRemoveOpenChange = (open: boolean) => {
|
||||
setRemoveOpen(open);
|
||||
if (!open) {
|
||||
setCardToRemove(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveConfirm = async () => {
|
||||
if (!cardToRemove) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deleteCardAction({ id: cardToRemove.id });
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
};
|
||||
|
||||
const removeTitle = cardToRemove
|
||||
? `Remover cartão "${cardToRemove.name}"?`
|
||||
: "Remover cartão?";
|
||||
|
||||
const renderCardList = (list: CreditCard[], isArchived: boolean) => {
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<UiCard className="flex w-full items-center justify-center py-12">
|
||||
<EmptyState
|
||||
media={<RiBankCard2Line className="size-6 text-primary" />}
|
||||
title={
|
||||
isArchived
|
||||
? "Nenhum cartão arquivado"
|
||||
: "Nenhum cartão cadastrado"
|
||||
}
|
||||
description={
|
||||
isArchived
|
||||
? "Os cartões arquivados aparecerão aqui."
|
||||
: "Adicione seu primeiro cartão para acompanhar limites e faturas com mais controle."
|
||||
}
|
||||
/>
|
||||
</UiCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{list.map((card) => (
|
||||
<CardItem
|
||||
key={card.id}
|
||||
name={card.name}
|
||||
brand={card.brand}
|
||||
status={card.status}
|
||||
closingDay={card.closingDay}
|
||||
dueDay={card.dueDay}
|
||||
limit={card.limit}
|
||||
limitInUse={card.limitInUse ?? null}
|
||||
limitAvailable={card.limitAvailable ?? card.limit ?? null}
|
||||
contaName={card.contaName}
|
||||
logo={card.logo}
|
||||
note={card.note}
|
||||
onEdit={() => handleEdit(card)}
|
||||
onInvoice={() => handleInvoice(card)}
|
||||
onRemove={() => handleRemoveRequest(card)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex">
|
||||
<CardDialog
|
||||
mode="create"
|
||||
accounts={accounts}
|
||||
logoOptions={logoOptions}
|
||||
trigger={
|
||||
<Button className="w-full sm:w-auto">
|
||||
<RiAddCircleLine className="size-4" />
|
||||
Novo cartão
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="ativos">Ativos</TabsTrigger>
|
||||
<TabsTrigger value="arquivados">Arquivados</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ativos" className="mt-4">
|
||||
{renderCardList(orderedCards, false)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="arquivados" className="mt-4">
|
||||
{renderCardList(orderedArchivedCards, true)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<CardDialog
|
||||
mode="update"
|
||||
accounts={accounts}
|
||||
logoOptions={logoOptions}
|
||||
card={selectedCard ?? undefined}
|
||||
open={editOpen && !!selectedCard}
|
||||
onOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
|
||||
<ConfirmActionDialog
|
||||
open={removeOpen && !!cardToRemove}
|
||||
onOpenChange={handleRemoveOpenChange}
|
||||
title={removeTitle}
|
||||
description="Ao remover este cartão, os registros relacionados a ele serão excluídos permanentemente."
|
||||
confirmLabel="Remover cartão"
|
||||
pendingLabel="Removendo..."
|
||||
confirmVariant="destructive"
|
||||
onConfirm={handleRemoveConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
src/features/cards/components/types.ts
Normal file
27
src/features/cards/components/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type Card = {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
status: string;
|
||||
closingDay: string;
|
||||
dueDay: string;
|
||||
note: string | null;
|
||||
logo: string | null;
|
||||
limit: number | null;
|
||||
contaId: string;
|
||||
contaName: string;
|
||||
limitInUse?: number | null;
|
||||
limitAvailable?: number | null;
|
||||
};
|
||||
|
||||
export type CardFormValues = {
|
||||
name: string;
|
||||
brand: string;
|
||||
status: string;
|
||||
closingDay: string;
|
||||
dueDay: string;
|
||||
limit: string;
|
||||
note: string;
|
||||
logo: string;
|
||||
contaId: string;
|
||||
};
|
||||
242
src/features/cards/queries.ts
Normal file
242
src/features/cards/queries.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { and, eq, ilike, isNull, ne, not, or, sql } from "drizzle-orm";
|
||||
import { cartoes, contas, lancamentos } from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { loadLogoOptions } from "@/shared/lib/logo/options";
|
||||
|
||||
export type CardData = {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
status: string | null;
|
||||
closingDay: number;
|
||||
dueDay: number;
|
||||
note: string | null;
|
||||
logo: string | null;
|
||||
limit: number | null;
|
||||
limitInUse: number;
|
||||
limitAvailable: number | null;
|
||||
contaId: string;
|
||||
contaName: string;
|
||||
};
|
||||
|
||||
export type AccountSimple = {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
};
|
||||
|
||||
export async function fetchCardsForUser(userId: string): Promise<{
|
||||
cards: CardData[];
|
||||
accounts: AccountSimple[];
|
||||
logoOptions: LogoOption[];
|
||||
}> {
|
||||
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
|
||||
db.query.cartoes.findMany({
|
||||
orderBy: (
|
||||
card: typeof cartoes.$inferSelect,
|
||||
{ desc }: { desc: (field: unknown) => unknown },
|
||||
) => [desc(card.name)],
|
||||
where: and(
|
||||
eq(cartoes.userId, userId),
|
||||
not(ilike(cartoes.status, "inativo")),
|
||||
),
|
||||
with: {
|
||||
conta: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.query.contas.findMany({
|
||||
orderBy: (
|
||||
account: typeof contas.$inferSelect,
|
||||
{ desc }: { desc: (field: unknown) => unknown },
|
||||
) => [desc(account.name)],
|
||||
where: eq(contas.userId, userId),
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
logo: true,
|
||||
},
|
||||
}),
|
||||
loadLogoOptions(),
|
||||
db
|
||||
.select({
|
||||
cartaoId: lancamentos.cartaoId,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)),
|
||||
// Recorrente no cartão: só consome limite quando a data da ocorrência já passou
|
||||
or(
|
||||
ne(lancamentos.condition, "Recorrente"),
|
||||
sql`${lancamentos.purchaseDate} <= current_date`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.cartaoId),
|
||||
]);
|
||||
|
||||
const usageMap = new Map<string, number>();
|
||||
usageRows.forEach(
|
||||
(row: { cartaoId: string | null; total: number | null }) => {
|
||||
if (!row.cartaoId) return;
|
||||
usageMap.set(row.cartaoId, Number(row.total ?? 0));
|
||||
},
|
||||
);
|
||||
|
||||
const cards = cardRows.map((card) => ({
|
||||
id: card.id,
|
||||
name: card.name,
|
||||
brand: card.brand,
|
||||
status: card.status,
|
||||
closingDay: card.closingDay,
|
||||
dueDay: card.dueDay,
|
||||
note: card.note,
|
||||
logo: card.logo,
|
||||
limit: card.limit ? Number(card.limit) : null,
|
||||
limitInUse: (() => {
|
||||
const total = usageMap.get(card.id) ?? 0;
|
||||
return total < 0 ? Math.abs(total) : 0;
|
||||
})(),
|
||||
limitAvailable: (() => {
|
||||
if (!card.limit) {
|
||||
return null;
|
||||
}
|
||||
const total = usageMap.get(card.id) ?? 0;
|
||||
const inUse = total < 0 ? Math.abs(total) : 0;
|
||||
return Math.max(Number(card.limit) - inUse, 0);
|
||||
})(),
|
||||
contaId: card.contaId,
|
||||
contaName: card.conta?.name ?? "Conta não encontrada",
|
||||
}));
|
||||
|
||||
const accounts = accountRows.map((account) => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
logo: account.logo,
|
||||
}));
|
||||
|
||||
return { cards, accounts, logoOptions };
|
||||
}
|
||||
|
||||
export async function fetchInativosForUser(userId: string): Promise<{
|
||||
cards: CardData[];
|
||||
accounts: AccountSimple[];
|
||||
logoOptions: LogoOption[];
|
||||
}> {
|
||||
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
|
||||
db.query.cartoes.findMany({
|
||||
orderBy: (
|
||||
card: typeof cartoes.$inferSelect,
|
||||
{ desc }: { desc: (field: unknown) => unknown },
|
||||
) => [desc(card.name)],
|
||||
where: and(eq(cartoes.userId, userId), ilike(cartoes.status, "inativo")),
|
||||
with: {
|
||||
conta: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.query.contas.findMany({
|
||||
orderBy: (
|
||||
account: typeof contas.$inferSelect,
|
||||
{ desc }: { desc: (field: unknown) => unknown },
|
||||
) => [desc(account.name)],
|
||||
where: eq(contas.userId, userId),
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
logo: true,
|
||||
},
|
||||
}),
|
||||
loadLogoOptions(),
|
||||
db
|
||||
.select({
|
||||
cartaoId: lancamentos.cartaoId,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)),
|
||||
// Recorrente no cartão: só consome limite quando a data da ocorrência já passou
|
||||
or(
|
||||
ne(lancamentos.condition, "Recorrente"),
|
||||
sql`${lancamentos.purchaseDate} <= current_date`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.cartaoId),
|
||||
]);
|
||||
|
||||
const usageMap = new Map<string, number>();
|
||||
usageRows.forEach(
|
||||
(row: { cartaoId: string | null; total: number | null }) => {
|
||||
if (!row.cartaoId) return;
|
||||
usageMap.set(row.cartaoId, Number(row.total ?? 0));
|
||||
},
|
||||
);
|
||||
|
||||
const cards = cardRows.map((card) => ({
|
||||
id: card.id,
|
||||
name: card.name,
|
||||
brand: card.brand,
|
||||
status: card.status,
|
||||
closingDay: card.closingDay,
|
||||
dueDay: card.dueDay,
|
||||
note: card.note,
|
||||
logo: card.logo,
|
||||
limit: card.limit ? Number(card.limit) : null,
|
||||
limitInUse: (() => {
|
||||
const total = usageMap.get(card.id) ?? 0;
|
||||
return total < 0 ? Math.abs(total) : 0;
|
||||
})(),
|
||||
limitAvailable: (() => {
|
||||
if (!card.limit) {
|
||||
return null;
|
||||
}
|
||||
const total = usageMap.get(card.id) ?? 0;
|
||||
const inUse = total < 0 ? Math.abs(total) : 0;
|
||||
return Math.max(Number(card.limit) - inUse, 0);
|
||||
})(),
|
||||
contaId: card.contaId,
|
||||
contaName: card.conta?.name ?? "Conta não encontrada",
|
||||
}));
|
||||
|
||||
const accounts = accountRows.map((account) => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
logo: account.logo,
|
||||
}));
|
||||
|
||||
return { cards, accounts, logoOptions };
|
||||
}
|
||||
|
||||
export async function fetchAllCardsForUser(userId: string): Promise<{
|
||||
activeCards: CardData[];
|
||||
archivedCards: CardData[];
|
||||
accounts: AccountSimple[];
|
||||
logoOptions: LogoOption[];
|
||||
}> {
|
||||
const [activeData, archivedData] = await Promise.all([
|
||||
fetchCardsForUser(userId),
|
||||
fetchInativosForUser(userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
activeCards: activeData.cards,
|
||||
archivedCards: archivedData.cards,
|
||||
accounts: activeData.accounts,
|
||||
logoOptions: activeData.logoOptions,
|
||||
};
|
||||
}
|
||||
176
src/features/categories/actions.ts
Normal file
176
src/features/categories/actions.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { categorias } from "@/db/schema";
|
||||
import {
|
||||
type ActionResult,
|
||||
handleActionError,
|
||||
revalidateForEntity,
|
||||
} from "@/shared/lib/actions/helpers";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { CATEGORY_TYPES } from "@/shared/lib/categories/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { uuidSchema } from "@/shared/lib/schemas/common";
|
||||
import { normalizeIconInput } from "@/shared/utils/string";
|
||||
|
||||
const categoryBaseSchema = z.object({
|
||||
name: z
|
||||
.string({ message: "Informe o nome da categoria." })
|
||||
.trim()
|
||||
.min(1, "Informe o nome da categoria."),
|
||||
type: z.enum(CATEGORY_TYPES, {
|
||||
message: "Tipo de categoria inválido.",
|
||||
}),
|
||||
icon: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(100, "O ícone deve ter no máximo 100 caracteres.")
|
||||
.nullish()
|
||||
.transform((value) => normalizeIconInput(value)),
|
||||
});
|
||||
|
||||
const createCategorySchema = categoryBaseSchema;
|
||||
const updateCategorySchema = categoryBaseSchema.extend({
|
||||
id: uuidSchema("Categoria"),
|
||||
});
|
||||
const deleteCategorySchema = z.object({
|
||||
id: uuidSchema("Categoria"),
|
||||
});
|
||||
|
||||
type CategoryCreateInput = z.infer<typeof createCategorySchema>;
|
||||
type CategoryUpdateInput = z.infer<typeof updateCategorySchema>;
|
||||
type CategoryDeleteInput = z.infer<typeof deleteCategorySchema>;
|
||||
|
||||
export async function createCategoryAction(
|
||||
input: CategoryCreateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = createCategorySchema.parse(input);
|
||||
|
||||
await db.insert(categorias).values({
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
icon: data.icon,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
revalidateForEntity("categorias");
|
||||
|
||||
return { success: true, message: "Categoria criada com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCategoryAction(
|
||||
input: CategoryUpdateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = updateCategorySchema.parse(input);
|
||||
|
||||
// Buscar categoria antes de atualizar para verificar restrições
|
||||
const categoria = await db.query.categorias.findFirst({
|
||||
columns: { id: true, name: true },
|
||||
where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)),
|
||||
});
|
||||
|
||||
if (!categoria) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Categoria não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
// Bloquear edição das categorias protegidas
|
||||
const categoriasProtegidas = [
|
||||
"Transferência interna",
|
||||
"Saldo inicial",
|
||||
"Pagamentos",
|
||||
];
|
||||
if (categoriasProtegidas.includes(categoria.name)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `A categoria '${categoria.name}' é protegida e não pode ser editada.`,
|
||||
};
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(categorias)
|
||||
.set({
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
icon: data.icon,
|
||||
})
|
||||
.where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id)))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Categoria não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("categorias");
|
||||
|
||||
return { success: true, message: "Categoria atualizada com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCategoryAction(
|
||||
input: CategoryDeleteInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = deleteCategorySchema.parse(input);
|
||||
|
||||
// Buscar categoria antes de deletar para verificar restrições
|
||||
const categoria = await db.query.categorias.findFirst({
|
||||
columns: { id: true, name: true },
|
||||
where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)),
|
||||
});
|
||||
|
||||
if (!categoria) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Categoria não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
// Bloquear remoção das categorias protegidas
|
||||
const categoriasProtegidas = [
|
||||
"Transferência interna",
|
||||
"Saldo inicial",
|
||||
"Pagamentos",
|
||||
];
|
||||
if (categoriasProtegidas.includes(categoria.name)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `A categoria '${categoria.name}' é protegida e não pode ser removida.`,
|
||||
};
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(categorias)
|
||||
.where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id)))
|
||||
.returning({ id: categorias.id });
|
||||
|
||||
if (!deleted) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Categoria não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("categorias");
|
||||
|
||||
return { success: true, message: "Categoria removida com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
260
src/features/categories/components/categories-page.tsx
Normal file
260
src/features/categories/components/categories-page.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiAddCircleLine,
|
||||
RiDeleteBin5Line,
|
||||
RiExternalLinkLine,
|
||||
RiPencilLine,
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { deleteCategoryAction } from "@/features/categories/actions";
|
||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/shared/components/ui/tabs";
|
||||
import {
|
||||
CATEGORY_TYPE_LABEL,
|
||||
CATEGORY_TYPES,
|
||||
} from "@/shared/lib/categories/constants";
|
||||
import { CategoryDialog } from "./category-dialog";
|
||||
import { CategoryIconBadge } from "./category-icon-badge";
|
||||
import type { Category, CategoryType } from "./types";
|
||||
|
||||
const CATEGORIAS_PROTEGIDAS = [
|
||||
"Transferência interna",
|
||||
"Saldo inicial",
|
||||
"Pagamentos",
|
||||
];
|
||||
|
||||
interface CategoriesPageProps {
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
export function CategoriesPage({ categories }: CategoriesPageProps) {
|
||||
const [activeType, setActiveType] = useState<CategoryType>(CATEGORY_TYPES[0]);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category | null>(
|
||||
null,
|
||||
);
|
||||
const [removeOpen, setRemoveOpen] = useState(false);
|
||||
const [categoryToRemove, setCategoryToRemove] = useState<Category | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const categoriesByType = useMemo(() => {
|
||||
const base = Object.fromEntries(
|
||||
CATEGORY_TYPES.map((type) => [type, [] as Category[]]),
|
||||
) as Record<CategoryType, Category[]>;
|
||||
|
||||
categories.forEach((category) => {
|
||||
base[category.type]?.push(category);
|
||||
});
|
||||
|
||||
CATEGORY_TYPES.forEach((type) => {
|
||||
base[type].sort((a, b) =>
|
||||
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
|
||||
);
|
||||
});
|
||||
|
||||
return base;
|
||||
}, [categories]);
|
||||
|
||||
const handleEdit = (category: Category) => {
|
||||
setSelectedCategory(category);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const handleEditOpenChange = (open: boolean) => {
|
||||
setEditOpen(open);
|
||||
if (!open) {
|
||||
setSelectedCategory(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRequest = (category: Category) => {
|
||||
setCategoryToRemove(category);
|
||||
setRemoveOpen(true);
|
||||
};
|
||||
|
||||
const handleRemoveOpenChange = (open: boolean) => {
|
||||
setRemoveOpen(open);
|
||||
if (!open) {
|
||||
setCategoryToRemove(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveConfirm = async () => {
|
||||
if (!categoryToRemove) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deleteCategoryAction({ id: categoryToRemove.id });
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
};
|
||||
|
||||
const removeTitle = categoryToRemove
|
||||
? `Remover categoria "${categoryToRemove.name}"?`
|
||||
: "Remover categoria?";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex">
|
||||
<CategoryDialog
|
||||
mode="create"
|
||||
defaultType={activeType}
|
||||
trigger={
|
||||
<Button className="w-full sm:w-auto">
|
||||
<RiAddCircleLine className="size-4" />
|
||||
Nova categoria
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeType}
|
||||
onValueChange={(value) => setActiveType(value as CategoryType)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList>
|
||||
{CATEGORY_TYPES.map((type) => (
|
||||
<TabsTrigger key={type} value={type}>
|
||||
{CATEGORY_TYPE_LABEL[type]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{CATEGORY_TYPES.map((type) => (
|
||||
<TabsContent key={type} value={type} className="mt-4">
|
||||
{categoriesByType[type].length === 0 ? (
|
||||
<div className="flex min-h-[280px] items-center justify-center rounded-lg border border-dashed bg-muted/10 p-10 text-center text-sm text-muted-foreground">
|
||||
Ainda não há categorias de{" "}
|
||||
{CATEGORY_TYPE_LABEL[type].toLowerCase()}.
|
||||
</div>
|
||||
) : (
|
||||
<Card className="py-2">
|
||||
<CardContent className="px-2 py-4 sm:px-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead className="text-right">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{categoriesByType[type].map((category, index) => {
|
||||
const isProtegida = CATEGORIAS_PROTEGIDAS.includes(
|
||||
category.name,
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={category.id}>
|
||||
<TableCell>
|
||||
<CategoryIconBadge
|
||||
icon={category.icon}
|
||||
name={category.name}
|
||||
colorIndex={index}
|
||||
size="md"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={`/categories/${category.id}`}
|
||||
className="inline-flex items-center gap-1 underline-offset-2 hover:text-primary hover:underline"
|
||||
>
|
||||
{category.name}
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-3 text-sm">
|
||||
{!isProtegida && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(category)}
|
||||
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
|
||||
>
|
||||
<RiPencilLine
|
||||
className="size-4"
|
||||
aria-hidden
|
||||
/>
|
||||
editar
|
||||
</button>
|
||||
)}
|
||||
{!isProtegida && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleRemoveRequest(category)
|
||||
}
|
||||
className="flex items-center gap-1 font-medium text-destructive transition-opacity hover:opacity-80"
|
||||
>
|
||||
<RiDeleteBin5Line
|
||||
className="size-4"
|
||||
aria-hidden
|
||||
/>
|
||||
remover
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<CategoryDialog
|
||||
mode="update"
|
||||
category={selectedCategory ?? undefined}
|
||||
open={editOpen && !!selectedCategory}
|
||||
onOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
|
||||
<ConfirmActionDialog
|
||||
open={removeOpen && !!categoryToRemove}
|
||||
onOpenChange={handleRemoveOpenChange}
|
||||
title={removeTitle}
|
||||
description="Ao remover esta categoria, os lançamentos associados serão desrelacionados."
|
||||
confirmLabel="Remover categoria"
|
||||
pendingLabel="Removendo..."
|
||||
confirmVariant="destructive"
|
||||
onConfirm={handleRemoveConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
133
src/features/categories/components/category-detail-header.tsx
Normal file
133
src/features/categories/components/category-detail-header.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { RiArrowDownSFill, RiArrowUpSFill } from "@remixicon/react";
|
||||
import { TypeBadge } from "@/shared/components/type-badge";
|
||||
import { Card } from "@/shared/components/ui/card";
|
||||
import type { CategoryType } from "@/shared/lib/categories/constants";
|
||||
import { currencyFormatter } from "@/shared/utils/currency";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import { CategoryIconBadge } from "./category-icon-badge";
|
||||
|
||||
type CategorySummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: CategoryType;
|
||||
};
|
||||
|
||||
type CategoryDetailHeaderProps = {
|
||||
category: CategorySummary;
|
||||
currentPeriodLabel: string;
|
||||
previousPeriodLabel: string;
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
percentageChange: number | null;
|
||||
transactionCount: number;
|
||||
};
|
||||
|
||||
export function CategoryDetailHeader({
|
||||
category,
|
||||
currentPeriodLabel,
|
||||
previousPeriodLabel,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
percentageChange,
|
||||
transactionCount,
|
||||
}: CategoryDetailHeaderProps) {
|
||||
const isIncrease =
|
||||
typeof percentageChange === "number" && percentageChange > 0;
|
||||
const isDecrease =
|
||||
typeof percentageChange === "number" && percentageChange < 0;
|
||||
|
||||
const variationColor =
|
||||
category.type === "receita"
|
||||
? isIncrease
|
||||
? "text-success"
|
||||
: isDecrease
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
: isIncrease
|
||||
? "text-destructive"
|
||||
: isDecrease
|
||||
? "text-success"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const variationIcon =
|
||||
isIncrease || isDecrease ? (
|
||||
isIncrease ? (
|
||||
<RiArrowUpSFill className="size-4" aria-hidden />
|
||||
) : (
|
||||
<RiArrowDownSFill className="size-4" aria-hidden />
|
||||
)
|
||||
) : null;
|
||||
|
||||
const variationLabel =
|
||||
typeof percentageChange === "number"
|
||||
? formatPercentage(percentageChange, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
absolute: true,
|
||||
signDisplay: percentageChange === 0 ? "auto" : "always",
|
||||
})
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<Card className="px-4">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<CategoryIconBadge
|
||||
icon={category.icon}
|
||||
name={category.name}
|
||||
colorIndex={0}
|
||||
size="lg"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-semibold leading-tight">
|
||||
{category.name}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<TypeBadge type={category.type} />
|
||||
<span>
|
||||
{transactionCount}{" "}
|
||||
{transactionCount === 1 ? "lançamento" : "lançamentos"} no{" "}
|
||||
período
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full gap-4 sm:grid-cols-2 lg:w-auto lg:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Total em {currentPeriodLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">
|
||||
{currencyFormatter.format(currentTotal)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Total em {previousPeriodLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-medium text-muted-foreground">
|
||||
{currencyFormatter.format(previousTotal)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Variação vs mês anterior
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex items-center gap-1 text-xl font-semibold",
|
||||
variationColor,
|
||||
)}
|
||||
>
|
||||
{variationIcon}
|
||||
<span>{variationLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
180
src/features/categories/components/category-dialog.tsx
Normal file
180
src/features/categories/components/category-dialog.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createCategoryAction,
|
||||
updateCategoryAction,
|
||||
} from "@/features/categories/actions";
|
||||
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 { CATEGORY_TYPES } from "@/shared/lib/categories/constants";
|
||||
import { getDefaultIconForType } from "@/shared/lib/categories/icons";
|
||||
|
||||
import { CategoryFormFields } from "./category-form-fields";
|
||||
import type { Category, CategoryFormValues } from "./types";
|
||||
|
||||
interface CategoryDialogProps {
|
||||
mode: "create" | "update";
|
||||
trigger?: React.ReactNode;
|
||||
category?: Category;
|
||||
defaultType?: CategoryFormValues["type"];
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const buildInitialValues = ({
|
||||
category,
|
||||
defaultType,
|
||||
}: {
|
||||
category?: Category;
|
||||
defaultType?: CategoryFormValues["type"];
|
||||
}): CategoryFormValues => {
|
||||
const initialType = category?.type ?? defaultType ?? CATEGORY_TYPES[0];
|
||||
const fallbackIcon = getDefaultIconForType();
|
||||
const existingIcon = category?.icon ?? "";
|
||||
const icon = existingIcon || fallbackIcon;
|
||||
|
||||
return {
|
||||
name: category?.name ?? "",
|
||||
type: initialType,
|
||||
icon,
|
||||
};
|
||||
};
|
||||
|
||||
export function CategoryDialog({
|
||||
mode,
|
||||
trigger,
|
||||
category,
|
||||
defaultType,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CategoryDialogProps) {
|
||||
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 initialState = useMemo(
|
||||
() =>
|
||||
buildInitialValues({
|
||||
category,
|
||||
defaultType,
|
||||
}),
|
||||
[category, defaultType],
|
||||
);
|
||||
|
||||
// Use form state hook for form management
|
||||
const { formState, resetForm, updateField } =
|
||||
useFormState<CategoryFormValues>(initialState);
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
resetForm(initialState);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [dialogOpen, initialState, resetForm]);
|
||||
|
||||
// Clear error when dialog closes
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [dialogOpen]);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (mode === "update" && !category?.id) {
|
||||
const message = "Categoria inválida.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: formState.name.trim(),
|
||||
type: formState.type,
|
||||
icon: formState.icon.trim(),
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const result =
|
||||
mode === "create"
|
||||
? await createCategoryAction(payload)
|
||||
: await updateCategoryAction({
|
||||
id: category?.id ?? "",
|
||||
...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 categoria" : "Editar categoria";
|
||||
const description =
|
||||
mode === "create"
|
||||
? "Crie uma categoria para organizar seus lançamentos."
|
||||
: "Atualize os detalhes da categoria selecionada.";
|
||||
const submitLabel =
|
||||
mode === "create" ? "Salvar categoria" : "Atualizar categoria";
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<CategoryFormFields values={formState} onChange={updateField} />
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
128
src/features/categories/components/category-form-fields.tsx
Normal file
128
src/features/categories/components/category-form-fields.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { RiMoreLine } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import {
|
||||
CATEGORY_TYPE_LABEL,
|
||||
CATEGORY_TYPES,
|
||||
} from "@/shared/lib/categories/constants";
|
||||
import { getCategoryIconOptions } from "@/shared/lib/categories/icons";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
import { CategoryIcon } from "./category-icon";
|
||||
import { TypeSelectContent } from "./category-select-items";
|
||||
import type { CategoryFormValues } from "./types";
|
||||
|
||||
interface CategoryFormFieldsProps {
|
||||
values: CategoryFormValues;
|
||||
onChange: (field: keyof CategoryFormValues, value: string) => void;
|
||||
}
|
||||
|
||||
export function CategoryFormFields({
|
||||
values,
|
||||
onChange,
|
||||
}: CategoryFormFieldsProps) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const iconOptions = getCategoryIconOptions();
|
||||
|
||||
const handleIconSelect = (icon: string) => {
|
||||
onChange("icon", icon);
|
||||
setPopoverOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="category-name">Nome</Label>
|
||||
<Input
|
||||
id="category-name"
|
||||
value={values.name}
|
||||
onChange={(event) => onChange("name", event.target.value)}
|
||||
placeholder="Ex.: Alimentação"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="category-type">Tipo da categoria</Label>
|
||||
<Select
|
||||
value={values.type}
|
||||
onValueChange={(value) => onChange("type", value)}
|
||||
>
|
||||
<SelectTrigger id="category-type" className="w-full">
|
||||
<SelectValue placeholder="Selecione o tipo">
|
||||
{values.type && (
|
||||
<TypeSelectContent label={CATEGORY_TYPE_LABEL[values.type]} />
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
<TypeSelectContent label={CATEGORY_TYPE_LABEL[type]} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Ícone</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-12 items-center justify-center rounded-lg border bg-muted/30 text-primary">
|
||||
{values.icon ? (
|
||||
<CategoryIcon name={values.icon} className="size-7" />
|
||||
) : (
|
||||
<RiMoreLine className="size-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" className="flex-1">
|
||||
Selecionar ícone
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[480px] p-3" align="start">
|
||||
<div className="grid max-h-96 grid-cols-8 gap-2 overflow-y-auto">
|
||||
{iconOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleIconSelect(option.value)}
|
||||
className={cn(
|
||||
"flex size-12 items-center justify-center rounded-lg border transition-all hover:border-primary hover:bg-primary/5",
|
||||
values.icon === option.value
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
title={option.label}
|
||||
>
|
||||
<CategoryIcon name={option.value} className="size-6" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Escolha um ícone que represente melhor esta categoria.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
src/features/categories/components/category-icon-badge.tsx
Normal file
75
src/features/categories/components/category-icon-badge.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
buildCategoryInitials,
|
||||
getCategoryBgColor,
|
||||
getCategoryColor,
|
||||
} from "@/shared/utils/category-colors";
|
||||
import { getIconComponent } from "@/shared/utils/icons";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
const sizeVariants = {
|
||||
sm: {
|
||||
container: "size-8",
|
||||
icon: "size-4",
|
||||
text: "text-[10px]",
|
||||
},
|
||||
md: {
|
||||
container: "size-9",
|
||||
icon: "size-5",
|
||||
text: "text-xs",
|
||||
},
|
||||
lg: {
|
||||
container: "size-12",
|
||||
icon: "size-6",
|
||||
text: "text-sm",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type CategoryIconBadgeSize = keyof typeof sizeVariants;
|
||||
|
||||
export interface CategoryIconBadgeProps {
|
||||
/** Nome do ícone Remix (ex: "RiShoppingBag3Line") */
|
||||
icon?: string | null;
|
||||
/** Nome da categoria (usado para gerar iniciais como fallback) */
|
||||
name: string;
|
||||
/** Índice para determinar a cor (cicla entre as cores disponíveis) */
|
||||
colorIndex: number;
|
||||
/** Tamanho do badge: sm (32px), md (36px), lg (48px) */
|
||||
size?: CategoryIconBadgeSize;
|
||||
/** Classes adicionais para o container */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CategoryIconBadge({
|
||||
icon,
|
||||
name,
|
||||
colorIndex,
|
||||
size = "md",
|
||||
className,
|
||||
}: CategoryIconBadgeProps) {
|
||||
const IconComponent = icon ? getIconComponent(icon) : null;
|
||||
const initials = buildCategoryInitials(name);
|
||||
const color = getCategoryColor(colorIndex);
|
||||
const bgColor = getCategoryBgColor(colorIndex);
|
||||
const variant = sizeVariants[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center overflow-hidden rounded-full",
|
||||
variant.container,
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{IconComponent ? (
|
||||
<IconComponent className={variant.icon} style={{ color }} />
|
||||
) : (
|
||||
<span className={cn("uppercase", variant.text)} style={{ color }}>
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/features/categories/components/category-icon.tsx
Normal file
28
src/features/categories/components/category-icon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import type { RemixiconComponentType } from "@remixicon/react";
|
||||
import * as RemixIcons from "@remixicon/react";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
const ICONS = RemixIcons as Record<string, RemixiconComponentType | undefined>;
|
||||
const FALLBACK_ICON = ICONS.RiPriceTag3Line;
|
||||
|
||||
interface CategoryIconProps {
|
||||
name?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CategoryIcon({ name, className }: CategoryIconProps) {
|
||||
const IconComponent =
|
||||
(name ? ICONS[name] : undefined) ?? FALLBACK_ICON ?? null;
|
||||
|
||||
if (!IconComponent) {
|
||||
return (
|
||||
<span className={cn("text-xs text-muted-foreground", className)}>
|
||||
{name ?? "Categoria"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <IconComponent className={cn("size-5", className)} aria-hidden />;
|
||||
}
|
||||
14
src/features/categories/components/category-select-items.tsx
Normal file
14
src/features/categories/components/category-select-items.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import StatusDot from "@/shared/components/status-dot";
|
||||
|
||||
export function TypeSelectContent({ label }: { label: string }) {
|
||||
const isReceita = label === "Receita";
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusDot color={isReceita ? "bg-success" : "bg-destructive"} />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
20
src/features/categories/components/types.ts
Normal file
20
src/features/categories/components/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { CategoryType } from "@/shared/lib/categories/constants";
|
||||
|
||||
export type { CategoryType } from "@/shared/lib/categories/constants";
|
||||
export {
|
||||
CATEGORY_TYPE_LABEL,
|
||||
CATEGORY_TYPES,
|
||||
} from "@/shared/lib/categories/constants";
|
||||
|
||||
export type Category = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: CategoryType;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
export type CategoryFormValues = {
|
||||
name: string;
|
||||
type: CategoryType;
|
||||
icon: string;
|
||||
};
|
||||
26
src/features/categories/queries.ts
Normal file
26
src/features/categories/queries.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { type Categoria, categorias } from "@/db/schema";
|
||||
import type { CategoryType } from "@/features/categories/components/types";
|
||||
import { db } from "@/shared/lib/db";
|
||||
|
||||
export type CategoryData = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: CategoryType;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
export async function fetchCategoriesForUser(
|
||||
userId: string,
|
||||
): Promise<CategoryData[]> {
|
||||
const categoryRows = await db.query.categorias.findMany({
|
||||
where: eq(categorias.userId, userId),
|
||||
});
|
||||
|
||||
return categoryRows.map((category: Categoria) => ({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
type: category.type as CategoryType,
|
||||
icon: category.icon,
|
||||
}));
|
||||
}
|
||||
114
src/features/dashboard/accounts-queries.ts
Normal file
114
src/features/dashboard/accounts-queries.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
type RawDashboardAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
logo: string | null;
|
||||
initialBalance: string | number | null;
|
||||
balanceMovements: unknown;
|
||||
};
|
||||
|
||||
export type DashboardAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
logo: string | null;
|
||||
initialBalance: number;
|
||||
balance: number;
|
||||
excludeFromBalance: boolean;
|
||||
};
|
||||
|
||||
export type DashboardAccountsSnapshot = {
|
||||
totalBalance: number;
|
||||
accounts: DashboardAccount[];
|
||||
};
|
||||
|
||||
export async function fetchDashboardAccounts(
|
||||
userId: string,
|
||||
): Promise<DashboardAccountsSnapshot> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: contas.id,
|
||||
name: contas.name,
|
||||
accountType: contas.accountType,
|
||||
status: contas.status,
|
||||
logo: contas.logo,
|
||||
initialBalance: contas.initialBalance,
|
||||
excludeFromBalance: contas.excludeFromBalance,
|
||||
balanceMovements: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||
else ${lancamentos.amount}
|
||||
end
|
||||
),
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(contas)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.contaId, contas.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.isSettled, true),
|
||||
),
|
||||
)
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(contas.userId, userId),
|
||||
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
contas.id,
|
||||
contas.name,
|
||||
contas.accountType,
|
||||
contas.status,
|
||||
contas.logo,
|
||||
contas.initialBalance,
|
||||
contas.excludeFromBalance,
|
||||
);
|
||||
|
||||
const accounts = rows
|
||||
.map(
|
||||
(
|
||||
row: RawDashboardAccount & { excludeFromBalance: boolean },
|
||||
): DashboardAccount => {
|
||||
const initialBalance = toNumber(row.initialBalance);
|
||||
const balanceMovements = toNumber(row.balanceMovements);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
accountType: row.accountType,
|
||||
status: row.status,
|
||||
logo: row.logo,
|
||||
initialBalance,
|
||||
balance: initialBalance + balanceMovements,
|
||||
excludeFromBalance: row.excludeFromBalance,
|
||||
};
|
||||
},
|
||||
)
|
||||
.sort((a, b) => b.balance - a.balance);
|
||||
|
||||
const totalBalance = accounts
|
||||
.filter((account) => !account.excludeFromBalance)
|
||||
.reduce((total, account) => total + account.balance, 0);
|
||||
|
||||
return {
|
||||
totalBalance,
|
||||
accounts,
|
||||
};
|
||||
}
|
||||
53
src/features/dashboard/bills-helpers.ts
Normal file
53
src/features/dashboard/bills-helpers.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import type { PaymentDialogState } from "@/features/dashboard/use-payment-dialog-controller";
|
||||
import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date";
|
||||
import {
|
||||
buildFinancialStatusLabel,
|
||||
formatFinancialDateLabel,
|
||||
} from "@/shared/utils/financial-dates";
|
||||
|
||||
export type BillDialogState = PaymentDialogState;
|
||||
export type BillStatusDateItem = Pick<
|
||||
DashboardBill,
|
||||
"dueDate" | "boletoPaymentDate" | "isSettled"
|
||||
>;
|
||||
|
||||
export const formatBillDateLabel = (value: string | null, prefix?: string) => {
|
||||
return formatFinancialDateLabel(value, prefix);
|
||||
};
|
||||
|
||||
export const buildBillStatusLabel = (bill: BillStatusDateItem) => {
|
||||
return buildFinancialStatusLabel({
|
||||
isSettled: bill.isSettled,
|
||||
dueDate: bill.dueDate,
|
||||
paidAt: bill.boletoPaymentDate,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCurrentBillDateString = () => getBusinessDateString();
|
||||
|
||||
export const isBillOverdue = (bill: DashboardBill) => {
|
||||
if (bill.isSettled || !bill.dueDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isDateOnlyPast(bill.dueDate);
|
||||
};
|
||||
|
||||
export const getBillStatusBadgeVariant = (
|
||||
statusLabel: string,
|
||||
): "success" | "info" => {
|
||||
if (statusLabel.toLowerCase() === "pendente") {
|
||||
return "info";
|
||||
}
|
||||
return "success";
|
||||
};
|
||||
|
||||
export const markBillAsSettled = (
|
||||
bill: DashboardBill,
|
||||
boletoPaymentDate: string,
|
||||
): DashboardBill => ({
|
||||
...bill,
|
||||
isSettled: true,
|
||||
boletoPaymentDate,
|
||||
});
|
||||
96
src/features/dashboard/bills-queries.ts
Normal file
96
src/features/dashboard/bills-queries.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
"use server";
|
||||
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { lancamentos } from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { toDateOnlyString } from "@/shared/utils/date";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||
|
||||
type RawDashboardBill = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: string | number | null;
|
||||
dueDate: string | Date | null;
|
||||
boletoPaymentDate: string | Date | null;
|
||||
isSettled: boolean | null;
|
||||
};
|
||||
|
||||
export type DashboardBill = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
dueDate: string | null;
|
||||
boletoPaymentDate: string | null;
|
||||
isSettled: boolean;
|
||||
};
|
||||
|
||||
export type DashboardBillsSnapshot = {
|
||||
bills: DashboardBill[];
|
||||
totalPendingAmount: number;
|
||||
pendingCount: number;
|
||||
};
|
||||
|
||||
export async function fetchDashboardBills(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<DashboardBillsSnapshot> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { bills: [], totalPendingAmount: 0, pendingCount: 0 };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
dueDate: lancamentos.dueDate,
|
||||
boletoPaymentDate: lancamentos.boletoPaymentDate,
|
||||
isSettled: lancamentos.isSettled,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
eq(lancamentos.pagadorId, adminPagadorId),
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
asc(lancamentos.isSettled),
|
||||
asc(lancamentos.dueDate),
|
||||
asc(lancamentos.name),
|
||||
);
|
||||
|
||||
const bills = rows.map((row: RawDashboardBill): DashboardBill => {
|
||||
const amount = Math.abs(toNumber(row.amount));
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount,
|
||||
dueDate: toDateOnlyString(row.dueDate),
|
||||
boletoPaymentDate: toDateOnlyString(row.boletoPaymentDate),
|
||||
isSettled: Boolean(row.isSettled),
|
||||
};
|
||||
});
|
||||
|
||||
let totalPendingAmount = 0;
|
||||
let pendingCount = 0;
|
||||
|
||||
for (const bill of bills) {
|
||||
if (!bill.isSettled) {
|
||||
totalPendingAmount += bill.amount;
|
||||
pendingCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bills,
|
||||
totalPendingAmount,
|
||||
pendingCount,
|
||||
};
|
||||
}
|
||||
121
src/features/dashboard/categories/category-breakdown.ts
Normal file
121
src/features/dashboard/categories/category-breakdown.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { calculatePercentageChange } from "@/shared/utils/math";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type DashboardCategoryBreakdownItem = {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
categoryIcon: string | null;
|
||||
currentAmount: number;
|
||||
previousAmount: number;
|
||||
percentageChange: number | null;
|
||||
percentageOfTotal: number;
|
||||
budgetAmount: number | null;
|
||||
budgetUsedPercentage: number | null;
|
||||
};
|
||||
|
||||
export type DashboardCategoryBreakdownData = {
|
||||
categories: DashboardCategoryBreakdownItem[];
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
};
|
||||
|
||||
type CategoryBreakdownRow = {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
categoryIcon: string | null;
|
||||
period: string | null;
|
||||
total: unknown;
|
||||
};
|
||||
|
||||
type CategoryBudgetRow = {
|
||||
categoriaId: string | null;
|
||||
amount: unknown;
|
||||
};
|
||||
|
||||
export function buildCategoryBreakdownData({
|
||||
rows,
|
||||
budgetRows,
|
||||
period,
|
||||
}: {
|
||||
rows: CategoryBreakdownRow[];
|
||||
budgetRows: CategoryBudgetRow[];
|
||||
period: string;
|
||||
}): DashboardCategoryBreakdownData {
|
||||
const budgetMap = new Map<string, number>();
|
||||
for (const row of budgetRows) {
|
||||
if (row.categoriaId) {
|
||||
budgetMap.set(row.categoriaId, toNumber(row.amount));
|
||||
}
|
||||
}
|
||||
|
||||
const categoryMap = new Map<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
icon: string | null;
|
||||
current: number;
|
||||
previous: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const entry = categoryMap.get(row.categoryId) ?? {
|
||||
name: row.categoryName,
|
||||
icon: row.categoryIcon,
|
||||
current: 0,
|
||||
previous: 0,
|
||||
};
|
||||
|
||||
const amount = Math.abs(toNumber(row.total));
|
||||
if (row.period === period) {
|
||||
entry.current = amount;
|
||||
} else {
|
||||
entry.previous = amount;
|
||||
}
|
||||
|
||||
categoryMap.set(row.categoryId, entry);
|
||||
}
|
||||
|
||||
let currentTotal = 0;
|
||||
let previousTotal = 0;
|
||||
for (const entry of categoryMap.values()) {
|
||||
currentTotal += entry.current;
|
||||
previousTotal += entry.previous;
|
||||
}
|
||||
|
||||
const categories: DashboardCategoryBreakdownItem[] = [];
|
||||
for (const [categoryId, entry] of categoryMap) {
|
||||
const percentageChange = calculatePercentageChange(
|
||||
entry.current,
|
||||
entry.previous,
|
||||
);
|
||||
const percentageOfTotal =
|
||||
currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
|
||||
|
||||
const budgetAmount = budgetMap.get(categoryId) ?? null;
|
||||
const budgetUsedPercentage =
|
||||
budgetAmount && budgetAmount > 0
|
||||
? (entry.current / budgetAmount) * 100
|
||||
: null;
|
||||
|
||||
categories.push({
|
||||
categoryId,
|
||||
categoryName: entry.name,
|
||||
categoryIcon: entry.icon,
|
||||
currentAmount: entry.current,
|
||||
previousAmount: entry.previous,
|
||||
percentageChange,
|
||||
percentageOfTotal,
|
||||
budgetAmount,
|
||||
budgetUsedPercentage,
|
||||
});
|
||||
}
|
||||
|
||||
categories.sort((a, b) => b.currentAmount - a.currentAmount);
|
||||
|
||||
return {
|
||||
categories,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
};
|
||||
}
|
||||
136
src/features/dashboard/categories/category-details-queries.ts
Normal file
136
src/features/dashboard/categories/category-details-queries.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { and, desc, eq, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import { categorias, contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import { mapLancamentosData } from "@/features/transactions/page-helpers";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/shared/lib/accounts/constants";
|
||||
import type { CategoryType } from "@/shared/lib/categories/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||
import { calculatePercentageChange } from "@/shared/utils/math";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
import { getPreviousPeriod } from "@/shared/utils/period";
|
||||
|
||||
type MappedLancamentos = ReturnType<typeof mapLancamentosData>;
|
||||
|
||||
export type CategoryDetailData = {
|
||||
category: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: CategoryType;
|
||||
};
|
||||
period: string;
|
||||
previousPeriod: string;
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
percentageChange: number | null;
|
||||
transactions: MappedLancamentos;
|
||||
};
|
||||
|
||||
export async function fetchCategoryDetails(
|
||||
userId: string,
|
||||
categoryId: string,
|
||||
period: string,
|
||||
): Promise<CategoryDetailData | null> {
|
||||
const category = await db.query.categorias.findFirst({
|
||||
where: and(eq(categorias.userId, userId), eq(categorias.id, categoryId)),
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
const transactionType = category.type === "receita" ? "Receita" : "Despesa";
|
||||
|
||||
const sanitizedNote = or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
);
|
||||
|
||||
const currentRows = await db.query.lancamentos.findMany({
|
||||
where: and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.categoriaId, categoryId),
|
||||
eq(lancamentos.transactionType, transactionType),
|
||||
eq(lancamentos.period, period),
|
||||
sanitizedNote,
|
||||
),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
orderBy: [desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)],
|
||||
});
|
||||
|
||||
const filteredRows = currentRows.filter((row) => {
|
||||
// Filtrar apenas pagadores admin
|
||||
if (row.pagador?.role !== PAGADOR_ROLE_ADMIN) return false;
|
||||
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
if (
|
||||
row.note === INITIAL_BALANCE_NOTE &&
|
||||
row.conta?.excludeInitialBalanceFromIncome
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const transactions = mapLancamentosData(filteredRows);
|
||||
|
||||
const currentTotal = transactions.reduce(
|
||||
(total, transaction) => total + Math.abs(toNumber(transaction.amount)),
|
||||
0,
|
||||
);
|
||||
|
||||
const [previousTotalRow] = await db
|
||||
.select({
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.categoriaId, categoryId),
|
||||
eq(lancamentos.transactionType, transactionType),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
sanitizedNote,
|
||||
eq(lancamentos.period, previousPeriod),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const previousTotal = Math.abs(toNumber(previousTotalRow?.total ?? 0));
|
||||
const percentageChange = calculatePercentageChange(
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
);
|
||||
|
||||
return {
|
||||
category: {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
icon: category.icon,
|
||||
type: category.type as CategoryType,
|
||||
},
|
||||
period,
|
||||
previousPeriod,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
percentageChange,
|
||||
transactions,
|
||||
};
|
||||
}
|
||||
208
src/features/dashboard/categories/category-history-queries.ts
Normal file
208
src/features/dashboard/categories/category-history-queries.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { categorias, lancamentos, pagadores } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||
import { CATEGORY_COLORS } from "@/shared/utils/category-colors";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
import {
|
||||
addMonthsToPeriod,
|
||||
buildPeriodWindow,
|
||||
formatPeriodMonthShort,
|
||||
} from "@/shared/utils/period";
|
||||
|
||||
export type CategoryOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: "receita" | "despesa";
|
||||
};
|
||||
|
||||
export type CategoryHistoryItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
color: string;
|
||||
data: Record<string, number>;
|
||||
};
|
||||
|
||||
export type CategoryHistoryData = {
|
||||
months: string[]; // ["NOV", "DEZ", "JAN", ...]
|
||||
categories: CategoryHistoryItem[];
|
||||
chartData: Array<{
|
||||
month: string;
|
||||
[categoryName: string]: number | string;
|
||||
}>;
|
||||
allCategories: CategoryOption[];
|
||||
};
|
||||
|
||||
const CHART_COLORS = CATEGORY_COLORS;
|
||||
type MonthlyCategoryRow = {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
categoryIcon: string | null;
|
||||
period: string;
|
||||
totalAmount: unknown;
|
||||
};
|
||||
|
||||
type UniqueCategory = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
export async function fetchAllCategories(
|
||||
userId: string,
|
||||
): Promise<CategoryOption[]> {
|
||||
const result = await db
|
||||
.select({
|
||||
id: categorias.id,
|
||||
name: categorias.name,
|
||||
icon: categorias.icon,
|
||||
type: categorias.type,
|
||||
})
|
||||
.from(categorias)
|
||||
.where(eq(categorias.userId, userId))
|
||||
.orderBy(categorias.type, categorias.name);
|
||||
|
||||
return result as CategoryOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches category expense/income history for all categories with transactions
|
||||
* Widget will allow user to select up to 5 to display
|
||||
*/
|
||||
export async function fetchCategoryHistory(
|
||||
userId: string,
|
||||
currentPeriod: string,
|
||||
): Promise<CategoryHistoryData> {
|
||||
// Generate last 8 months, current month, and next month (10 total)
|
||||
const periods = buildPeriodWindow(addMonthsToPeriod(currentPeriod, 1), 10);
|
||||
const monthLabels = periods.map((period) =>
|
||||
formatPeriodMonthShort(period).toUpperCase(),
|
||||
);
|
||||
|
||||
// Fetch all categories for the selector
|
||||
const allCategories = await fetchAllCategories(userId);
|
||||
|
||||
// Fetch monthly data for ALL categories with transactions
|
||||
const monthlyDataQuery = (await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
period: lancamentos.period,
|
||||
totalAmount: sql<string>`SUM(ABS(${lancamentos.amount}))`.as(
|
||||
"total_amount",
|
||||
),
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(categorias.userId, userId),
|
||||
inArray(lancamentos.period, periods),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
lancamentos.period,
|
||||
)) as MonthlyCategoryRow[];
|
||||
|
||||
if (monthlyDataQuery.length === 0) {
|
||||
return {
|
||||
months: monthLabels,
|
||||
categories: [],
|
||||
chartData: monthLabels.map((month) => ({ month })),
|
||||
allCategories,
|
||||
};
|
||||
}
|
||||
|
||||
// Get unique categories from query results
|
||||
const uniqueCategories: UniqueCategory[] = Array.from(
|
||||
new Map<string, UniqueCategory>(
|
||||
monthlyDataQuery.map((row) => [
|
||||
row.categoryId,
|
||||
{
|
||||
id: row.categoryId,
|
||||
name: row.categoryName,
|
||||
icon: row.categoryIcon,
|
||||
},
|
||||
]),
|
||||
).values(),
|
||||
);
|
||||
|
||||
// Transform data into chart-ready format
|
||||
const categoriesMap = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
color: string;
|
||||
data: Record<string, number>;
|
||||
}
|
||||
>();
|
||||
|
||||
// Initialize ALL categories with transactions with all months set to 0
|
||||
uniqueCategories.forEach((cat, index) => {
|
||||
const monthData: Record<string, number> = {};
|
||||
periods.forEach((_period, periodIndex) => {
|
||||
monthData[monthLabels[periodIndex]] = 0;
|
||||
});
|
||||
|
||||
categoriesMap.set(cat.id, {
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
icon: cat.icon,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
data: monthData,
|
||||
});
|
||||
});
|
||||
|
||||
// Fill in actual values from monthly data
|
||||
monthlyDataQuery.forEach((row) => {
|
||||
const category = categoriesMap.get(row.categoryId);
|
||||
if (category) {
|
||||
const periodIndex = periods.indexOf(row.period);
|
||||
if (periodIndex !== -1) {
|
||||
const monthLabel = monthLabels[periodIndex];
|
||||
category.data[monthLabel] = toNumber(row.totalAmount);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to chart data format
|
||||
const chartData: CategoryHistoryData["chartData"] = monthLabels.map(
|
||||
(month) => {
|
||||
const dataPoint: {
|
||||
month: string;
|
||||
[categoryName: string]: number | string;
|
||||
} = { month };
|
||||
|
||||
categoriesMap.forEach((category) => {
|
||||
dataPoint[category.name] = category.data[month];
|
||||
});
|
||||
|
||||
return dataPoint;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
months: monthLabels,
|
||||
categories: Array.from(categoriesMap.values()),
|
||||
chartData,
|
||||
allCategories,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { categorias, lancamentos, orcamentos } from "@/db/schema";
|
||||
import {
|
||||
buildCategoryBreakdownData,
|
||||
type DashboardCategoryBreakdownData,
|
||||
type DashboardCategoryBreakdownItem,
|
||||
} from "@/features/dashboard/categories/category-breakdown";
|
||||
import {
|
||||
buildDashboardAdminFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
} from "@/features/dashboard/lancamento-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { getPreviousPeriod } from "@/shared/utils/period";
|
||||
|
||||
export type CategoryExpenseItem = DashboardCategoryBreakdownItem;
|
||||
export type ExpensesByCategoryData = DashboardCategoryBreakdownData;
|
||||
|
||||
export async function fetchExpensesByCategory(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<ExpensesByCategoryData> {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { categories: [], currentTotal: 0, previousTotal: 0 };
|
||||
}
|
||||
|
||||
// Single query: GROUP BY categoriaId + period for both current and previous periods
|
||||
const [rows, budgetRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
period: lancamentos.period,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminFilters({ userId, adminPagadorId }),
|
||||
inArray(lancamentos.period, [period, previousPeriod]),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(categorias.type, "despesa"),
|
||||
excludeAutoInvoiceEntries(),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
lancamentos.period,
|
||||
),
|
||||
db
|
||||
.select({
|
||||
categoriaId: orcamentos.categoriaId,
|
||||
amount: orcamentos.amount,
|
||||
})
|
||||
.from(orcamentos)
|
||||
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
|
||||
]);
|
||||
|
||||
return buildCategoryBreakdownData({
|
||||
rows,
|
||||
budgetRows,
|
||||
period,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { categorias, contas, lancamentos, orcamentos } from "@/db/schema";
|
||||
import {
|
||||
buildCategoryBreakdownData,
|
||||
type DashboardCategoryBreakdownData,
|
||||
type DashboardCategoryBreakdownItem,
|
||||
} from "@/features/dashboard/categories/category-breakdown";
|
||||
import {
|
||||
buildDashboardAdminFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
excludeInitialBalanceWhenConfigured,
|
||||
} from "@/features/dashboard/lancamento-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { getPreviousPeriod } from "@/shared/utils/period";
|
||||
|
||||
export type CategoryIncomeItem = DashboardCategoryBreakdownItem;
|
||||
export type IncomeByCategoryData = DashboardCategoryBreakdownData;
|
||||
|
||||
export async function fetchIncomeByCategory(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<IncomeByCategoryData> {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { categories: [], currentTotal: 0, previousTotal: 0 };
|
||||
}
|
||||
|
||||
// Single query: GROUP BY categoriaId + period for both current and previous periods
|
||||
const [rows, budgetRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
period: lancamentos.period,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminFilters({ userId, adminPagadorId }),
|
||||
inArray(lancamentos.period, [period, previousPeriod]),
|
||||
eq(lancamentos.transactionType, "Receita"),
|
||||
eq(categorias.type, "receita"),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeInitialBalanceWhenConfigured(),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
lancamentos.period,
|
||||
),
|
||||
db
|
||||
.select({
|
||||
categoriaId: orcamentos.categoriaId,
|
||||
amount: orcamentos.amount,
|
||||
})
|
||||
.from(orcamentos)
|
||||
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
|
||||
]);
|
||||
|
||||
return buildCategoryBreakdownData({
|
||||
rows,
|
||||
budgetRows,
|
||||
period,
|
||||
});
|
||||
}
|
||||
35
src/features/dashboard/components/bill-widget.tsx
Normal file
35
src/features/dashboard/components/bill-widget.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import { useBillWidgetController } from "@/features/dashboard/use-bill-widget-controller";
|
||||
import { BillsWidgetView } from "./bills/bills-widget-view";
|
||||
|
||||
type BillWidgetProps = {
|
||||
bills?: DashboardBill[];
|
||||
};
|
||||
|
||||
export function BillWidget({ bills }: BillWidgetProps) {
|
||||
const {
|
||||
items,
|
||||
selectedBill,
|
||||
isModalOpen,
|
||||
modalState,
|
||||
isPending,
|
||||
openPaymentDialog,
|
||||
closePaymentDialog,
|
||||
confirmPayment,
|
||||
} = useBillWidgetController(bills);
|
||||
|
||||
return (
|
||||
<BillsWidgetView
|
||||
bills={items}
|
||||
selectedBill={selectedBill}
|
||||
isModalOpen={isModalOpen}
|
||||
modalState={modalState}
|
||||
isPending={isPending}
|
||||
onOpenPaymentDialog={openPaymentDialog}
|
||||
onClosePaymentDialog={closePaymentDialog}
|
||||
onConfirmPayment={confirmPayment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
73
src/features/dashboard/components/bills/bill-list-item.tsx
Normal file
73
src/features/dashboard/components/bills/bill-list-item.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { RiCheckboxCircleFill } from "@remixicon/react";
|
||||
import {
|
||||
buildBillStatusLabel,
|
||||
isBillOverdue,
|
||||
} from "@/features/dashboard/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import { EstabelecimentoLogo } from "@/features/transactions/components/shared/establishment-logo";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
type BillListItemProps = {
|
||||
bill: DashboardBill;
|
||||
onPay: (billId: string) => void;
|
||||
};
|
||||
|
||||
export function BillListItem({ bill, onPay }: BillListItemProps) {
|
||||
const statusLabel = buildBillStatusLabel(bill);
|
||||
const overdue = isBillOverdue(bill);
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
|
||||
<EstabelecimentoLogo name={bill.name} size={37} />
|
||||
|
||||
<div className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
{bill.name}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{statusLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full py-0.5",
|
||||
bill.isSettled && "text-success",
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={bill.amount} />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="h-auto p-0 disabled:opacity-100"
|
||||
disabled={bill.isSettled}
|
||||
onClick={() => onPay(bill.id)}
|
||||
>
|
||||
{bill.isSettled ? (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<RiCheckboxCircleFill className="size-3" /> Pago
|
||||
</span>
|
||||
) : overdue ? (
|
||||
<span className="overdue-blink">
|
||||
<span className="overdue-blink-primary text-destructive">
|
||||
Atrasado
|
||||
</span>
|
||||
<span className="overdue-blink-secondary">Pagar</span>
|
||||
</span>
|
||||
) : (
|
||||
"Pagar"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
189
src/features/dashboard/components/bills/bill-payment-dialog.tsx
Normal file
189
src/features/dashboard/components/bills/bill-payment-dialog.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
RiBarcodeFill,
|
||||
RiCheckboxCircleLine,
|
||||
RiLoader4Line,
|
||||
RiMoneyDollarCircleLine,
|
||||
} from "@remixicon/react";
|
||||
import {
|
||||
type BillDialogState,
|
||||
formatBillDateLabel,
|
||||
getBillStatusBadgeVariant,
|
||||
} from "@/features/dashboard/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
|
||||
type BillPaymentDialogProps = {
|
||||
bill: DashboardBill | null;
|
||||
open: boolean;
|
||||
modalState: BillDialogState;
|
||||
isPending: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function BillPaymentDialog({
|
||||
bill,
|
||||
open,
|
||||
modalState,
|
||||
isPending,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: BillPaymentDialogProps) {
|
||||
const isProcessing = modalState === "processing" || isPending;
|
||||
const dueLabel = bill
|
||||
? formatBillDateLabel(bill.dueDate, "Vencimento:")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen || isProcessing) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[calc(100%-2rem)] sm:max-w-md"
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (isProcessing) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (isProcessing) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{modalState === "success" ? (
|
||||
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
|
||||
<RiCheckboxCircleLine className="size-8" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<DialogTitle className="text-base">
|
||||
Pagamento registrado!
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
Atualizamos o status do boleto para pago. Em instantes ele
|
||||
aparecerá como baixado no histórico.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogFooter className="sm:justify-center">
|
||||
<Button type="button" onClick={onClose} className="sm:w-auto">
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar pagamento do boleto</DialogTitle>
|
||||
<DialogDescription>
|
||||
Confirme os dados para registrar o pagamento. Você poderá editar
|
||||
o lançamento depois, se necessário.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{bill ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<RiBarcodeFill className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Boleto
|
||||
</p>
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
{bill.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{dueLabel ? (
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{dueLabel}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RiMoneyDollarCircleLine className="size-4" />
|
||||
<span className="text-xs font-semibold uppercase">
|
||||
Valor do Boleto
|
||||
</span>
|
||||
</div>
|
||||
<MoneyValues
|
||||
amount={bill.amount}
|
||||
className="text-lg font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RiCheckboxCircleLine className="size-4" />
|
||||
<span className="text-xs font-semibold uppercase">
|
||||
Status
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={getBillStatusBadgeVariant(
|
||||
bill.isSettled ? "Pago" : "Pendente",
|
||||
)}
|
||||
>
|
||||
{bill.isSettled ? "Pago" : "Pendente"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isProcessing || !bill || bill.isSettled}
|
||||
className="relative"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
|
||||
Processando...
|
||||
</>
|
||||
) : (
|
||||
"Confirmar pagamento"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
29
src/features/dashboard/components/bills/bills-list.tsx
Normal file
29
src/features/dashboard/components/bills/bills-list.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { RiBarcodeFill } from "@remixicon/react";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { BillListItem } from "./bill-list-item";
|
||||
|
||||
type BillsListProps = {
|
||||
bills: DashboardBill[];
|
||||
onPay: (billId: string) => void;
|
||||
};
|
||||
|
||||
export function BillsList({ bills, onPay }: BillsListProps) {
|
||||
if (bills.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiBarcodeFill className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum boleto cadastrado para o período selecionado"
|
||||
description="Cadastre boletos para monitorar os pagamentos aqui."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col">
|
||||
{bills.map((bill) => (
|
||||
<BillListItem key={bill.id} bill={bill} onPay={onPay} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { BillDialogState } from "@/features/dashboard/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import { BillPaymentDialog } from "./bill-payment-dialog";
|
||||
import { BillsList } from "./bills-list";
|
||||
|
||||
type BillsWidgetViewProps = {
|
||||
bills: DashboardBill[];
|
||||
selectedBill: DashboardBill | null;
|
||||
isModalOpen: boolean;
|
||||
modalState: BillDialogState;
|
||||
isPending: boolean;
|
||||
onOpenPaymentDialog: (billId: string) => void;
|
||||
onClosePaymentDialog: () => void;
|
||||
onConfirmPayment: () => void;
|
||||
};
|
||||
|
||||
export function BillsWidgetView({
|
||||
bills,
|
||||
selectedBill,
|
||||
isModalOpen,
|
||||
modalState,
|
||||
isPending,
|
||||
onOpenPaymentDialog,
|
||||
onClosePaymentDialog,
|
||||
onConfirmPayment,
|
||||
}: BillsWidgetViewProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<BillsList bills={bills} onPay={onOpenPaymentDialog} />
|
||||
</div>
|
||||
|
||||
<BillPaymentDialog
|
||||
bill={selectedBill}
|
||||
open={isModalOpen}
|
||||
modalState={modalState}
|
||||
isPending={isPending}
|
||||
onClose={onClosePaymentDialog}
|
||||
onConfirm={onConfirmPayment}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiArrowDownSFill,
|
||||
RiArrowUpSFill,
|
||||
RiExternalLinkLine,
|
||||
RiListUnordered,
|
||||
RiPieChart2Line,
|
||||
RiPieChartLine,
|
||||
RiWallet3Line,
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Pie, PieChart, Tooltip } from "recharts";
|
||||
import { CategoryIconBadge } from "@/features/categories/components/category-icon-badge";
|
||||
import type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { type ChartConfig, ChartContainer } from "@/shared/components/ui/chart";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/shared/components/ui/tabs";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
import { formatPercentage as formatPercentageValue } from "@/shared/utils/percentage";
|
||||
import { formatPeriodForUrl } from "@/shared/utils/period";
|
||||
|
||||
type CategoryBreakdownVariant = "income" | "expense";
|
||||
|
||||
type CategoryBreakdownWidgetViewProps = {
|
||||
data: DashboardCategoryBreakdownData;
|
||||
period: string;
|
||||
variant: CategoryBreakdownVariant;
|
||||
};
|
||||
|
||||
const CATEGORY_BREAKDOWN_COLORS = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
"var(--chart-5)",
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
];
|
||||
|
||||
const VARIANT_CONFIG = {
|
||||
income: {
|
||||
emptyTitle: "Nenhuma receita encontrada",
|
||||
emptyDescription:
|
||||
"Quando houver receitas registradas, elas aparecerão aqui.",
|
||||
shareLabel: "receita total",
|
||||
percentageDigits: 1,
|
||||
changeClassName: {
|
||||
increase: "text-success",
|
||||
decrease: "text-destructive",
|
||||
},
|
||||
listItemClassName:
|
||||
"flex flex-col gap-1.5 py-2 border-b border-dashed last:border-0",
|
||||
includeBudgetAmount: true,
|
||||
},
|
||||
expense: {
|
||||
emptyTitle: "Nenhuma despesa encontrada",
|
||||
emptyDescription:
|
||||
"Quando houver despesas registradas, elas aparecerão aqui.",
|
||||
shareLabel: "despesa total",
|
||||
percentageDigits: 0,
|
||||
changeClassName: {
|
||||
increase: "text-destructive",
|
||||
decrease: "text-success",
|
||||
},
|
||||
listItemClassName:
|
||||
"flex flex-col py-2 border-b border-dashed last:border-0",
|
||||
includeBudgetAmount: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const formatPercentage = (value: number, digits: number) =>
|
||||
formatPercentageValue(value, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
export function CategoryBreakdownWidgetView({
|
||||
data,
|
||||
period,
|
||||
variant,
|
||||
}: CategoryBreakdownWidgetViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
|
||||
const periodParam = formatPeriodForUrl(period);
|
||||
const config = VARIANT_CONFIG[variant];
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
const nextConfig: ChartConfig = {};
|
||||
|
||||
if (data.categories.length <= 7) {
|
||||
data.categories.forEach((category, index) => {
|
||||
nextConfig[category.categoryId] = {
|
||||
label: category.categoryName,
|
||||
color:
|
||||
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const topCategories = data.categories.slice(0, 7);
|
||||
topCategories.forEach((category, index) => {
|
||||
nextConfig[category.categoryId] = {
|
||||
label: category.categoryName,
|
||||
color:
|
||||
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
|
||||
};
|
||||
});
|
||||
nextConfig.outros = {
|
||||
label: "Outros",
|
||||
color: "var(--chart-6)",
|
||||
};
|
||||
}
|
||||
|
||||
return nextConfig;
|
||||
}, [data.categories]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (data.categories.length <= 7) {
|
||||
return data.categories.map((category) => ({
|
||||
category: category.categoryId,
|
||||
name: category.categoryName,
|
||||
value: category.currentAmount,
|
||||
percentage: category.percentageOfTotal,
|
||||
fill: chartConfig[category.categoryId]?.color,
|
||||
}));
|
||||
}
|
||||
|
||||
const topCategories = data.categories.slice(0, 7);
|
||||
const otherCategories = data.categories.slice(7);
|
||||
const otherTotal = otherCategories.reduce(
|
||||
(sum, category) => sum + category.currentAmount,
|
||||
0,
|
||||
);
|
||||
const otherPercentage = otherCategories.reduce(
|
||||
(sum, category) => sum + category.percentageOfTotal,
|
||||
0,
|
||||
);
|
||||
|
||||
const groupedData = topCategories.map((category) => ({
|
||||
category: category.categoryId,
|
||||
name: category.categoryName,
|
||||
value: category.currentAmount,
|
||||
percentage: category.percentageOfTotal,
|
||||
fill: chartConfig[category.categoryId]?.color,
|
||||
}));
|
||||
|
||||
if (otherCategories.length > 0) {
|
||||
groupedData.push({
|
||||
category: "outros",
|
||||
name: "Outros",
|
||||
value: otherTotal,
|
||||
percentage: otherPercentage,
|
||||
fill: chartConfig.outros?.color,
|
||||
});
|
||||
}
|
||||
|
||||
return groupedData;
|
||||
}, [data.categories, chartConfig]);
|
||||
|
||||
if (data.categories.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
|
||||
title={config.emptyTitle}
|
||||
description={config.emptyDescription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value: string) => setActiveTab(value as "list" | "chart")}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="list" className="text-xs">
|
||||
<RiListUnordered className="mr-1 size-3.5" />
|
||||
Lista
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chart" className="text-xs">
|
||||
<RiPieChart2Line className="mr-1 size-3.5" />
|
||||
Gráfico
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="list" className="mt-0">
|
||||
<div className="flex flex-col px-0">
|
||||
{data.categories.map((category, index) => {
|
||||
const hasIncrease =
|
||||
category.percentageChange !== null &&
|
||||
category.percentageChange > 0;
|
||||
const hasDecrease =
|
||||
category.percentageChange !== null &&
|
||||
category.percentageChange < 0;
|
||||
const hasBudget = category.budgetAmount !== null;
|
||||
const budgetExceeded =
|
||||
hasBudget &&
|
||||
category.budgetUsedPercentage !== null &&
|
||||
category.budgetUsedPercentage > 100;
|
||||
const exceededAmount =
|
||||
budgetExceeded && category.budgetAmount
|
||||
? category.currentAmount - category.budgetAmount
|
||||
: 0;
|
||||
const changeClassName = hasIncrease
|
||||
? config.changeClassName.increase
|
||||
: hasDecrease
|
||||
? config.changeClassName.decrease
|
||||
: "text-muted-foreground";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category.categoryId}
|
||||
className={config.listItemClassName}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<CategoryIconBadge
|
||||
icon={category.categoryIcon}
|
||||
name={category.categoryName}
|
||||
colorIndex={index}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/categories/${category.categoryId}?periodo=${periodParam}`}
|
||||
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
<span className="truncate">
|
||||
{category.categoryName}
|
||||
</span>
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatPercentage(
|
||||
category.percentageOfTotal,
|
||||
config.percentageDigits,
|
||||
)}{" "}
|
||||
da {config.shareLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||
<MoneyValues
|
||||
className="text-foreground"
|
||||
amount={category.currentAmount}
|
||||
/>
|
||||
{category.percentageChange !== null ? (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs ${changeClassName}`}
|
||||
>
|
||||
{hasIncrease ? (
|
||||
<RiArrowUpSFill className="size-3" />
|
||||
) : null}
|
||||
{hasDecrease ? (
|
||||
<RiArrowDownSFill className="size-3" />
|
||||
) : null}
|
||||
{formatPercentage(
|
||||
category.percentageChange,
|
||||
config.percentageDigits,
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasBudget && category.budgetUsedPercentage !== null ? (
|
||||
<div className="ml-11 flex items-center gap-1.5 text-xs">
|
||||
<RiWallet3Line
|
||||
className={`size-3 ${
|
||||
budgetExceeded ? "text-destructive" : "text-info"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
budgetExceeded ? "text-destructive" : "text-info"
|
||||
}
|
||||
>
|
||||
{budgetExceeded ? (
|
||||
<>
|
||||
{formatPercentage(
|
||||
category.budgetUsedPercentage,
|
||||
config.percentageDigits,
|
||||
)}{" "}
|
||||
do limite
|
||||
{config.includeBudgetAmount &&
|
||||
category.budgetAmount !== null
|
||||
? ` ${formatCurrency(category.budgetAmount)}`
|
||||
: ""}{" "}
|
||||
- excedeu em {formatCurrency(exceededAmount)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{formatPercentage(
|
||||
category.budgetUsedPercentage,
|
||||
config.percentageDigits,
|
||||
)}{" "}
|
||||
do limite
|
||||
{config.includeBudgetAmount &&
|
||||
category.budgetAmount !== null
|
||||
? ` ${formatCurrency(category.budgetAmount)}`
|
||||
: ""}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="chart" className="mt-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ payload }) =>
|
||||
formatPercentage(
|
||||
(payload as { percentage?: number } | undefined)
|
||||
?.percentage ?? 0,
|
||||
config.percentageDigits,
|
||||
)
|
||||
}
|
||||
outerRadius={75}
|
||||
dataKey="value"
|
||||
nameKey="category"
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = payload[0]?.payload;
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="font-bold text-foreground">
|
||||
{formatCurrency(entry.value)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatPercentage(
|
||||
entry.percentage,
|
||||
config.percentageDigits,
|
||||
)}{" "}
|
||||
do total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="min-w-[140px] flex flex-col gap-2">
|
||||
{chartData.map((entry, index) => (
|
||||
<div key={`legend-${index}`} className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: entry.fill }}
|
||||
/>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
443
src/features/dashboard/components/category-history-widget.tsx
Normal file
443
src/features/dashboard/components/category-history-widget.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
"use client";
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiBarChartBoxLine,
|
||||
RiCloseLine,
|
||||
} from "@remixicon/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import type { CategoryHistoryData } from "@/features/dashboard/categories/category-history-queries";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
} from "@/shared/components/ui/chart";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/shared/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/components/ui/popover";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { CATEGORY_COLORS } from "@/shared/utils/category-colors";
|
||||
import { formatCurrency, formatCurrencyCompact } from "@/shared/utils/currency";
|
||||
import { getIconComponent } from "@/shared/utils/icons";
|
||||
|
||||
type CategoryHistoryWidgetProps = {
|
||||
data: CategoryHistoryData;
|
||||
};
|
||||
|
||||
const STORAGE_KEY_SELECTED = "dashboard-category-history-selected";
|
||||
|
||||
const CHART_COLORS = CATEGORY_COLORS;
|
||||
|
||||
export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
// Load from sessionStorage on mount and save on changes
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
|
||||
// Only load from storage on first render
|
||||
if (isFirstRender.current) {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed)) {
|
||||
const validCategories = parsed.filter((id) =>
|
||||
data.allCategories.some((cat) => cat.id === id),
|
||||
);
|
||||
setSelectedCategories(validCategories.slice(0, 5));
|
||||
}
|
||||
} catch (_e) {
|
||||
// Invalid JSON, ignore
|
||||
}
|
||||
}
|
||||
isFirstRender.current = false;
|
||||
} else {
|
||||
// Save to storage on subsequent changes
|
||||
sessionStorage.setItem(
|
||||
STORAGE_KEY_SELECTED,
|
||||
JSON.stringify(selectedCategories),
|
||||
);
|
||||
}
|
||||
}, [selectedCategories, data.allCategories]);
|
||||
|
||||
// Filter data to show only selected categories with vibrant colors
|
||||
const filteredCategories = useMemo(() => {
|
||||
return selectedCategories
|
||||
.map((id, index) => {
|
||||
const cat = data.categories.find((c) => c.id === id);
|
||||
if (!cat) return null;
|
||||
return {
|
||||
...cat,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
color: string;
|
||||
data: Record<string, number>;
|
||||
}>;
|
||||
}, [data.categories, selectedCategories]);
|
||||
|
||||
// Filter chart data to include only selected categories
|
||||
const filteredChartData = useMemo(() => {
|
||||
if (filteredCategories.length === 0) {
|
||||
return data.chartData.map((item) => ({ month: item.month }));
|
||||
}
|
||||
|
||||
return data.chartData.map((item) => {
|
||||
const filtered: Record<string, number | string> = { month: item.month };
|
||||
filteredCategories.forEach((category) => {
|
||||
filtered[category.name] = item[category.name] || 0;
|
||||
});
|
||||
return filtered;
|
||||
});
|
||||
}, [data.chartData, filteredCategories]);
|
||||
|
||||
// Build chart config dynamically from filtered categories
|
||||
const chartConfig = useMemo(() => {
|
||||
const config: ChartConfig = {};
|
||||
|
||||
filteredCategories.forEach((category) => {
|
||||
config[category.name] = {
|
||||
label: category.name,
|
||||
color: category.color,
|
||||
};
|
||||
});
|
||||
|
||||
return config;
|
||||
}, [filteredCategories]);
|
||||
|
||||
const handleAddCategory = (categoryId: string) => {
|
||||
if (
|
||||
categoryId &&
|
||||
!selectedCategories.includes(categoryId) &&
|
||||
selectedCategories.length < 5
|
||||
) {
|
||||
setSelectedCategories([...selectedCategories, categoryId]);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCategory = (categoryId: string) => {
|
||||
setSelectedCategories(selectedCategories.filter((id) => id !== categoryId));
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
setSelectedCategories([]);
|
||||
};
|
||||
|
||||
const availableCategories = useMemo(() => {
|
||||
return data.allCategories.filter(
|
||||
(cat) => !selectedCategories.includes(cat.id),
|
||||
);
|
||||
}, [data.allCategories, selectedCategories]);
|
||||
|
||||
const selectedCategoryDetails = useMemo(() => {
|
||||
return selectedCategories
|
||||
.map((id) => data.allCategories.find((cat) => cat.id === id))
|
||||
.filter(Boolean);
|
||||
}, [selectedCategories, data.allCategories]);
|
||||
|
||||
const isEmpty = filteredCategories.length === 0;
|
||||
|
||||
// Group available categories by type
|
||||
const { despesaCategories, receitaCategories } = useMemo(() => {
|
||||
const despesa = availableCategories.filter((cat) => cat.type === "despesa");
|
||||
const receita = availableCategories.filter((cat) => cat.type === "receita");
|
||||
return { despesaCategories: despesa, receitaCategories: receita };
|
||||
}, [availableCategories]);
|
||||
|
||||
if (!isClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-auto">
|
||||
<CardContent className="space-y-2.5">
|
||||
<div className="space-y-2">
|
||||
{selectedCategoryDetails.length > 0 && (
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedCategoryDetails.map((category) => {
|
||||
if (!category) return null;
|
||||
const IconComponent = category.icon
|
||||
? getIconComponent(category.icon)
|
||||
: null;
|
||||
const colorIndex = selectedCategories.indexOf(category.id);
|
||||
const color = CHART_COLORS[colorIndex % CHART_COLORS.length];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||
style={{ borderColor: color }}
|
||||
>
|
||||
{IconComponent ? (
|
||||
<span style={{ color }}>
|
||||
<IconComponent className="size-4" />
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
className="size-3 rounded-sm"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-foreground">{category.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||
onClick={() => handleRemoveCategory(category.id)}
|
||||
>
|
||||
<RiCloseLine className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 pt-1.5">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{selectedCategories.length}/5 selecionadas
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCategories.length < 5 && availableCategories.length > 0 && (
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between hover:scale-none"
|
||||
>
|
||||
Selecionar categorias
|
||||
<RiArrowDownSLine className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-(--radix-popover-trigger-width) p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Pesquisar categoria..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
|
||||
|
||||
{despesaCategories.length > 0 && (
|
||||
<CommandGroup heading="Despesas">
|
||||
{despesaCategories.map((category) => {
|
||||
const IconComponent = category.icon
|
||||
? getIconComponent(category.icon)
|
||||
: null;
|
||||
return (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
value={category.name}
|
||||
onSelect={() => handleAddCategory(category.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
{IconComponent ? (
|
||||
<IconComponent className="size-4 text-destructive" />
|
||||
) : (
|
||||
<div className="size-3 rounded-sm bg-destructive" />
|
||||
)}
|
||||
<span>{category.name}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{receitaCategories.length > 0 && (
|
||||
<CommandGroup heading="Receitas">
|
||||
{receitaCategories.map((category) => {
|
||||
const IconComponent = category.icon
|
||||
? getIconComponent(category.icon)
|
||||
: null;
|
||||
return (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
value={category.name}
|
||||
onSelect={() => handleAddCategory(category.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
{IconComponent ? (
|
||||
<IconComponent className="size-4 text-success" />
|
||||
) : (
|
||||
<div className="size-3 rounded-sm bg-success" />
|
||||
)}
|
||||
<span>{category.name}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="h-[450px] flex items-center justify-center">
|
||||
<WidgetEmptyState
|
||||
icon={
|
||||
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
|
||||
}
|
||||
title="Selecione categorias para visualizar"
|
||||
description="Escolha até 5 categorias para acompanhar o histórico dos últimos 8 meses, mês atual e próximo mês."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="h-[450px] w-full">
|
||||
<AreaChart
|
||||
data={filteredChartData}
|
||||
margin={{ top: 10, right: 20, left: 10, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
{filteredCategories.map((category) => (
|
||||
<linearGradient
|
||||
key={`gradient-${category.id}`}
|
||||
id={`gradient-${category.id}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={category.color}
|
||||
stopOpacity={0.4}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={category.color}
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
className="text-xs"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
className="text-xs"
|
||||
tickFormatter={(value) => formatCurrencyCompact(Number(value))}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload || payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort payload by value (descending)
|
||||
const sortedPayload = [...payload].sort(
|
||||
(a, b) => (b.value as number) - (a.value as number),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
{payload[0].payload.month}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
{sortedPayload
|
||||
.filter((entry) => (entry.value as number) > 0)
|
||||
.map((entry) => {
|
||||
const config =
|
||||
chartConfig[
|
||||
entry.dataKey as keyof typeof chartConfig
|
||||
];
|
||||
const value = entry.value as number;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.dataKey}
|
||||
className="flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: config?.color }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
|
||||
{config?.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium tabular-nums">
|
||||
{formatCurrency(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
cursor={{
|
||||
stroke: "hsl(var(--muted-foreground))",
|
||||
strokeWidth: 1,
|
||||
}}
|
||||
/>
|
||||
{filteredCategories.map((category) => (
|
||||
<Area
|
||||
key={category.id}
|
||||
type="monotone"
|
||||
dataKey={category.name}
|
||||
stroke={category.color}
|
||||
strokeWidth={1}
|
||||
fill={`url(#gradient-${category.id})`}
|
||||
fillOpacity={1}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 5,
|
||||
fill: category.color,
|
||||
stroke: "hsl(var(--background))",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
390
src/features/dashboard/components/dashboard-grid-editable.tsx
Normal file
390
src/features/dashboard/components/dashboard-grid-editable.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
closestCorners,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
rectSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
RiAddCircleLine,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiDragMove2Line,
|
||||
RiEyeOffLine,
|
||||
RiTodoLine,
|
||||
} from "@remixicon/react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { SortableWidget } from "@/features/dashboard/components/sortable-widget";
|
||||
import { WidgetSettingsDialog } from "@/features/dashboard/components/widget-settings-dialog";
|
||||
import type { DashboardData } from "@/features/dashboard/fetch-dashboard-data";
|
||||
import {
|
||||
resetWidgetPreferences,
|
||||
updateWidgetPreferences,
|
||||
type WidgetPreferences,
|
||||
} from "@/features/dashboard/widgets/actions";
|
||||
import {
|
||||
type WidgetConfig,
|
||||
widgetsConfig,
|
||||
} from "@/features/dashboard/widgets/widgets-config";
|
||||
import { NoteDialog } from "@/features/notes/components/note-dialog";
|
||||
import { LancamentoDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
||||
import type { SelectOption } from "@/features/transactions/components/types";
|
||||
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
type DashboardGridEditableProps = {
|
||||
data: DashboardData;
|
||||
period: string;
|
||||
initialPreferences: WidgetPreferences | null;
|
||||
quickActionOptions: {
|
||||
pagadorOptions: SelectOption[];
|
||||
splitPagadorOptions: SelectOption[];
|
||||
defaultPagadorId: string | null;
|
||||
contaOptions: SelectOption[];
|
||||
cartaoOptions: SelectOption[];
|
||||
categoriaOptions: SelectOption[];
|
||||
estabelecimentos: string[];
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_WIDGET_ORDER = widgetsConfig.map((widget) => widget.id);
|
||||
|
||||
export function DashboardGridEditable({
|
||||
data,
|
||||
period,
|
||||
initialPreferences,
|
||||
quickActionOptions,
|
||||
}: DashboardGridEditableProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Initialize widget order and hidden state
|
||||
const [widgetOrder, setWidgetOrder] = useState<string[]>(
|
||||
initialPreferences?.order ?? DEFAULT_WIDGET_ORDER,
|
||||
);
|
||||
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
|
||||
initialPreferences?.hidden ?? [],
|
||||
);
|
||||
|
||||
// Keep track of original state for cancel
|
||||
const [originalOrder, setOriginalOrder] = useState(widgetOrder);
|
||||
const [originalHidden, setOriginalHidden] = useState(hiddenWidgets);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
// Get ordered and visible widgets
|
||||
const orderedWidgets = useMemo(() => {
|
||||
// Create a map for quick lookup
|
||||
const widgetMap = new Map(widgetsConfig.map((w) => [w.id, w]));
|
||||
|
||||
// Get widgets in order, filtering out hidden ones
|
||||
const ordered: WidgetConfig[] = [];
|
||||
for (const id of widgetOrder) {
|
||||
const widget = widgetMap.get(id);
|
||||
if (widget && !hiddenWidgets.includes(id)) {
|
||||
ordered.push(widget);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any new widgets that might not be in the order yet
|
||||
for (const widget of widgetsConfig) {
|
||||
if (
|
||||
!widgetOrder.includes(widget.id) &&
|
||||
!hiddenWidgets.includes(widget.id)
|
||||
) {
|
||||
ordered.push(widget);
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}, [widgetOrder, hiddenWidgets]);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
setWidgetOrder((items) => {
|
||||
const oldIndex = items.indexOf(active.id as string);
|
||||
const newIndex = items.indexOf(over.id as string);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleWidget = (widgetId: string) => {
|
||||
const newHidden = hiddenWidgets.includes(widgetId)
|
||||
? hiddenWidgets.filter((id) => id !== widgetId)
|
||||
: [...hiddenWidgets, widgetId];
|
||||
|
||||
setHiddenWidgets(newHidden);
|
||||
|
||||
// Salvar automaticamente ao toggle
|
||||
startTransition(async () => {
|
||||
await updateWidgetPreferences({
|
||||
order: widgetOrder,
|
||||
hidden: newHidden,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleHideWidget = (widgetId: string) => {
|
||||
setHiddenWidgets((prev) => [...prev, widgetId]);
|
||||
};
|
||||
|
||||
const handleStartEditing = () => {
|
||||
setOriginalOrder(widgetOrder);
|
||||
setOriginalHidden(hiddenWidgets);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleCancelEditing = () => {
|
||||
setWidgetOrder(originalOrder);
|
||||
setHiddenWidgets(originalHidden);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
startTransition(async () => {
|
||||
const result = await updateWidgetPreferences({
|
||||
order: widgetOrder,
|
||||
hidden: hiddenWidgets,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Preferências salvas!");
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
toast.error(result.error ?? "Erro ao salvar");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
startTransition(async () => {
|
||||
const result = await resetWidgetPreferences();
|
||||
|
||||
if (result.success) {
|
||||
setWidgetOrder(DEFAULT_WIDGET_ORDER);
|
||||
setHiddenWidgets([]);
|
||||
toast.success("Preferências restauradas!");
|
||||
} else {
|
||||
toast.error(result.error ?? "Erro ao restaurar");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{!isEditing ? (
|
||||
<div className="flex w-full min-w-0 flex-col gap-1 px-1 sm:w-auto sm:flex-row sm:items-center sm:gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Ações rápidas
|
||||
</span>
|
||||
<div className="-mb-1 grid w-full grid-cols-3 gap-1 pb-1 sm:mb-0 sm:flex sm:w-auto sm:items-center sm:gap-2 sm:overflow-visible sm:pb-0">
|
||||
<LancamentoDialog
|
||||
mode="create"
|
||||
pagadorOptions={quickActionOptions.pagadorOptions}
|
||||
splitPagadorOptions={quickActionOptions.splitPagadorOptions}
|
||||
defaultPagadorId={quickActionOptions.defaultPagadorId}
|
||||
contaOptions={quickActionOptions.contaOptions}
|
||||
cartaoOptions={quickActionOptions.cartaoOptions}
|
||||
categoriaOptions={quickActionOptions.categoriaOptions}
|
||||
estabelecimentos={quickActionOptions.estabelecimentos}
|
||||
defaultPeriod={period}
|
||||
defaultTransactionType="Receita"
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-12 w-full min-w-0 flex-col justify-center gap-0.5 px-1.5 text-sm whitespace-normal sm:h-8 sm:w-auto sm:flex-row sm:gap-2 sm:px-3 sm:whitespace-nowrap"
|
||||
>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<RiAddCircleLine className="size-3.5 shrink-0 text-success/80" />
|
||||
</span>
|
||||
<span className="sm:hidden">Receita</span>
|
||||
<span className="hidden sm:inline">Nova receita</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<LancamentoDialog
|
||||
mode="create"
|
||||
pagadorOptions={quickActionOptions.pagadorOptions}
|
||||
splitPagadorOptions={quickActionOptions.splitPagadorOptions}
|
||||
defaultPagadorId={quickActionOptions.defaultPagadorId}
|
||||
contaOptions={quickActionOptions.contaOptions}
|
||||
cartaoOptions={quickActionOptions.cartaoOptions}
|
||||
categoriaOptions={quickActionOptions.categoriaOptions}
|
||||
estabelecimentos={quickActionOptions.estabelecimentos}
|
||||
defaultPeriod={period}
|
||||
defaultTransactionType="Despesa"
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-12 w-full min-w-0 flex-col justify-center gap-0.5 px-1.5 text-sm whitespace-normal sm:h-8 sm:w-auto sm:flex-row sm:gap-2 sm:px-3 sm:whitespace-nowrap"
|
||||
>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<RiAddCircleLine className="size-3.5 shrink-0 text-destructive/80" />
|
||||
</span>
|
||||
<span className="sm:hidden">Despesa</span>
|
||||
<span className="hidden sm:inline">Nova despesa</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<NoteDialog
|
||||
mode="create"
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-12 w-full min-w-0 flex-col justify-center gap-0.5 px-1.5 text-sm whitespace-normal sm:h-8 sm:w-auto sm:flex-row sm:gap-2 sm:px-3 sm:whitespace-nowrap"
|
||||
>
|
||||
<RiTodoLine className="size-3.5 shrink-0 text-info/80" />
|
||||
<span className="sm:hidden">Anotação</span>
|
||||
<span className="hidden sm:inline">Nova anotação</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
<div className="flex w-full items-center justify-end gap-2 sm:w-auto">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelEditing}
|
||||
disabled={isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiCheckLine className="size-4" />
|
||||
Salvar
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto">
|
||||
<WidgetSettingsDialog
|
||||
hiddenWidgets={hiddenWidgets}
|
||||
onToggleWidget={handleToggleWidget}
|
||||
onReset={handleReset}
|
||||
triggerClassName="w-full sm:w-auto"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStartEditing}
|
||||
className="w-full gap-2 sm:w-auto"
|
||||
>
|
||||
<RiDragMove2Line className="size-4" />
|
||||
Reordenar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={orderedWidgets.map((w) => w.id)}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<section className="grid grid-cols-1 gap-3 @4xl/main:grid-cols-2 @6xl/main:grid-cols-3">
|
||||
{orderedWidgets.map((widget) => (
|
||||
<SortableWidget
|
||||
key={widget.id}
|
||||
id={widget.id}
|
||||
isEditing={isEditing}
|
||||
>
|
||||
<div className="relative">
|
||||
{isEditing && (
|
||||
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[1px] rounded-lg border-2 border-dashed border-primary/50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RiDragMove2Line className="size-8 text-primary" />
|
||||
<span className="text-xs font-bold">
|
||||
Arraste para mover
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHideWidget(widget.id);
|
||||
}}
|
||||
className="gap-1 mt-2"
|
||||
>
|
||||
<RiEyeOffLine className="size-4" />
|
||||
Ocultar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ExpandableWidgetCard
|
||||
title={widget.title}
|
||||
subtitle={widget.subtitle}
|
||||
icon={widget.icon}
|
||||
action={widget.action}
|
||||
>
|
||||
{widget.component({ data, period })}
|
||||
</ExpandableWidgetCard>
|
||||
</div>
|
||||
</SortableWidget>
|
||||
))}
|
||||
</section>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Hidden widgets indicator */}
|
||||
{hiddenWidgets.length > 0 && !isEditing && (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{hiddenWidgets.length} widget(s) oculto(s) •{" "}
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Restaurar todos
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
src/features/dashboard/components/dashboard-metrics-cards.tsx
Normal file
127
src/features/dashboard/components/dashboard-metrics-cards.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
RiArrowDownLine,
|
||||
RiArrowDownSFill,
|
||||
RiArrowUpLine,
|
||||
RiArrowUpSFill,
|
||||
RiCashLine,
|
||||
RiIncreaseDecreaseLine,
|
||||
RiSubtractLine,
|
||||
} from "@remixicon/react";
|
||||
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
|
||||
type DashboardMetricsCardsProps = {
|
||||
metrics: DashboardCardMetrics;
|
||||
};
|
||||
|
||||
type Trend = "up" | "down" | "flat";
|
||||
|
||||
const TREND_THRESHOLD = 0.005;
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
label: "Receitas",
|
||||
key: "receitas",
|
||||
icon: RiArrowUpLine,
|
||||
invertTrend: false,
|
||||
},
|
||||
{
|
||||
label: "Despesas",
|
||||
key: "despesas",
|
||||
icon: RiArrowDownLine,
|
||||
invertTrend: true,
|
||||
},
|
||||
{
|
||||
label: "Balanço",
|
||||
key: "balanco",
|
||||
icon: RiIncreaseDecreaseLine,
|
||||
invertTrend: false,
|
||||
},
|
||||
{ label: "Previsto", key: "previsto", icon: RiCashLine, invertTrend: false },
|
||||
] as const;
|
||||
|
||||
const TREND_ICONS = {
|
||||
up: RiArrowUpSFill,
|
||||
down: RiArrowDownSFill,
|
||||
flat: RiSubtractLine,
|
||||
} as const;
|
||||
|
||||
const getTrend = (current: number, previous: number): Trend => {
|
||||
const diff = current - previous;
|
||||
if (diff > TREND_THRESHOLD) return "up";
|
||||
if (diff < -TREND_THRESHOLD) return "down";
|
||||
return "flat";
|
||||
};
|
||||
|
||||
const getPercentChange = (current: number, previous: number): string => {
|
||||
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
|
||||
|
||||
if (Math.abs(previous) < EPSILON) {
|
||||
if (Math.abs(current) < EPSILON) return "0%";
|
||||
return "—";
|
||||
}
|
||||
|
||||
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||
return Number.isFinite(change) && Math.abs(change) < 1000000
|
||||
? formatPercentage(change, {
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: 1,
|
||||
signDisplay: "always",
|
||||
})
|
||||
: "—";
|
||||
};
|
||||
|
||||
const getTrendColor = (trend: Trend, invertTrend: boolean): string => {
|
||||
if (trend === "flat") return "";
|
||||
const isPositive = invertTrend ? trend === "down" : trend === "up";
|
||||
return isPositive
|
||||
? "text-success border-success"
|
||||
: "text-destructive border-destructive";
|
||||
};
|
||||
|
||||
export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||
return (
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||
{CARDS.map(({ label, key, icon: Icon, invertTrend }) => {
|
||||
const metric = metrics[key];
|
||||
const trend = getTrend(metric.current, metric.previous);
|
||||
const TrendIcon = TREND_ICONS[trend];
|
||||
const trendColor = getTrendColor(trend, invertTrend);
|
||||
|
||||
return (
|
||||
<Card key={label} className="@container/card gap-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-1 tracking-tighter lowercase">
|
||||
<Icon className="size-4" />
|
||||
{label}
|
||||
</CardTitle>
|
||||
<MoneyValues className="text-2xl" amount={metric.current} />
|
||||
<CardAction>
|
||||
<div className={`flex items-center text-xs ${trendColor}`}>
|
||||
<TrendIcon size={16} />
|
||||
{getPercentChange(metric.current, metric.previous)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-2 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 text-xs">
|
||||
mês anterior
|
||||
</div>
|
||||
<div className="text-foreground">
|
||||
<MoneyValues amount={metric.previous} />
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/features/dashboard/components/dashboard-welcome.tsx
Normal file
18
src/features/dashboard/components/dashboard-welcome.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { formatCurrentDate, getGreeting } from "./welcome-widget";
|
||||
|
||||
export function DashboardWelcome({ name }: { name?: string | null }) {
|
||||
const displayName = name && name.trim().length > 0 ? name : "Administrador";
|
||||
const formattedDate = formatCurrentDate();
|
||||
const greeting = getGreeting();
|
||||
|
||||
return (
|
||||
<section className="p-2">
|
||||
<div className="tracking-tight">
|
||||
<h1 className="text-xl">
|
||||
{greeting}, {displayName}
|
||||
</h1>
|
||||
<p className="text-sm mt-1">{formattedDate}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import type { ExpensesByCategoryData } from "@/features/dashboard/categories/expenses-by-category-queries";
|
||||
import { CategoryBreakdownWidgetView } from "./category-breakdown/category-breakdown-widget-view";
|
||||
|
||||
type ExpensesByCategoryWidgetWithChartProps = {
|
||||
data: ExpensesByCategoryData;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export function ExpensesByCategoryWidgetWithChart({
|
||||
data,
|
||||
period,
|
||||
}: ExpensesByCategoryWidgetWithChartProps) {
|
||||
return (
|
||||
<CategoryBreakdownWidgetView
|
||||
data={data}
|
||||
period={period}
|
||||
variant="expense"
|
||||
/>
|
||||
);
|
||||
}
|
||||
32
src/features/dashboard/components/goals-progress-widget.tsx
Normal file
32
src/features/dashboard/components/goals-progress-widget.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import type { GoalsProgressData } from "@/features/dashboard/goals-progress-queries";
|
||||
import { useGoalsProgressWidgetController } from "@/features/dashboard/use-goals-progress-widget-controller";
|
||||
import { GoalsProgressWidgetView } from "./goals-progress/goals-progress-widget-view";
|
||||
|
||||
type GoalsProgressWidgetProps = {
|
||||
data: GoalsProgressData;
|
||||
};
|
||||
|
||||
export function GoalsProgressWidget({ data }: GoalsProgressWidgetProps) {
|
||||
const {
|
||||
selectedBudget,
|
||||
editOpen,
|
||||
categories,
|
||||
defaultPeriod,
|
||||
handleEdit,
|
||||
handleEditOpenChange,
|
||||
} = useGoalsProgressWidgetController(data);
|
||||
|
||||
return (
|
||||
<GoalsProgressWidgetView
|
||||
data={data}
|
||||
selectedBudget={selectedBudget}
|
||||
editOpen={editOpen}
|
||||
categories={categories}
|
||||
defaultPeriod={defaultPeriod}
|
||||
onEdit={handleEdit}
|
||||
onEditOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { RiPencilLine } from "@remixicon/react";
|
||||
import { CategoryIconBadge } from "@/features/categories/components/category-icon-badge";
|
||||
import {
|
||||
clampGoalProgress,
|
||||
formatGoalProgressPercentage,
|
||||
getGoalProgressStatusColorClass,
|
||||
} from "@/features/dashboard/goals-progress-helpers";
|
||||
import type { GoalProgressItem as GoalProgressItemData } from "@/features/dashboard/goals-progress-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
|
||||
type GoalProgressItemProps = {
|
||||
item: GoalProgressItemData;
|
||||
index: number;
|
||||
onEdit: (item: GoalProgressItemData) => void;
|
||||
};
|
||||
|
||||
export function GoalProgressItem({
|
||||
item,
|
||||
index,
|
||||
onEdit,
|
||||
}: GoalProgressItemProps) {
|
||||
const statusColor = getGoalProgressStatusColorClass(item.status);
|
||||
const progressValue = clampGoalProgress(item.usedPercentage, 0, 100);
|
||||
const percentageDelta = item.usedPercentage - 100;
|
||||
|
||||
return (
|
||||
<li className="border-b border-dashed py-2 last:border-b-0 last:pb-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
<CategoryIconBadge
|
||||
icon={item.categoryIcon}
|
||||
name={item.categoryName}
|
||||
colorIndex={index}
|
||||
size="md"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{item.categoryName}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<MoneyValues amount={item.spentAmount} /> de{" "}
|
||||
<MoneyValues amount={item.budgetAmount} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className={`text-xs font-medium ${statusColor}`}>
|
||||
{formatGoalProgressPercentage(percentageDelta, true)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onEdit(item)}
|
||||
aria-label={`Editar orçamento de ${item.categoryName}`}
|
||||
>
|
||||
<RiPencilLine className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-11 mt-1.5">
|
||||
<Progress value={progressValue} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { RiFundsLine } from "@remixicon/react";
|
||||
import type { GoalProgressItem } from "@/features/dashboard/goals-progress-queries";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { GoalProgressItem as GoalProgressListItem } from "./goal-progress-item";
|
||||
|
||||
type GoalsProgressListProps = {
|
||||
items: GoalProgressItem[];
|
||||
onEdit: (item: GoalProgressItem) => void;
|
||||
};
|
||||
|
||||
export function GoalsProgressList({ items, onEdit }: GoalsProgressListProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiFundsLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum orçamento para o período"
|
||||
description="Cadastre orçamentos para acompanhar o progresso das metas."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col">
|
||||
{items.map((item, index) => (
|
||||
<GoalProgressListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BudgetDialog } from "@/features/budgets/components/budget-dialog";
|
||||
import type {
|
||||
Budget,
|
||||
BudgetCategory,
|
||||
} from "@/features/budgets/components/types";
|
||||
|
||||
type GoalsProgressWidgetDialogsProps = {
|
||||
selectedBudget: Budget | null;
|
||||
editOpen: boolean;
|
||||
categories: BudgetCategory[];
|
||||
defaultPeriod: string;
|
||||
onEditOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function GoalsProgressWidgetDialogs({
|
||||
selectedBudget,
|
||||
editOpen,
|
||||
categories,
|
||||
defaultPeriod,
|
||||
onEditOpenChange,
|
||||
}: GoalsProgressWidgetDialogsProps) {
|
||||
return (
|
||||
<BudgetDialog
|
||||
mode="update"
|
||||
budget={selectedBudget ?? undefined}
|
||||
categories={categories}
|
||||
defaultPeriod={defaultPeriod}
|
||||
open={editOpen && !!selectedBudget}
|
||||
onOpenChange={onEditOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
Budget,
|
||||
BudgetCategory,
|
||||
} from "@/features/budgets/components/types";
|
||||
import type {
|
||||
GoalProgressItem,
|
||||
GoalsProgressData,
|
||||
} from "@/features/dashboard/goals-progress-queries";
|
||||
import { GoalsProgressList } from "./goals-progress-list";
|
||||
import { GoalsProgressWidgetDialogs } from "./goals-progress-widget-dialogs";
|
||||
|
||||
type GoalsProgressWidgetViewProps = {
|
||||
data: GoalsProgressData;
|
||||
selectedBudget: Budget | null;
|
||||
editOpen: boolean;
|
||||
categories: BudgetCategory[];
|
||||
defaultPeriod: string;
|
||||
onEdit: (item: GoalProgressItem) => void;
|
||||
onEditOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function GoalsProgressWidgetView({
|
||||
data,
|
||||
selectedBudget,
|
||||
editOpen,
|
||||
categories,
|
||||
defaultPeriod,
|
||||
onEdit,
|
||||
onEditOpenChange,
|
||||
}: GoalsProgressWidgetViewProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<GoalsProgressList items={data.items} onEdit={onEdit} />
|
||||
|
||||
<GoalsProgressWidgetDialogs
|
||||
selectedBudget={selectedBudget}
|
||||
editOpen={editOpen}
|
||||
categories={categories}
|
||||
defaultPeriod={defaultPeriod}
|
||||
onEditOpenChange={onEditOpenChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import type { IncomeByCategoryData } from "@/features/dashboard/categories/income-by-category-queries";
|
||||
import { CategoryBreakdownWidgetView } from "./category-breakdown/category-breakdown-widget-view";
|
||||
|
||||
type IncomeByCategoryWidgetWithChartProps = {
|
||||
data: IncomeByCategoryData;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export function IncomeByCategoryWidgetWithChart({
|
||||
data,
|
||||
period,
|
||||
}: IncomeByCategoryWidgetWithChartProps) {
|
||||
return (
|
||||
<CategoryBreakdownWidgetView data={data} period={period} variant="income" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { RiLineChartLine } from "@remixicon/react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
||||
import type { IncomeExpenseBalanceData } from "@/features/dashboard/income-expense-balance-queries";
|
||||
import { CardContent } from "@/shared/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
} from "@/shared/components/ui/chart";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
|
||||
type IncomeExpenseBalanceWidgetProps = {
|
||||
data: IncomeExpenseBalanceData;
|
||||
};
|
||||
|
||||
const chartConfig = {
|
||||
receita: {
|
||||
label: "Receita",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
despesa: {
|
||||
label: "Despesa",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
balanco: {
|
||||
label: "Balanço",
|
||||
color: "var(--chart-3)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function IncomeExpenseBalanceWidget({
|
||||
data,
|
||||
}: IncomeExpenseBalanceWidgetProps) {
|
||||
const chartData = data.months.map((month) => ({
|
||||
month: month.monthLabel,
|
||||
receita: month.income,
|
||||
despesa: month.expense,
|
||||
balanco: month.balance,
|
||||
}));
|
||||
|
||||
// Verifica se todos os valores são zero
|
||||
const isEmpty = chartData.every(
|
||||
(item) => item.receita === 0 && item.despesa === 0 && item.balanco === 0,
|
||||
);
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<CardContent className="px-0">
|
||||
<WidgetEmptyState
|
||||
icon={<RiLineChartLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma movimentação financeira no período"
|
||||
description="Registre receitas e despesas para visualizar o balanço mensal."
|
||||
/>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardContent className="space-y-4 px-0">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[270px] w-full aspect-auto"
|
||||
>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 10, left: 10, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload || payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid gap-2">
|
||||
{payload.map((entry) => {
|
||||
const config =
|
||||
chartConfig[entry.dataKey as keyof typeof chartConfig];
|
||||
const value = entry.value as number;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.dataKey}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: config?.color }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{config?.label}:
|
||||
</span>
|
||||
<span className="text-xs font-medium">
|
||||
{formatCurrency(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
cursor={{ fill: "hsl(var(--muted))", opacity: 0.3 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="receita"
|
||||
fill={chartConfig.receita.color}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="despesa"
|
||||
fill={chartConfig.despesa.color}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="balanco"
|
||||
fill={chartConfig.balanco.color}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: chartConfig.receita.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{chartConfig.receita.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: chartConfig.despesa.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{chartConfig.despesa.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: chartConfig.balanco.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{chartConfig.balanco.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiCalculatorLine,
|
||||
RiCheckboxBlankLine,
|
||||
RiCheckboxLine,
|
||||
} from "@remixicon/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { InstallmentGroupCard } from "./installment-group-card";
|
||||
import type { InstallmentAnalysisData } from "./types";
|
||||
|
||||
type InstallmentAnalysisPageProps = {
|
||||
data: InstallmentAnalysisData;
|
||||
};
|
||||
|
||||
export function InstallmentAnalysisPage({
|
||||
data,
|
||||
}: InstallmentAnalysisPageProps) {
|
||||
// Estado para parcelas selecionadas: Map<seriesId, Set<installmentId>>
|
||||
const [selectedInstallments, setSelectedInstallments] = useState<
|
||||
Map<string, Set<string>>
|
||||
>(new Map());
|
||||
|
||||
// Calcular se está tudo selecionado (apenas parcelas não pagas)
|
||||
const isAllSelected = useMemo(() => {
|
||||
const allInstallmentsSelected = data.installmentGroups.every((group) => {
|
||||
const groupSelection = selectedInstallments.get(group.seriesId);
|
||||
const unpaidInstallments = group.pendingInstallments.filter(
|
||||
(i) => !i.isSettled,
|
||||
);
|
||||
if (!groupSelection || unpaidInstallments.length === 0) return false;
|
||||
return groupSelection.size === unpaidInstallments.length;
|
||||
});
|
||||
|
||||
return allInstallmentsSelected && data.installmentGroups.length > 0;
|
||||
}, [selectedInstallments, data]);
|
||||
|
||||
// Função para selecionar/desselecionar tudo
|
||||
const toggleSelectAll = () => {
|
||||
if (isAllSelected) {
|
||||
// Desmarcar tudo
|
||||
setSelectedInstallments(new Map());
|
||||
} else {
|
||||
// Marcar tudo (exceto parcelas já pagas)
|
||||
const newInstallments = new Map<string, Set<string>>();
|
||||
data.installmentGroups.forEach((group) => {
|
||||
const unpaidIds = group.pendingInstallments
|
||||
.filter((i) => !i.isSettled)
|
||||
.map((i) => i.id);
|
||||
if (unpaidIds.length > 0) {
|
||||
newInstallments.set(group.seriesId, new Set(unpaidIds));
|
||||
}
|
||||
});
|
||||
|
||||
setSelectedInstallments(newInstallments);
|
||||
}
|
||||
};
|
||||
|
||||
// Função para selecionar/desselecionar um grupo de parcelas
|
||||
const toggleGroupSelection = (seriesId: string, installmentIds: string[]) => {
|
||||
const newMap = new Map(selectedInstallments);
|
||||
const current = newMap.get(seriesId) || new Set<string>();
|
||||
|
||||
if (current.size === installmentIds.length) {
|
||||
// Já está tudo selecionado, desmarcar
|
||||
newMap.delete(seriesId);
|
||||
} else {
|
||||
// Marcar tudo
|
||||
newMap.set(seriesId, new Set(installmentIds));
|
||||
}
|
||||
|
||||
setSelectedInstallments(newMap);
|
||||
};
|
||||
|
||||
// Função para selecionar/desselecionar parcela individual
|
||||
const toggleInstallmentSelection = (
|
||||
seriesId: string,
|
||||
installmentId: string,
|
||||
) => {
|
||||
const newMap = new Map(selectedInstallments);
|
||||
// Criar uma NOVA instância do Set para React detectar a mudança
|
||||
const current = new Set(newMap.get(seriesId) || []);
|
||||
|
||||
if (current.has(installmentId)) {
|
||||
current.delete(installmentId);
|
||||
if (current.size === 0) {
|
||||
newMap.delete(seriesId);
|
||||
} else {
|
||||
newMap.set(seriesId, current);
|
||||
}
|
||||
} else {
|
||||
current.add(installmentId);
|
||||
newMap.set(seriesId, current);
|
||||
}
|
||||
|
||||
setSelectedInstallments(newMap);
|
||||
};
|
||||
|
||||
// Calcular totais
|
||||
const { grandTotal, selectedCount } = useMemo(() => {
|
||||
let installmentsSum = 0;
|
||||
let installmentsCount = 0;
|
||||
|
||||
selectedInstallments.forEach((installmentIds, seriesId) => {
|
||||
const group = data.installmentGroups.find((g) => g.seriesId === seriesId);
|
||||
if (group) {
|
||||
installmentIds.forEach((id) => {
|
||||
const installment = group.pendingInstallments.find(
|
||||
(i) => i.id === id,
|
||||
);
|
||||
if (installment && !installment.isSettled) {
|
||||
installmentsSum += installment.amount;
|
||||
installmentsCount++;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
grandTotal: installmentsSum,
|
||||
selectedCount: installmentsCount,
|
||||
};
|
||||
}, [selectedInstallments, data]);
|
||||
|
||||
const hasNoData = data.installmentGroups.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Card de resumo principal */}
|
||||
<Card className="border-none bg-primary/15">
|
||||
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Se você pagar tudo que está selecionado:
|
||||
</p>
|
||||
<MoneyValues
|
||||
amount={grandTotal}
|
||||
className="text-3xl font-bold text-primary"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}
|
||||
selecionadas
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Botões de ação */}
|
||||
{!hasNoData && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleSelectAll}
|
||||
className="gap-2"
|
||||
>
|
||||
{isAllSelected ? (
|
||||
<RiCheckboxLine className="size-4" />
|
||||
) : (
|
||||
<RiCheckboxBlankLine className="size-4" />
|
||||
)}
|
||||
{isAllSelected ? "Desmarcar Tudo" : "Selecionar Tudo"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Seção de Lançamentos Parcelados */}
|
||||
{data.installmentGroups.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{data.installmentGroups.map((group) => (
|
||||
<InstallmentGroupCard
|
||||
key={group.seriesId}
|
||||
group={group}
|
||||
selectedInstallments={
|
||||
selectedInstallments.get(group.seriesId) || new Set()
|
||||
}
|
||||
onToggleGroup={() =>
|
||||
toggleGroupSelection(
|
||||
group.seriesId,
|
||||
group.pendingInstallments
|
||||
.filter((i) => !i.isSettled)
|
||||
.map((i) => i.id),
|
||||
)
|
||||
}
|
||||
onToggleInstallment={(installmentId) =>
|
||||
toggleInstallmentSelection(group.seriesId, installmentId)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Estado vazio */}
|
||||
{hasNoData && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center gap-3 py-12">
|
||||
<RiCalculatorLine className="size-12 text-muted-foreground/50" />
|
||||
<div className="text-center">
|
||||
<p className="font-medium">Nenhuma parcela pendente</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Você está em dia com seus pagamentos!
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
RiCheckboxCircleFill,
|
||||
} from "@remixicon/react";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { useState } from "react";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import type { InstallmentGroup } from "./types";
|
||||
|
||||
type InstallmentGroupCardProps = {
|
||||
group: InstallmentGroup;
|
||||
selectedInstallments: Set<string>;
|
||||
onToggleGroup: () => void;
|
||||
onToggleInstallment: (installmentId: string) => void;
|
||||
};
|
||||
|
||||
export function InstallmentGroupCard({
|
||||
group,
|
||||
selectedInstallments,
|
||||
onToggleGroup,
|
||||
onToggleInstallment,
|
||||
}: InstallmentGroupCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const unpaidInstallments = group.pendingInstallments.filter(
|
||||
(i) => !i.isSettled,
|
||||
);
|
||||
|
||||
const unpaidCount = unpaidInstallments.length;
|
||||
|
||||
const isFullySelected =
|
||||
selectedInstallments.size === unpaidInstallments.length &&
|
||||
unpaidInstallments.length > 0;
|
||||
|
||||
const progress =
|
||||
group.totalInstallments > 0
|
||||
? (group.paidInstallments / group.totalInstallments) * 100
|
||||
: 0;
|
||||
|
||||
const selectedAmount = group.pendingInstallments
|
||||
.filter((i) => selectedInstallments.has(i.id) && !i.isSettled)
|
||||
.reduce((sum, i) => sum + Number(i.amount), 0);
|
||||
|
||||
// Calcular valor total de todas as parcelas (pagas + pendentes)
|
||||
const totalAmount = group.pendingInstallments.reduce(
|
||||
(sum, i) => sum + i.amount,
|
||||
0,
|
||||
);
|
||||
|
||||
// Calcular valor pendente (apenas não pagas)
|
||||
const pendingAmount = unpaidInstallments.reduce(
|
||||
(sum, i) => sum + i.amount,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className={cn(isFullySelected && "border-primary/50")}>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{/* Header do card */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isFullySelected}
|
||||
onCheckedChange={onToggleGroup}
|
||||
className="mt-1"
|
||||
aria-label={`Selecionar todas as parcelas de ${group.name}`}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{group.cartaoLogo && (
|
||||
<img
|
||||
src={`/logos/${group.cartaoLogo}`}
|
||||
alt={group.cartaoName ?? "Cartão"}
|
||||
className="h-6 w-auto object-contain rounded"
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium truncate">{group.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
| {group.cartaoName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">Total:</span>
|
||||
<MoneyValues
|
||||
amount={totalAmount}
|
||||
className="text-base font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Pendente:
|
||||
</span>
|
||||
<MoneyValues
|
||||
amount={pendingAmount}
|
||||
className="text-sm font-medium text-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 flex flex-wrap items-center px-1 justify-between gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{group.paidInstallments} de {group.totalInstallments} pagas
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span>
|
||||
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
|
||||
</span>
|
||||
{selectedInstallments.size > 0 && (
|
||||
<span className="text-primary font-medium">
|
||||
• Selecionado:{" "}
|
||||
<MoneyValues
|
||||
amount={selectedAmount}
|
||||
className="text-xs font-medium text-primary inline"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* Botão de expandir */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="mt-2 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<RiArrowDownSLine className="size-4" />
|
||||
Ocultar parcelas ({group.pendingInstallments.length})
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiArrowRightSLine className="size-4" />
|
||||
Ver parcelas ({group.pendingInstallments.length})
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de parcelas expandida */}
|
||||
{isExpanded && (
|
||||
<div className="px-2 sm:px-8 mt-2 flex flex-col gap-2">
|
||||
{group.pendingInstallments.map((installment) => {
|
||||
const isSelected = selectedInstallments.has(installment.id);
|
||||
const isPaid = installment.isSettled;
|
||||
const dueDate = installment.dueDate
|
||||
? format(installment.dueDate, "dd/MM/yyyy", { locale: ptBR })
|
||||
: format(installment.purchaseDate, "dd/MM/yyyy", {
|
||||
locale: ptBR,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={installment.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md border p-2 transition-colors",
|
||||
isSelected && !isPaid && "border-primary/50 bg-primary/5",
|
||||
isPaid &&
|
||||
"border-success/40 bg-success/5 dark:border-success/20 dark:bg-success/5",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isPaid ? false : isSelected}
|
||||
disabled={isPaid}
|
||||
onCheckedChange={() =>
|
||||
!isPaid && onToggleInstallment(installment.id)
|
||||
}
|
||||
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
isPaid &&
|
||||
"text-success line-through decoration-success/50",
|
||||
)}
|
||||
>
|
||||
Parcela {installment.currentInstallment}/
|
||||
{group.totalInstallments}
|
||||
{isPaid && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 text-xs border-none text-success"
|
||||
>
|
||||
<RiCheckboxCircleFill /> Pago
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs mt-1",
|
||||
isPaid ? "text-success" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
Vencimento: {dueDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MoneyValues
|
||||
amount={installment.amount}
|
||||
className={cn(
|
||||
"shrink-0 text-sm",
|
||||
isPaid && "text-success",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type {
|
||||
InstallmentAnalysisData,
|
||||
InstallmentGroup,
|
||||
} from "@/features/dashboard/expenses/installment-analysis-queries";
|
||||
|
||||
export type { InstallmentAnalysisData, InstallmentGroup };
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import { InstallmentExpensesWidgetView } from "./installment-expenses/installment-expenses-widget-view";
|
||||
|
||||
type InstallmentExpensesWidgetProps = {
|
||||
data: InstallmentExpensesData;
|
||||
};
|
||||
|
||||
export function InstallmentExpensesWidget({
|
||||
data,
|
||||
}: InstallmentExpensesWidgetProps) {
|
||||
return <InstallmentExpensesWidgetView data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Image from "next/image";
|
||||
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import { buildInstallmentExpenseDisplay } from "@/features/dashboard/installment-expenses-helpers";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
|
||||
type InstallmentExpenseListItemProps = {
|
||||
expense: InstallmentExpense;
|
||||
};
|
||||
|
||||
export function InstallmentExpenseListItem({
|
||||
expense,
|
||||
}: InstallmentExpenseListItemProps) {
|
||||
const {
|
||||
compactLabel,
|
||||
isLast,
|
||||
remainingInstallments,
|
||||
remainingAmount,
|
||||
endDate,
|
||||
progress,
|
||||
} = buildInstallmentExpenseDisplay(expense);
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{expense.name}
|
||||
</p>
|
||||
{compactLabel ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
{compactLabel}
|
||||
{isLast ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Image
|
||||
src="/icons/party.svg"
|
||||
alt="Última parcela"
|
||||
width={14}
|
||||
height={14}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="sr-only">Última parcela</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Última parcela!</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<MoneyValues amount={expense.amount} className="shrink-0" />
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{endDate ? `Termina em ${endDate}` : null}
|
||||
{" | Restante "}
|
||||
<MoneyValues
|
||||
amount={remainingAmount}
|
||||
className="inline-block font-medium"
|
||||
/>{" "}
|
||||
({remainingInstallments})
|
||||
</p>
|
||||
|
||||
<Progress value={progress} className="mt-1 h-2" />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { RiNumbersLine } from "@remixicon/react";
|
||||
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { InstallmentExpenseListItem } from "./installment-expense-list-item";
|
||||
|
||||
type InstallmentExpensesListProps = {
|
||||
expenses: InstallmentExpense[];
|
||||
};
|
||||
|
||||
export function InstallmentExpensesList({
|
||||
expenses,
|
||||
}: InstallmentExpensesListProps) {
|
||||
if (expenses.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiNumbersLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma despesa parcelada"
|
||||
description="Lançamentos parcelados aparecerão aqui conforme forem registrados."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{expenses.map((expense) => (
|
||||
<InstallmentExpenseListItem key={expense.id} expense={expense} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import { InstallmentExpensesList } from "./installment-expenses-list";
|
||||
|
||||
type InstallmentExpensesWidgetViewProps = {
|
||||
data: InstallmentExpensesData;
|
||||
};
|
||||
|
||||
export function InstallmentExpensesWidgetView({
|
||||
data,
|
||||
}: InstallmentExpensesWidgetViewProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<InstallmentExpensesList expenses={data.expenses} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/features/dashboard/components/invoices-widget.tsx
Normal file
35
src/features/dashboard/components/invoices-widget.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import { useInvoicesWidgetController } from "@/features/dashboard/use-invoices-widget-controller";
|
||||
import { InvoicesWidgetView } from "./invoices/invoices-widget-view";
|
||||
|
||||
type InvoicesWidgetProps = {
|
||||
invoices: DashboardInvoice[];
|
||||
};
|
||||
|
||||
export function InvoicesWidget({ invoices }: InvoicesWidgetProps) {
|
||||
const {
|
||||
items,
|
||||
selectedInvoice,
|
||||
isModalOpen,
|
||||
modalState,
|
||||
isPending,
|
||||
openPaymentDialog,
|
||||
closePaymentDialog,
|
||||
confirmPayment,
|
||||
} = useInvoicesWidgetController(invoices);
|
||||
|
||||
return (
|
||||
<InvoicesWidgetView
|
||||
invoices={items}
|
||||
selectedInvoice={selectedInvoice}
|
||||
isModalOpen={isModalOpen}
|
||||
modalState={modalState}
|
||||
isPending={isPending}
|
||||
onOpenPaymentDialog={openPaymentDialog}
|
||||
onClosePaymentDialog={closePaymentDialog}
|
||||
onConfirmPayment={confirmPayment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
152
src/features/dashboard/components/invoices/invoice-list-item.tsx
Normal file
152
src/features/dashboard/components/invoices/invoice-list-item.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
buildInvoiceDetailsHref,
|
||||
buildInvoiceInitials,
|
||||
formatInvoicePaymentDate,
|
||||
getInvoiceShareLabel,
|
||||
parseInvoiceDueDate,
|
||||
} from "@/features/dashboard/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/shared/components/ui/avatar";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/shared/components/ui/hover-card";
|
||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||
import { isDateOnlyPast } from "@/shared/utils/date";
|
||||
import { InvoiceLogo } from "./invoice-logo";
|
||||
|
||||
type InvoiceListItemProps = {
|
||||
invoice: DashboardInvoice;
|
||||
onPay: (invoiceId: string) => void;
|
||||
};
|
||||
|
||||
export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
||||
const dueInfo = parseInvoiceDueDate(invoice.period, invoice.dueDay);
|
||||
const isPaid = invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
|
||||
const isOverdue =
|
||||
!isPaid && dueInfo.date !== null && isDateOnlyPast(dueInfo.date);
|
||||
const paymentInfo = formatInvoicePaymentDate(invoice.paidAt);
|
||||
const breakdown = invoice.pagadorBreakdown ?? [];
|
||||
const hasBreakdown = breakdown.length > 0;
|
||||
const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period);
|
||||
|
||||
const linkNode = (
|
||||
<Link
|
||||
prefetch
|
||||
href={detailHref}
|
||||
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
|
||||
>
|
||||
<span className="truncate">{invoice.cardName}</span>
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
|
||||
<InvoiceLogo
|
||||
cardName={invoice.cardName}
|
||||
logo={invoice.logo}
|
||||
size={36}
|
||||
containerClassName="size-9.5"
|
||||
/>
|
||||
|
||||
<div className="min-w-0">
|
||||
{hasBreakdown ? (
|
||||
<HoverCard openDelay={150}>
|
||||
<HoverCardTrigger asChild>{linkNode}</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-72 space-y-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Distribuição por pagador
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{breakdown.map((share, index) => (
|
||||
<li
|
||||
key={`${invoice.id}-${
|
||||
share.pagadorId ?? share.pagadorName ?? index
|
||||
}`}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<Avatar className="size-9">
|
||||
<AvatarImage
|
||||
src={getAvatarSrc(share.pagadorAvatar)}
|
||||
alt={`Avatar de ${share.pagadorName}`}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{buildInvoiceInitials(share.pagadorName)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{share.pagadorName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getInvoiceShareLabel(
|
||||
share.amount,
|
||||
Math.abs(invoice.totalAmount),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
<MoneyValues amount={share.amount} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
linkNode
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{!isPaid ? <span>{dueInfo.label}</span> : null}
|
||||
{isPaid && paymentInfo ? (
|
||||
<span className="text-success">{paymentInfo.label}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={Math.abs(invoice.totalAmount)} />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="h-auto p-0 disabled:opacity-100"
|
||||
disabled={isPaid}
|
||||
onClick={() => onPay(invoice.id)}
|
||||
>
|
||||
{isPaid ? (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<RiCheckboxCircleFill className="size-3" /> Pago
|
||||
</span>
|
||||
) : isOverdue ? (
|
||||
<span className="overdue-blink">
|
||||
<span className="overdue-blink-primary text-destructive">
|
||||
Atrasado
|
||||
</span>
|
||||
<span className="overdue-blink-secondary">Pagar</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>Pagar</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
59
src/features/dashboard/components/invoices/invoice-logo.tsx
Normal file
59
src/features/dashboard/components/invoices/invoice-logo.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import Image from "next/image";
|
||||
import {
|
||||
buildInvoiceInitials,
|
||||
type InvoiceLogoTone,
|
||||
} from "@/features/dashboard/invoices-helpers";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
type InvoiceLogoProps = {
|
||||
cardName: string;
|
||||
logo: string | null;
|
||||
size: number;
|
||||
containerClassName?: string;
|
||||
imageClassName?: string;
|
||||
fallbackClassName?: string;
|
||||
tone?: InvoiceLogoTone;
|
||||
};
|
||||
|
||||
export function InvoiceLogo({
|
||||
cardName,
|
||||
logo,
|
||||
size,
|
||||
containerClassName,
|
||||
imageClassName,
|
||||
fallbackClassName,
|
||||
tone = "muted",
|
||||
}: InvoiceLogoProps) {
|
||||
const resolvedLogo = resolveLogoSrc(logo);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center overflow-hidden rounded-full",
|
||||
tone === "accent" && "bg-primary/10",
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
{resolvedLogo ? (
|
||||
<Image
|
||||
src={resolvedLogo}
|
||||
alt={`Logo do cartão ${cardName}`}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn("h-full w-full object-contain", imageClassName)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold uppercase text-muted-foreground",
|
||||
tone === "accent" && "text-primary",
|
||||
fallbackClassName,
|
||||
)}
|
||||
>
|
||||
{buildInvoiceInitials(cardName)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
RiCheckboxCircleLine,
|
||||
RiLoader4Line,
|
||||
RiMoneyDollarCircleLine,
|
||||
} from "@remixicon/react";
|
||||
import {
|
||||
formatInvoicePaymentDate,
|
||||
getInvoiceStatusBadgeVariant,
|
||||
type InvoiceDialogState,
|
||||
parseInvoiceDueDate,
|
||||
} from "@/features/dashboard/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import {
|
||||
INVOICE_PAYMENT_STATUS,
|
||||
INVOICE_STATUS_LABEL,
|
||||
} from "@/shared/lib/invoices";
|
||||
import { InvoiceLogo } from "./invoice-logo";
|
||||
|
||||
type InvoicePaymentDialogProps = {
|
||||
invoice: DashboardInvoice | null;
|
||||
open: boolean;
|
||||
modalState: InvoiceDialogState;
|
||||
isPending: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function InvoicePaymentDialog({
|
||||
invoice,
|
||||
open,
|
||||
modalState,
|
||||
isPending,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: InvoicePaymentDialogProps) {
|
||||
const isProcessing = modalState === "processing" || isPending;
|
||||
const paymentInfo = invoice ? formatInvoicePaymentDate(invoice.paidAt) : null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen || isProcessing) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[calc(100%-2rem)] sm:max-w-md"
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (isProcessing) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (isProcessing) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{modalState === "success" ? (
|
||||
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
|
||||
<RiCheckboxCircleLine className="size-8" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<DialogTitle className="text-base">
|
||||
Pagamento confirmado!
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
Atualizamos o status da fatura. O lançamento do pagamento
|
||||
aparecerá no extrato em instantes.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogFooter className="sm:justify-center">
|
||||
<Button type="button" onClick={onClose} className="sm:w-auto">
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar pagamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Revise os dados antes de confirmar. Vamos registrar a fatura
|
||||
como paga.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{invoice ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<InvoiceLogo
|
||||
cardName={invoice.cardName}
|
||||
logo={invoice.logo}
|
||||
size={40}
|
||||
tone="accent"
|
||||
containerClassName="size-10"
|
||||
fallbackClassName="text-xs"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Cartão
|
||||
</p>
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
{invoice.cardName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PAID ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{
|
||||
parseInvoiceDueDate(invoice.period, invoice.dueDay)
|
||||
.label
|
||||
}
|
||||
</p>
|
||||
) : null}
|
||||
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID &&
|
||||
paymentInfo ? (
|
||||
<p className="text-sm text-success">
|
||||
{paymentInfo.label}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RiMoneyDollarCircleLine className="size-4" />
|
||||
<span className="text-xs font-semibold uppercase">
|
||||
Valor da Fatura
|
||||
</span>
|
||||
</div>
|
||||
<MoneyValues
|
||||
amount={Math.abs(invoice.totalAmount)}
|
||||
className="text-lg font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RiCheckboxCircleLine className="size-4" />
|
||||
<span className="text-xs font-semibold uppercase">
|
||||
Status
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={getInvoiceStatusBadgeVariant(
|
||||
INVOICE_STATUS_LABEL[invoice.paymentStatus],
|
||||
)}
|
||||
>
|
||||
{INVOICE_STATUS_LABEL[invoice.paymentStatus]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isProcessing || !invoice}
|
||||
className="relative"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
|
||||
Processando...
|
||||
</>
|
||||
) : (
|
||||
"Confirmar pagamento"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
29
src/features/dashboard/components/invoices/invoices-list.tsx
Normal file
29
src/features/dashboard/components/invoices/invoices-list.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { RiBillLine } from "@remixicon/react";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { InvoiceListItem } from "./invoice-list-item";
|
||||
|
||||
type InvoicesListProps = {
|
||||
invoices: DashboardInvoice[];
|
||||
onPay: (invoiceId: string) => void;
|
||||
};
|
||||
|
||||
export function InvoicesList({ invoices, onPay }: InvoicesListProps) {
|
||||
if (invoices.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiBillLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma fatura para o período selecionado"
|
||||
description="Quando houver cartões com compras registradas, eles aparecerão aqui."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col">
|
||||
{invoices.map((invoice) => (
|
||||
<InvoiceListItem key={invoice.id} invoice={invoice} onPay={onPay} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { InvoiceDialogState } from "@/features/dashboard/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import { InvoicePaymentDialog } from "./invoice-payment-dialog";
|
||||
import { InvoicesList } from "./invoices-list";
|
||||
|
||||
type InvoicesWidgetViewProps = {
|
||||
invoices: DashboardInvoice[];
|
||||
selectedInvoice: DashboardInvoice | null;
|
||||
isModalOpen: boolean;
|
||||
modalState: InvoiceDialogState;
|
||||
isPending: boolean;
|
||||
onOpenPaymentDialog: (invoiceId: string) => void;
|
||||
onClosePaymentDialog: () => void;
|
||||
onConfirmPayment: () => void;
|
||||
};
|
||||
|
||||
export function InvoicesWidgetView({
|
||||
invoices,
|
||||
selectedInvoice,
|
||||
isModalOpen,
|
||||
modalState,
|
||||
isPending,
|
||||
onOpenPaymentDialog,
|
||||
onClosePaymentDialog,
|
||||
onConfirmPayment,
|
||||
}: InvoicesWidgetViewProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<InvoicesList invoices={invoices} onPay={onOpenPaymentDialog} />
|
||||
</div>
|
||||
|
||||
<InvoicePaymentDialog
|
||||
invoice={selectedInvoice}
|
||||
open={isModalOpen}
|
||||
modalState={modalState}
|
||||
isPending={isPending}
|
||||
onClose={onClosePaymentDialog}
|
||||
onConfirm={onConfirmPayment}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
src/features/dashboard/components/my-accounts-widget.tsx
Normal file
103
src/features/dashboard/components/my-accounts-widget.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { RiBarChartBoxLine, RiExternalLinkLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { DashboardAccount } from "@/features/dashboard/accounts-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { CardFooter } from "@/shared/components/ui/card";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { formatPeriodForUrl } from "@/shared/utils/period";
|
||||
|
||||
type MyAccountsWidgetProps = {
|
||||
accounts: DashboardAccount[];
|
||||
totalBalance: number;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export function MyAccountsWidget({
|
||||
accounts,
|
||||
totalBalance,
|
||||
period,
|
||||
}: MyAccountsWidgetProps) {
|
||||
const visibleAccounts = accounts.filter(
|
||||
(account) => !account.excludeFromBalance,
|
||||
);
|
||||
const displayedAccounts = visibleAccounts.slice(0, 5);
|
||||
const remainingCount = visibleAccounts.length - displayedAccounts.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between py-2">
|
||||
Saldo Total
|
||||
<MoneyValues className="text-2xl" amount={totalBalance} />
|
||||
</div>
|
||||
|
||||
<div className="py-2 px-0">
|
||||
{displayedAccounts.length === 0 ? (
|
||||
<div className="-mt-10">
|
||||
<WidgetEmptyState
|
||||
icon={
|
||||
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
|
||||
}
|
||||
title="Você ainda não adicionou nenhuma conta"
|
||||
description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{displayedAccounts.map((account) => {
|
||||
const logoSrc = resolveLogoSrc(account.logo);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={account.id}
|
||||
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="relative size-10 overflow-hidden">
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={`Logo da conta ${account.name}`}
|
||||
fill
|
||||
className="object-contain rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
prefetch
|
||||
href={`/accounts/${
|
||||
account.id
|
||||
}/statement?periodo=${formatPeriodForUrl(period)}`}
|
||||
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
|
||||
>
|
||||
<span className="truncate">{account.name}</span>
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="truncate">{account.accountType}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-0.5 text-right">
|
||||
<MoneyValues amount={account.balance} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visibleAccounts.length > displayedAccounts.length ? (
|
||||
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground">
|
||||
+{remainingCount} contas não exibidas
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
37
src/features/dashboard/components/notes-widget.tsx
Normal file
37
src/features/dashboard/components/notes-widget.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import type { DashboardNote } from "@/features/dashboard/notes-queries";
|
||||
import { useNotesWidgetController } from "@/features/dashboard/use-notes-widget-controller";
|
||||
import { NotesWidgetView } from "./notes/notes-widget-view";
|
||||
|
||||
type NotesWidgetProps = {
|
||||
notes: DashboardNote[];
|
||||
};
|
||||
|
||||
export function NotesWidget({ notes }: NotesWidgetProps) {
|
||||
const {
|
||||
mappedNotes,
|
||||
noteToEdit,
|
||||
isEditOpen,
|
||||
noteDetails,
|
||||
isDetailsOpen,
|
||||
openEdit,
|
||||
openDetails,
|
||||
handleEditOpenChange,
|
||||
handleDetailsOpenChange,
|
||||
} = useNotesWidgetController(notes);
|
||||
|
||||
return (
|
||||
<NotesWidgetView
|
||||
notes={mappedNotes}
|
||||
noteToEdit={noteToEdit}
|
||||
isEditOpen={isEditOpen}
|
||||
noteDetails={noteDetails}
|
||||
isDetailsOpen={isDetailsOpen}
|
||||
onOpenEdit={openEdit}
|
||||
onOpenDetails={openDetails}
|
||||
onEditOpenChange={handleEditOpenChange}
|
||||
onDetailsOpenChange={handleDetailsOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
65
src/features/dashboard/components/notes/note-list-item.tsx
Normal file
65
src/features/dashboard/components/notes/note-list-item.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { RiFileList2Line, RiPencilLine } from "@remixicon/react";
|
||||
import type { Note } from "@/features/notes/components/types";
|
||||
import {
|
||||
buildNoteDisplayTitle,
|
||||
formatNoteCreatedAt,
|
||||
getNoteTasksSummary,
|
||||
} from "@/features/notes/lib/formatters";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
type NoteListItemProps = {
|
||||
note: Note;
|
||||
onOpenEdit: (note: Note) => void;
|
||||
onOpenDetails: (note: Note) => void;
|
||||
};
|
||||
|
||||
export function NoteListItem({
|
||||
note,
|
||||
onOpenEdit,
|
||||
onOpenDetails,
|
||||
}: NoteListItemProps) {
|
||||
const displayTitle = buildNoteDisplayTitle(note.title);
|
||||
const createdAtLabel = formatNoteCreatedAt(note.createdAt);
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-b-0 last:pb-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{displayTitle}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||
{getNoteTasksSummary(note)}
|
||||
</Badge>
|
||||
{createdAtLabel ? (
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{createdAtLabel}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onOpenEdit(note)}
|
||||
aria-label={`Editar anotação ${displayTitle}`}
|
||||
>
|
||||
<RiPencilLine className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onOpenDetails(note)}
|
||||
aria-label={`Ver detalhes da anotação ${displayTitle}`}
|
||||
>
|
||||
<RiFileList2Line className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
39
src/features/dashboard/components/notes/notes-list.tsx
Normal file
39
src/features/dashboard/components/notes/notes-list.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { RiTodoLine } from "@remixicon/react";
|
||||
import type { Note } from "@/features/notes/components/types";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { NoteListItem } from "./note-list-item";
|
||||
|
||||
type NotesListProps = {
|
||||
notes: Note[];
|
||||
onOpenEdit: (note: Note) => void;
|
||||
onOpenDetails: (note: Note) => void;
|
||||
};
|
||||
|
||||
export function NotesList({
|
||||
notes,
|
||||
onOpenEdit,
|
||||
onOpenDetails,
|
||||
}: NotesListProps) {
|
||||
if (notes.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiTodoLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma anotação ativa"
|
||||
description="Crie anotações para acompanhar lembretes e tarefas financeiras."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col">
|
||||
{notes.map((note) => (
|
||||
<NoteListItem
|
||||
key={note.id}
|
||||
note={note}
|
||||
onOpenEdit={onOpenEdit}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NoteDetailsDialog } from "@/features/notes/components/note-details-dialog";
|
||||
import { NoteDialog } from "@/features/notes/components/note-dialog";
|
||||
import type { Note } from "@/features/notes/components/types";
|
||||
|
||||
type NotesWidgetDialogsProps = {
|
||||
noteToEdit: Note | null;
|
||||
isEditOpen: boolean;
|
||||
noteDetails: Note | null;
|
||||
isDetailsOpen: boolean;
|
||||
onEditOpenChange: (open: boolean) => void;
|
||||
onDetailsOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function NotesWidgetDialogs({
|
||||
noteToEdit,
|
||||
isEditOpen,
|
||||
noteDetails,
|
||||
isDetailsOpen,
|
||||
onEditOpenChange,
|
||||
onDetailsOpenChange,
|
||||
}: NotesWidgetDialogsProps) {
|
||||
return (
|
||||
<>
|
||||
<NoteDialog
|
||||
mode="update"
|
||||
note={noteToEdit ?? undefined}
|
||||
open={isEditOpen}
|
||||
onOpenChange={onEditOpenChange}
|
||||
/>
|
||||
|
||||
<NoteDetailsDialog
|
||||
note={noteDetails}
|
||||
open={isDetailsOpen}
|
||||
onOpenChange={onDetailsOpenChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Note } from "@/features/notes/components/types";
|
||||
import { NotesList } from "./notes-list";
|
||||
import { NotesWidgetDialogs } from "./notes-widget-dialogs";
|
||||
|
||||
type NotesWidgetViewProps = {
|
||||
notes: Note[];
|
||||
noteToEdit: Note | null;
|
||||
isEditOpen: boolean;
|
||||
noteDetails: Note | null;
|
||||
isDetailsOpen: boolean;
|
||||
onOpenEdit: (note: Note) => void;
|
||||
onOpenDetails: (note: Note) => void;
|
||||
onEditOpenChange: (open: boolean) => void;
|
||||
onDetailsOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function NotesWidgetView({
|
||||
notes,
|
||||
noteToEdit,
|
||||
isEditOpen,
|
||||
noteDetails,
|
||||
isDetailsOpen,
|
||||
onOpenEdit,
|
||||
onOpenDetails,
|
||||
onEditOpenChange,
|
||||
onDetailsOpenChange,
|
||||
}: NotesWidgetViewProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<NotesList
|
||||
notes={notes}
|
||||
onOpenEdit={onOpenEdit}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NotesWidgetDialogs
|
||||
noteToEdit={noteToEdit}
|
||||
isEditOpen={isEditOpen}
|
||||
noteDetails={noteDetails}
|
||||
isDetailsOpen={isDetailsOpen}
|
||||
onEditOpenChange={onEditOpenChange}
|
||||
onDetailsOpenChange={onDetailsOpenChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
src/features/dashboard/components/payers-widget.tsx
Normal file
130
src/features/dashboard/components/payers-widget.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiArrowDownSFill,
|
||||
RiArrowUpSFill,
|
||||
RiExternalLinkLine,
|
||||
RiGroupLine,
|
||||
RiVerifiedBadgeFill,
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import type { DashboardPagador } from "@/features/dashboard/payers-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/shared/components/ui/avatar";
|
||||
import { CardContent } from "@/shared/components/ui/card";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
|
||||
type PayersWidgetProps = {
|
||||
pagadores: DashboardPagador[];
|
||||
};
|
||||
|
||||
const buildInitials = (value: string) => {
|
||||
const parts = value.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return "??";
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
const firstPart = parts[0];
|
||||
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "??";
|
||||
}
|
||||
const firstChar = parts[0]?.[0] ?? "";
|
||||
const secondChar = parts[1]?.[0] ?? "";
|
||||
return `${firstChar}${secondChar}`.toUpperCase() || "??";
|
||||
};
|
||||
|
||||
export function PayersWidget({ pagadores }: PayersWidgetProps) {
|
||||
return (
|
||||
<CardContent className="flex flex-col gap-4 px-0">
|
||||
{pagadores.length === 0 ? (
|
||||
<WidgetEmptyState
|
||||
icon={<RiGroupLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum pagador para o período"
|
||||
description="Quando houver despesas associadas a pagadores, eles aparecerão aqui."
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{pagadores.map((pagador) => {
|
||||
const initials = buildInitials(pagador.name);
|
||||
const hasValidPercentageChange =
|
||||
typeof pagador.percentageChange === "number" &&
|
||||
Number.isFinite(pagador.percentageChange);
|
||||
const percentageChange = hasValidPercentageChange
|
||||
? pagador.percentageChange
|
||||
: null;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={pagador.id}
|
||||
className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage
|
||||
src={getAvatarSrc(pagador.avatarUrl)}
|
||||
alt={`Avatar de ${pagador.name}`}
|
||||
/>
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
prefetch
|
||||
href={`/payers/${pagador.id}`}
|
||||
className="inline-flex max-w-full items-center gap-1 text-sm text-foreground underline-offset-2 hover:text-primary hover:underline"
|
||||
>
|
||||
<span className="truncate font-medium">
|
||||
{pagador.name}
|
||||
</span>
|
||||
{pagador.isAdmin && (
|
||||
<RiVerifiedBadgeFill
|
||||
className="size-4 shrink-0 text-blue-500"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{pagador.email ?? "Sem email cadastrado"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={pagador.totalExpenses} />
|
||||
{percentageChange !== null && (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs ${
|
||||
percentageChange > 0
|
||||
? "text-destructive"
|
||||
: percentageChange < 0
|
||||
? "text-success"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{percentageChange > 0 && (
|
||||
<RiArrowUpSFill className="size-3" />
|
||||
)}
|
||||
{percentageChange < 0 && (
|
||||
<RiArrowDownSFill className="size-3" />
|
||||
)}
|
||||
{formatPercentage(percentageChange)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||
import { usePaymentOverviewWidgetController } from "@/features/dashboard/use-payment-overview-widget-controller";
|
||||
import { PaymentOverviewWidgetView } from "./payment-overview/payment-overview-widget-view";
|
||||
|
||||
type PaymentOverviewWidgetProps = {
|
||||
paymentConditionsData: PaymentConditionsData;
|
||||
paymentMethodsData: PaymentMethodsData;
|
||||
};
|
||||
|
||||
export function PaymentOverviewWidget({
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
}: PaymentOverviewWidgetProps) {
|
||||
const { activeTab, handleTabChange } = usePaymentOverviewWidgetController();
|
||||
|
||||
return (
|
||||
<PaymentOverviewWidgetView
|
||||
activeTab={activeTab}
|
||||
paymentConditionsData={paymentConditionsData}
|
||||
paymentMethodsData={paymentMethodsData}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
formatPaymentBreakdownPercentage,
|
||||
formatPaymentBreakdownTransactionsLabel,
|
||||
} from "@/features/dashboard/payment-breakdown-formatters";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
|
||||
const ICON_WRAPPER_CLASS =
|
||||
"flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground";
|
||||
|
||||
export type PaymentBreakdownListItemData = {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
amount: number;
|
||||
transactions: number;
|
||||
percentage: number;
|
||||
};
|
||||
|
||||
type PaymentBreakdownListItemProps = {
|
||||
item: PaymentBreakdownListItemData;
|
||||
};
|
||||
|
||||
export function PaymentBreakdownListItem({
|
||||
item,
|
||||
}: PaymentBreakdownListItemProps) {
|
||||
return (
|
||||
<li className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0">
|
||||
<div className={ICON_WRAPPER_CLASS}>{item.icon}</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
||||
<MoneyValues amount={item.amount} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatPaymentBreakdownTransactionsLabel(item.transactions)}
|
||||
</span>
|
||||
<span>{formatPaymentBreakdownPercentage(item.percentage)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<Progress value={item.percentage} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import {
|
||||
PaymentBreakdownListItem,
|
||||
type PaymentBreakdownListItemData,
|
||||
} from "./payment-breakdown-list-item";
|
||||
|
||||
export type { PaymentBreakdownListItemData } from "./payment-breakdown-list-item";
|
||||
|
||||
type PaymentBreakdownListProps = {
|
||||
items: PaymentBreakdownListItemData[];
|
||||
emptyIcon: ReactNode;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
};
|
||||
|
||||
export function PaymentBreakdownList({
|
||||
items,
|
||||
emptyIcon,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
}: PaymentBreakdownListProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={emptyIcon}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
<PaymentBreakdownListItem key={item.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { RiCheckLine, RiSlideshowLine } from "@remixicon/react";
|
||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||
import { getConditionIcon } from "@/shared/utils/icons";
|
||||
import {
|
||||
PaymentBreakdownList,
|
||||
type PaymentBreakdownListItemData,
|
||||
} from "./payment-breakdown-list";
|
||||
|
||||
type PaymentConditionsWidgetProps = {
|
||||
data: PaymentConditionsData;
|
||||
};
|
||||
|
||||
const resolveConditionIcon = (condition: string) =>
|
||||
getConditionIcon(condition) ?? <RiCheckLine className="size-5" aria-hidden />;
|
||||
|
||||
export function PaymentConditionsWidget({
|
||||
data,
|
||||
}: PaymentConditionsWidgetProps) {
|
||||
const items: PaymentBreakdownListItemData[] = data.conditions.map(
|
||||
(condition) => ({
|
||||
id: condition.condition,
|
||||
title: condition.condition,
|
||||
icon: resolveConditionIcon(condition.condition),
|
||||
amount: condition.amount,
|
||||
transactions: condition.transactions,
|
||||
percentage: condition.percentage,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<PaymentBreakdownList
|
||||
items={items}
|
||||
emptyIcon={<RiSlideshowLine className="size-6 text-muted-foreground" />}
|
||||
emptyTitle="Nenhuma despesa encontrada"
|
||||
emptyDescription="As distribuições por condição aparecerão conforme novos lançamentos."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
|
||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||
import { getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||
import {
|
||||
PaymentBreakdownList,
|
||||
type PaymentBreakdownListItemData,
|
||||
} from "./payment-breakdown-list";
|
||||
|
||||
type PaymentMethodsWidgetProps = {
|
||||
data: PaymentMethodsData;
|
||||
};
|
||||
|
||||
const resolvePaymentMethodIcon = (paymentMethod: string) =>
|
||||
getPaymentMethodIcon(paymentMethod) ?? (
|
||||
<RiBankCard2Line className="size-5" aria-hidden />
|
||||
);
|
||||
|
||||
export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) {
|
||||
const items: PaymentBreakdownListItemData[] = data.methods.map((method) => ({
|
||||
id: method.paymentMethod,
|
||||
title: method.paymentMethod,
|
||||
icon: resolvePaymentMethodIcon(method.paymentMethod),
|
||||
amount: method.amount,
|
||||
transactions: method.transactions,
|
||||
percentage: method.percentage,
|
||||
}));
|
||||
|
||||
return (
|
||||
<PaymentBreakdownList
|
||||
items={items}
|
||||
emptyIcon={
|
||||
<RiMoneyDollarCircleLine className="size-6 text-muted-foreground" />
|
||||
}
|
||||
emptyTitle="Nenhuma despesa encontrada"
|
||||
emptyDescription="Cadastre despesas para visualizar a distribuição por forma de pagamento."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
|
||||
import type { PaymentOverviewTab } from "@/features/dashboard/payment-overview-tabs";
|
||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/shared/components/ui/tabs";
|
||||
import { PaymentConditionsWidget } from "./payment-conditions-widget";
|
||||
import { PaymentMethodsWidget } from "./payment-methods-widget";
|
||||
|
||||
type PaymentOverviewWidgetViewProps = {
|
||||
activeTab: PaymentOverviewTab;
|
||||
paymentConditionsData: PaymentConditionsData;
|
||||
paymentMethodsData: PaymentMethodsData;
|
||||
onTabChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function PaymentOverviewWidgetView({
|
||||
activeTab,
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
onTabChange,
|
||||
}: PaymentOverviewWidgetViewProps) {
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="conditions" className="text-xs">
|
||||
<RiSlideshowLine className="mr-1 size-3.5" />
|
||||
Condições
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="methods" className="text-xs">
|
||||
<RiMoneyDollarCircleLine className="mr-1 size-3.5" />
|
||||
Formas
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="conditions" className="mt-2">
|
||||
<PaymentConditionsWidget data={paymentConditionsData} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="methods" className="mt-2">
|
||||
<PaymentMethodsWidget data={paymentMethodsData} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
12
src/features/dashboard/components/payment-status-widget.tsx
Normal file
12
src/features/dashboard/components/payment-status-widget.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
|
||||
import { PaymentStatusWidgetView } from "./payment-status/payment-status-widget-view";
|
||||
|
||||
type PaymentStatusWidgetProps = {
|
||||
data: PaymentStatusData;
|
||||
};
|
||||
|
||||
export function PaymentStatusWidget({ data }: PaymentStatusWidgetProps) {
|
||||
return <PaymentStatusWidgetView data={data} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user