refactor(core): move app para src e padroniza estrutura

This commit is contained in:
Felipe Coutinho
2026-03-12 19:22:50 +00:00
parent d92e70f1b9
commit b0fbb1062a
567 changed files with 8981 additions and 5014 deletions

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

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

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

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

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

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

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

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

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

View 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,
};
}

View 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),
});
}

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

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

View 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;

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

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

View 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">
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>
);
}

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

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

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

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

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

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

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

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

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

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

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

View 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,
},
};
};

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

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

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

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

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

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

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

View 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,
};
}

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

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

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

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

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

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

View 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 />;
}

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

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

View 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,
}));
}

View 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,
};
}

View 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,
});

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View File

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

View File

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

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

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
import type {
InstallmentAnalysisData,
InstallmentGroup,
} from "@/features/dashboard/expenses/installment-analysis-queries";
export type { InstallmentAnalysisData, InstallmentGroup };

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

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

View File

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

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

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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