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