refactor: atualiza transacoes dashboard e relatorios

This commit is contained in:
Felipe Coutinho
2026-03-14 12:51:22 +00:00
parent 43b0f0c47e
commit 6854017a8c
89 changed files with 2785 additions and 2705 deletions

View File

@@ -1,8 +1,8 @@
import { and, eq, sql } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { financialAccounts, payers, transactions } 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 { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { safeToNumber as toNumber } from "@/shared/utils/number";
type RawDashboardAccount = {
@@ -36,49 +36,49 @@ export async function fetchDashboardAccounts(
): 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,
id: financialAccounts.id,
name: financialAccounts.name,
accountType: financialAccounts.accountType,
status: financialAccounts.status,
logo: financialAccounts.logo,
initialBalance: financialAccounts.initialBalance,
excludeFromBalance: financialAccounts.excludeFromBalance,
balanceMovements: sql<number>`
coalesce(
sum(
case
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
else ${lancamentos.amount}
when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0
else ${transactions.amount}
end
),
0
)
`,
})
.from(contas)
.from(financialAccounts)
.leftJoin(
lancamentos,
transactions,
and(
eq(lancamentos.contaId, contas.id),
eq(lancamentos.userId, userId),
eq(lancamentos.isSettled, true),
eq(transactions.accountId, financialAccounts.id),
eq(transactions.userId, userId),
eq(transactions.isSettled, true),
),
)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(contas.userId, userId),
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
eq(financialAccounts.userId, userId),
sql`(${transactions.id} IS NULL OR ${payers.role} = ${PAYER_ROLE_ADMIN})`,
),
)
.groupBy(
contas.id,
contas.name,
contas.accountType,
contas.status,
contas.logo,
contas.initialBalance,
contas.excludeFromBalance,
financialAccounts.id,
financialAccounts.name,
financialAccounts.accountType,
financialAccounts.status,
financialAccounts.logo,
financialAccounts.initialBalance,
financialAccounts.excludeFromBalance,
);
const accounts = rows

View File

@@ -1,9 +1,9 @@
"use server";
import { and, asc, eq } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import { transactions } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { toDateOnlyString } from "@/shared/utils/date";
import { safeToNumber as toNumber } from "@/shared/utils/number";
@@ -37,33 +37,33 @@ export async function fetchDashboardBills(
userId: string,
period: string,
): Promise<DashboardBillsSnapshot> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
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,
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
dueDate: transactions.dueDate,
boletoPaymentDate: transactions.boletoPaymentDate,
isSettled: transactions.isSettled,
})
.from(lancamentos)
.from(transactions)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(lancamentos.pagadorId, adminPagadorId),
eq(transactions.userId, userId),
eq(transactions.period, period),
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(transactions.payerId, adminPayerId),
),
)
.orderBy(
asc(lancamentos.isSettled),
asc(lancamentos.dueDate),
asc(lancamentos.name),
asc(transactions.isSettled),
asc(transactions.dueDate),
asc(transactions.name),
);
const bills = rows.map((row: RawDashboardBill): DashboardBill => {

View File

@@ -28,7 +28,7 @@ type CategoryBreakdownRow = {
};
type CategoryBudgetRow = {
categoriaId: string | null;
categoryId: string | null;
amount: unknown;
};
@@ -43,8 +43,8 @@ export function buildCategoryBreakdownData({
}): DashboardCategoryBreakdownData {
const budgetMap = new Map<string, number>();
for (const row of budgetRows) {
if (row.categoriaId) {
budgetMap.set(row.categoriaId, toNumber(row.amount));
if (row.categoryId) {
budgetMap.set(row.categoryId, toNumber(row.amount));
}
}

View File

@@ -1,18 +1,23 @@
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 {
categories,
financialAccounts,
payers,
transactions,
} from "@/db/schema";
import { mapTransactionsData } 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 { PAYER_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>;
type MappedLancamentos = ReturnType<typeof mapTransactionsData>;
export type CategoryDetailData = {
category: {
@@ -34,8 +39,8 @@ export async function fetchCategoryDetails(
categoryId: string,
period: string,
): Promise<CategoryDetailData | null> {
const category = await db.query.categorias.findFirst({
where: and(eq(categorias.userId, userId), eq(categorias.id, categoryId)),
const category = await db.query.categories.findFirst({
where: and(eq(categories.userId, userId), eq(categories.id, categoryId)),
});
if (!category) {
@@ -46,35 +51,35 @@ export async function fetchCategoryDetails(
const transactionType = category.type === "receita" ? "Receita" : "Despesa";
const sanitizedNote = or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
isNull(transactions.note),
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
);
const currentRows = await db.query.lancamentos.findMany({
const currentRows = await db.query.transactions.findMany({
where: and(
eq(lancamentos.userId, userId),
eq(lancamentos.categoriaId, categoryId),
eq(lancamentos.transactionType, transactionType),
eq(lancamentos.period, period),
eq(transactions.userId, userId),
eq(transactions.categoryId, categoryId),
eq(transactions.transactionType, transactionType),
eq(transactions.period, period),
sanitizedNote,
),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
payer: true,
financialAccount: true,
card: true,
category: true,
},
orderBy: [desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)],
orderBy: [desc(transactions.purchaseDate), desc(transactions.createdAt)],
});
const filteredRows = currentRows.filter((row) => {
// Filtrar apenas pagadores admin
if (row.pagador?.role !== PAGADOR_ROLE_ADMIN) return false;
// Filtrar apenas payers admin
if (row.payer?.role !== PAYER_ROLE_ADMIN) return false;
// Excluir saldos iniciais se a conta tiver o flag ativo
if (
row.note === INITIAL_BALANCE_NOTE &&
row.conta?.excludeInitialBalanceFromIncome
row.financialAccount?.excludeInitialBalanceFromIncome
) {
return false;
}
@@ -82,33 +87,36 @@ export async function fetchCategoryDetails(
return true;
});
const transactions = mapLancamentosData(filteredRows);
const transactionList = mapTransactionsData(filteredRows);
const currentTotal = transactions.reduce(
const currentTotal = transactionList.reduce(
(total, transaction) => total + Math.abs(toNumber(transaction.amount)),
0,
);
const [previousTotalRow] = await db
.select({
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.categoriaId, categoryId),
eq(lancamentos.transactionType, transactionType),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(transactions.userId, userId),
eq(transactions.categoryId, categoryId),
eq(transactions.transactionType, transactionType),
eq(payers.role, PAYER_ROLE_ADMIN),
sanitizedNote,
eq(lancamentos.period, previousPeriod),
eq(transactions.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),
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
),
),
);
@@ -131,6 +139,6 @@ export async function fetchCategoryDetails(
currentTotal,
previousTotal,
percentageChange,
transactions,
transactions: transactionList,
};
}

View File

@@ -1,8 +1,8 @@
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { categories, payers, transactions } 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 { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { CATEGORY_COLORS } from "@/shared/utils/category-colors";
import { safeToNumber as toNumber } from "@/shared/utils/number";
import {
@@ -56,14 +56,14 @@ export async function fetchAllCategories(
): Promise<CategoryOption[]> {
const result = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
type: categorias.type,
id: categories.id,
name: categories.name,
icon: categories.icon,
type: categories.type,
})
.from(categorias)
.where(eq(categorias.userId, userId))
.orderBy(categorias.type, categorias.name);
.from(categories)
.where(eq(categories.userId, userId))
.orderBy(categories.type, categories.name);
return result as CategoryOption[];
}
@@ -88,36 +88,36 @@ export async function fetchCategoryHistory(
// 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(
categoryId: categories.id,
categoryName: categories.name,
categoryIcon: categories.icon,
period: transactions.period,
totalAmount: sql<string>`SUM(ABS(${transactions.amount}))`.as(
"total_amount",
),
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(categorias.userId, userId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(transactions.userId, userId),
eq(categories.userId, userId),
inArray(transactions.period, periods),
eq(payers.role, PAYER_ROLE_ADMIN),
or(
isNull(lancamentos.note),
isNull(transactions.note),
sql`${
lancamentos.note
transactions.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
lancamentos.period,
categories.id,
categories.name,
categories.icon,
transactions.period,
)) as MonthlyCategoryRow[];
if (monthlyDataQuery.length === 0) {

View File

@@ -1,5 +1,5 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos } from "@/db/schema";
import { budgets, categories, transactions } from "@/db/schema";
import {
buildCategoryBreakdownData,
type DashboardCategoryBreakdownData,
@@ -8,9 +8,9 @@ import {
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
} from "@/features/dashboard/lancamento-filters";
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { getPreviousPeriod } from "@/shared/utils/period";
export type CategoryExpenseItem = DashboardCategoryBreakdownItem;
@@ -22,45 +22,45 @@ export async function fetchExpensesByCategory(
): Promise<ExpensesByCategoryData> {
const previousPeriod = getPreviousPeriod(period);
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { categories: [], currentTotal: 0, previousTotal: 0 };
}
// Single query: GROUP BY categoriaId + period for both current and previous periods
// Single query: GROUP BY categoryId + 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)`,
categoryId: categories.id,
categoryName: categories.name,
categoryIcon: categories.icon,
period: transactions.period,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.where(
and(
...buildDashboardAdminFilters({ userId, adminPagadorId }),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Despesa"),
eq(categorias.type, "despesa"),
...buildDashboardAdminFilters({ userId, adminPayerId }),
inArray(transactions.period, [period, previousPeriod]),
eq(transactions.transactionType, "Despesa"),
eq(categories.type, "despesa"),
excludeAutoInvoiceEntries(),
),
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
lancamentos.period,
categories.id,
categories.name,
categories.icon,
transactions.period,
),
db
.select({
categoriaId: orcamentos.categoriaId,
amount: orcamentos.amount,
categoryId: budgets.categoryId,
amount: budgets.amount,
})
.from(orcamentos)
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
.from(budgets)
.where(and(eq(budgets.userId, userId), eq(budgets.period, period))),
]);
return buildCategoryBreakdownData({

View File

@@ -1,5 +1,10 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { categorias, contas, lancamentos, orcamentos } from "@/db/schema";
import {
budgets,
categories,
financialAccounts,
transactions,
} from "@/db/schema";
import {
buildCategoryBreakdownData,
type DashboardCategoryBreakdownData,
@@ -9,9 +14,9 @@ import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
} from "@/features/dashboard/lancamento-filters";
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { getPreviousPeriod } from "@/shared/utils/period";
export type CategoryIncomeItem = DashboardCategoryBreakdownItem;
@@ -23,47 +28,50 @@ export async function fetchIncomeByCategory(
): Promise<IncomeByCategoryData> {
const previousPeriod = getPreviousPeriod(period);
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { categories: [], currentTotal: 0, previousTotal: 0 };
}
// Single query: GROUP BY categoriaId + period for both current and previous periods
// Single query: GROUP BY categoryId + 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)`,
categoryId: categories.id,
categoryName: categories.name,
categoryIcon: categories.icon,
period: transactions.period,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildDashboardAdminFilters({ userId, adminPagadorId }),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Receita"),
eq(categorias.type, "receita"),
...buildDashboardAdminFilters({ userId, adminPayerId }),
inArray(transactions.period, [period, previousPeriod]),
eq(transactions.transactionType, "Receita"),
eq(categories.type, "receita"),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
),
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
lancamentos.period,
categories.id,
categories.name,
categories.icon,
transactions.period,
),
db
.select({
categoriaId: orcamentos.categoriaId,
amount: orcamentos.amount,
categoryId: budgets.categoryId,
amount: budgets.amount,
})
.from(orcamentos)
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
.from(budgets)
.where(and(eq(budgets.userId, userId), eq(budgets.period, period))),
]);
return buildCategoryBreakdownData({

View File

@@ -38,7 +38,7 @@ import {
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 { TransactionDialog } 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";
@@ -48,12 +48,12 @@ type DashboardGridEditableProps = {
period: string;
initialPreferences: WidgetPreferences | null;
quickActionOptions: {
pagadorOptions: SelectOption[];
splitPagadorOptions: SelectOption[];
defaultPagadorId: string | null;
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
payerOptions: SelectOption[];
splitPayerOptions: SelectOption[];
defaultPayerId: string | null;
accountOptions: SelectOption[];
cardOptions: SelectOption[];
categoryOptions: SelectOption[];
estabelecimentos: string[];
};
};
@@ -203,14 +203,14 @@ export function DashboardGridEditable({
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
<TransactionDialog
mode="create"
pagadorOptions={quickActionOptions.pagadorOptions}
splitPagadorOptions={quickActionOptions.splitPagadorOptions}
defaultPagadorId={quickActionOptions.defaultPagadorId}
contaOptions={quickActionOptions.contaOptions}
cartaoOptions={quickActionOptions.cartaoOptions}
categoriaOptions={quickActionOptions.categoriaOptions}
payerOptions={quickActionOptions.payerOptions}
splitPayerOptions={quickActionOptions.splitPayerOptions}
defaultPayerId={quickActionOptions.defaultPayerId}
accountOptions={quickActionOptions.accountOptions}
cardOptions={quickActionOptions.cardOptions}
categoryOptions={quickActionOptions.categoryOptions}
estabelecimentos={quickActionOptions.estabelecimentos}
defaultPeriod={period}
defaultTransactionType="Receita"
@@ -228,14 +228,14 @@ export function DashboardGridEditable({
</Button>
}
/>
<LancamentoDialog
<TransactionDialog
mode="create"
pagadorOptions={quickActionOptions.pagadorOptions}
splitPagadorOptions={quickActionOptions.splitPagadorOptions}
defaultPagadorId={quickActionOptions.defaultPagadorId}
contaOptions={quickActionOptions.contaOptions}
cartaoOptions={quickActionOptions.cartaoOptions}
categoriaOptions={quickActionOptions.categoriaOptions}
payerOptions={quickActionOptions.payerOptions}
splitPayerOptions={quickActionOptions.splitPayerOptions}
defaultPayerId={quickActionOptions.defaultPayerId}
accountOptions={quickActionOptions.accountOptions}
cardOptions={quickActionOptions.cardOptions}
categoryOptions={quickActionOptions.categoryOptions}
estabelecimentos={quickActionOptions.estabelecimentos}
defaultPeriod={period}
defaultTransactionType="Despesa"

View File

@@ -76,7 +76,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
{breakdown.map((share, index) => (
<li
key={`${invoice.id}-${
share.pagadorId ?? share.pagadorName ?? index
share.payerId ?? share.pagadorName ?? index
}`}
className="flex items-center gap-3"
>

View File

@@ -146,7 +146,7 @@ export function InvoicePaymentDialog({
<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
Valor da Invoice
</span>
</div>
<MoneyValues

View File

@@ -55,12 +55,14 @@ export function MyAccountsWidget({
>
<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"
/>
{logoSrc ? (
<Image
src={logoSrc}
alt={`Logo da conta ${account.name}`}
fill
className="object-contain rounded-full"
/>
) : null}
</div>
<div className="min-w-0">

View File

@@ -21,7 +21,7 @@ import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { formatPercentage } from "@/shared/utils/percentage";
type PayersWidgetProps = {
pagadores: DashboardPagador[];
payers: DashboardPagador[];
};
const buildInitials = (value: string) => {
@@ -38,10 +38,10 @@ const buildInitials = (value: string) => {
return `${firstChar}${secondChar}`.toUpperCase() || "??";
};
export function PayersWidget({ pagadores }: PayersWidgetProps) {
export function PayersWidget({ payers }: PayersWidgetProps) {
return (
<CardContent className="flex flex-col gap-4 px-0">
{pagadores.length === 0 ? (
{payers.length === 0 ? (
<WidgetEmptyState
icon={<RiGroupLine className="size-6 text-muted-foreground" />}
title="Nenhum pagador para o período"
@@ -49,25 +49,25 @@ export function PayersWidget({ pagadores }: PayersWidgetProps) {
/>
) : (
<ul className="flex flex-col">
{pagadores.map((pagador) => {
const initials = buildInitials(pagador.name);
{payers.map((payer) => {
const initials = buildInitials(payer.name);
const hasValidPercentageChange =
typeof pagador.percentageChange === "number" &&
Number.isFinite(pagador.percentageChange);
typeof payer.percentageChange === "number" &&
Number.isFinite(payer.percentageChange);
const percentageChange = hasValidPercentageChange
? pagador.percentageChange
? payer.percentageChange
: null;
return (
<li
key={pagador.id}
key={payer.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}`}
src={getAvatarSrc(payer.avatarUrl)}
alt={`Avatar de ${payer.name}`}
/>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
@@ -75,13 +75,11 @@ export function PayersWidget({ pagadores }: PayersWidgetProps) {
<div className="min-w-0">
<Link
prefetch
href={`/payers/${pagador.id}`}
href={`/payers/${payer.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 && (
<span className="truncate font-medium">{payer.name}</span>
{payer.isAdmin && (
<RiVerifiedBadgeFill
className="size-4 shrink-0 text-blue-500"
aria-hidden
@@ -93,13 +91,13 @@ export function PayersWidget({ pagadores }: PayersWidgetProps) {
/>
</Link>
<p className="truncate text-xs text-muted-foreground">
{pagador.email ?? "Sem email cadastrado"}
{payer.email ?? "Sem email cadastrado"}
</p>
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={pagador.totalExpenses} />
<MoneyValues amount={payer.totalExpenses} />
{percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-xs ${

View File

@@ -1,12 +1,12 @@
import { and, asc, eq, gte, lte, ne, sum } from "drizzle-orm";
import { contas, lancamentos } from "@/db/schema";
import { financialAccounts, transactions } from "@/db/schema";
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
} from "@/features/dashboard/lancamento-filters";
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber } from "@/shared/utils/number";
import {
addMonthsToPeriod,
@@ -71,8 +71,8 @@ export async function fetchDashboardCardMetrics(
): Promise<DashboardCardMetrics> {
const previousPeriod = getPreviousPeriod(period);
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return {
period,
previousPeriod,
@@ -88,24 +88,27 @@ export async function fetchDashboardCardMetrics(
const rows = await db
.select({
period: lancamentos.period,
transactionType: lancamentos.transactionType,
totalAmount: sum(lancamentos.amount).as("total"),
period: transactions.period,
transactionType: transactions.transactionType,
totalAmount: sum(transactions.amount).as("total"),
})
.from(lancamentos)
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildDashboardAdminFilters({ userId, adminPagadorId }),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, period),
ne(lancamentos.transactionType, TRANSFERENCIA),
...buildDashboardAdminFilters({ userId, adminPayerId }),
gte(transactions.period, startPeriod),
lte(transactions.period, period),
ne(transactions.transactionType, TRANSFERENCIA),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
),
)
.groupBy(lancamentos.period, lancamentos.transactionType)
.orderBy(asc(lancamentos.period), asc(lancamentos.transactionType));
.groupBy(transactions.period, transactions.transactionType)
.orderBy(asc(transactions.period), asc(transactions.transactionType));
const periodTotals = new Map<string, PeriodTotals>();

View File

@@ -1,11 +1,11 @@
import { and, eq, isNotNull, isNull, or, sql } from "drizzle-orm";
import { cartoes, lancamentos, pagadores } from "@/db/schema";
import { cards, payers, transactions } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import {
buildDateOnlyStringFromPeriodDay,
parseLocalDateString,
@@ -46,7 +46,7 @@ export type InstallmentGroup = {
seriesId: string;
name: string;
paymentMethod: string;
cartaoId: string | null;
cardId: string | null;
cartaoName: string | null;
cartaoDueDay: string | null;
cartaoLogo: string | null;
@@ -68,44 +68,44 @@ export async function fetchInstallmentAnalysis(
// 1. Buscar todos os lançamentos parcelados não antecipados do pagador admin
const installmentRows = await db
.select({
id: lancamentos.id,
seriesId: lancamentos.seriesId,
name: lancamentos.name,
amount: lancamentos.amount,
paymentMethod: lancamentos.paymentMethod,
currentInstallment: lancamentos.currentInstallment,
installmentCount: lancamentos.installmentCount,
dueDate: lancamentos.dueDate,
period: lancamentos.period,
isAnticipated: lancamentos.isAnticipated,
isSettled: lancamentos.isSettled,
purchaseDate: lancamentos.purchaseDate,
cartaoId: lancamentos.cartaoId,
cartaoName: cartoes.name,
cartaoDueDay: cartoes.dueDay,
cartaoLogo: cartoes.logo,
id: transactions.id,
seriesId: transactions.seriesId,
name: transactions.name,
amount: transactions.amount,
paymentMethod: transactions.paymentMethod,
currentInstallment: transactions.currentInstallment,
installmentCount: transactions.installmentCount,
dueDate: transactions.dueDate,
period: transactions.period,
isAnticipated: transactions.isAnticipated,
isSettled: transactions.isSettled,
purchaseDate: transactions.purchaseDate,
cardId: transactions.cardId,
cartaoName: cards.name,
cartaoDueDay: cards.dueDay,
cartaoLogo: cards.logo,
})
.from(lancamentos)
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.from(transactions)
.leftJoin(cards, eq(transactions.cardId, cards.id))
.leftJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.condition, "Parcelado"),
eq(lancamentos.isAnticipated, false),
isNotNull(lancamentos.seriesId),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(transactions.userId, userId),
eq(transactions.transactionType, "Despesa"),
eq(transactions.condition, "Parcelado"),
eq(transactions.isAnticipated, false),
isNotNull(transactions.seriesId),
eq(payers.role, PAYER_ROLE_ADMIN),
or(
isNull(lancamentos.note),
isNull(transactions.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
sql`${transactions.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
),
)
.orderBy(lancamentos.purchaseDate, lancamentos.currentInstallment);
.orderBy(transactions.purchaseDate, transactions.currentInstallment);
// Agrupar por seriesId
const seriesMap = new Map<string, InstallmentGroup>();
@@ -140,7 +140,7 @@ export async function fetchInstallmentAnalysis(
seriesId: row.seriesId,
name: row.name,
paymentMethod: row.paymentMethod,
cartaoId: row.cartaoId,
cardId: row.cardId,
cartaoName: row.cartaoName,
cartaoDueDay: row.cartaoDueDay,
cartaoLogo: row.cartaoLogo,

View File

@@ -1,11 +1,11 @@
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import { transactions } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
export type InstallmentExpense = {
@@ -28,42 +28,42 @@ export async function fetchInstallmentExpenses(
userId: string,
period: string,
): Promise<InstallmentExpensesData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { expenses: [] };
}
const rows = await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
paymentMethod: lancamentos.paymentMethod,
currentInstallment: lancamentos.currentInstallment,
installmentCount: lancamentos.installmentCount,
dueDate: lancamentos.dueDate,
purchaseDate: lancamentos.purchaseDate,
period: lancamentos.period,
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
paymentMethod: transactions.paymentMethod,
currentInstallment: transactions.currentInstallment,
installmentCount: transactions.installmentCount,
dueDate: transactions.dueDate,
purchaseDate: transactions.purchaseDate,
period: transactions.period,
})
.from(lancamentos)
.from(transactions)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.condition, "Parcelado"),
eq(lancamentos.isAnticipated, false),
eq(lancamentos.pagadorId, adminPagadorId),
eq(transactions.userId, userId),
eq(transactions.period, period),
eq(transactions.transactionType, "Despesa"),
eq(transactions.condition, "Parcelado"),
eq(transactions.isAnticipated, false),
eq(transactions.payerId, adminPayerId),
or(
isNull(lancamentos.note),
isNull(transactions.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
sql`${transactions.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
),
)
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
.orderBy(desc(transactions.purchaseDate), desc(transactions.createdAt));
type InstallmentExpenseRow = (typeof rows)[number];

View File

@@ -1,11 +1,11 @@
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import { transactions } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
export type RecurringExpense = {
@@ -24,37 +24,37 @@ export async function fetchRecurringExpenses(
userId: string,
period: string,
): Promise<RecurringExpensesData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { expenses: [] };
}
const results = await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
paymentMethod: lancamentos.paymentMethod,
recurrenceCount: lancamentos.recurrenceCount,
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
paymentMethod: transactions.paymentMethod,
recurrenceCount: transactions.recurrenceCount,
})
.from(lancamentos)
.from(transactions)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.condition, "Recorrente"),
eq(lancamentos.pagadorId, adminPagadorId),
eq(transactions.userId, userId),
eq(transactions.period, period),
eq(transactions.transactionType, "Despesa"),
eq(transactions.condition, "Recorrente"),
eq(transactions.payerId, adminPayerId),
or(
isNull(lancamentos.note),
isNull(transactions.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
sql`${transactions.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
),
)
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
.orderBy(desc(transactions.purchaseDate), desc(transactions.createdAt));
const expenses = results.map(
(row): RecurringExpense => ({

View File

@@ -1,11 +1,11 @@
import { and, asc, eq } from "drizzle-orm";
import { cartoes, contas, lancamentos } from "@/db/schema";
import { cards, financialAccounts, transactions } from "@/db/schema";
import {
buildDashboardAdminPeriodFilters,
excludeAutoGeneratedEntryNotes,
} from "@/features/dashboard/lancamento-filters";
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
export type TopExpense = {
@@ -26,8 +26,8 @@ export async function fetchTopExpenses(
period: string,
cardOnly: boolean = false,
): Promise<TopExpensesData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { expenses: [] };
}
@@ -35,34 +35,37 @@ export async function fetchTopExpenses(
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
adminPayerId,
}),
eq(lancamentos.transactionType, "Despesa"),
eq(transactions.transactionType, "Despesa"),
excludeAutoGeneratedEntryNotes(),
];
// Se cardOnly for true, filtra apenas pagamentos com cartão
if (cardOnly) {
conditions.push(eq(lancamentos.paymentMethod, "Cartão de Crédito"));
conditions.push(eq(transactions.paymentMethod, "Cartão de Crédito"));
}
const results = await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
purchaseDate: lancamentos.purchaseDate,
paymentMethod: lancamentos.paymentMethod,
cartaoId: lancamentos.cartaoId,
contaId: lancamentos.contaId,
cardLogo: cartoes.logo,
accountLogo: contas.logo,
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
purchaseDate: transactions.purchaseDate,
paymentMethod: transactions.paymentMethod,
cardId: transactions.cardId,
accountId: transactions.accountId,
cardLogo: cards.logo,
accountLogo: financialAccounts.logo,
})
.from(lancamentos)
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.from(transactions)
.leftJoin(cards, eq(transactions.cardId, cards.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(and(...conditions))
.orderBy(asc(lancamentos.amount))
.orderBy(asc(transactions.amount))
.limit(10);
const expenses = results.map(

View File

@@ -11,7 +11,7 @@ import { fetchGoalsProgressData } from "./goals-progress-queries";
import { fetchIncomeExpenseBalance } from "./income-expense-balance-queries";
import { fetchDashboardInvoices } from "./invoices-queries";
import { fetchDashboardNotes } from "./notes-queries";
import { fetchDashboardPagadores } from "./payers-queries";
import { fetchDashboardPayers } from "./payers-queries";
import { fetchPaymentConditions } from "./payments/payment-conditions-queries";
import { fetchPaymentMethods } from "./payments/payment-methods-queries";
import { fetchPaymentStatus } from "./payments/payment-status-queries";
@@ -49,7 +49,7 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
fetchGoalsProgressData(userId, period),
fetchPaymentStatus(userId, period),
fetchIncomeExpenseBalance(userId, period),
fetchDashboardPagadores(userId, period),
fetchDashboardPayers(userId, period),
fetchDashboardNotes(userId),
fetchPaymentConditions(userId, period),
fetchPaymentMethods(userId, period),

View File

@@ -1,7 +1,7 @@
import { and, eq, ne, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos } from "@/db/schema";
import { budgets, categories, transactions } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
const BUDGET_CRITICAL_THRESHOLD = 80;
@@ -49,9 +49,9 @@ export async function fetchGoalsProgressData(
userId: string,
period: string,
): Promise<GoalsProgressData> {
const adminPagadorId = await getAdminPagadorId(userId);
const adminPayerId = await getAdminPayerId(userId);
if (!adminPagadorId) {
if (!adminPayerId) {
return {
items: [],
categories: [],
@@ -64,45 +64,45 @@ export async function fetchGoalsProgressData(
const [rows, categoryRows] = await Promise.all([
db
.select({
orcamentoId: orcamentos.id,
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
period: orcamentos.period,
createdAt: orcamentos.createdAt,
budgetAmount: orcamentos.amount,
spentAmount: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`,
orcamentoId: budgets.id,
categoryId: categories.id,
categoryName: categories.name,
categoryIcon: categories.icon,
period: budgets.period,
createdAt: budgets.createdAt,
budgetAmount: budgets.amount,
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
})
.from(orcamentos)
.innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id))
.from(budgets)
.innerJoin(categories, eq(budgets.categoryId, categories.id))
.leftJoin(
lancamentos,
transactions,
and(
eq(lancamentos.categoriaId, orcamentos.categoriaId),
eq(lancamentos.userId, orcamentos.userId),
eq(lancamentos.period, orcamentos.period),
eq(lancamentos.pagadorId, adminPagadorId),
eq(lancamentos.transactionType, "Despesa"),
ne(lancamentos.condition, "cancelado"),
eq(transactions.categoryId, budgets.categoryId),
eq(transactions.userId, budgets.userId),
eq(transactions.period, budgets.period),
eq(transactions.payerId, adminPayerId),
eq(transactions.transactionType, "Despesa"),
ne(transactions.condition, "cancelado"),
),
)
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period)))
.where(and(eq(budgets.userId, userId), eq(budgets.period, period)))
.groupBy(
orcamentos.id,
categorias.id,
categorias.name,
categorias.icon,
orcamentos.period,
orcamentos.createdAt,
orcamentos.amount,
budgets.id,
categories.id,
categories.name,
categories.icon,
budgets.period,
budgets.createdAt,
budgets.amount,
),
db.query.categorias.findMany({
where: and(eq(categorias.userId, userId), eq(categorias.type, "despesa")),
db.query.categories.findMany({
where: and(eq(categories.userId, userId), eq(categories.type, "despesa")),
orderBy: (category, { asc }) => [asc(category.name)],
}),
]);
const categories: GoalProgressCategory[] = categoryRows.map((category) => ({
const categoryList: GoalProgressCategory[] = categoryRows.map((category) => ({
id: category.id,
name: category.name,
icon: category.icon,
@@ -139,7 +139,7 @@ export async function fetchGoalsProgressData(
return {
items,
categories,
categories: categoryList,
totalBudgets: items.length,
exceededCount,
criticalCount,

View File

@@ -1,12 +1,12 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { contas, lancamentos } from "@/db/schema";
import { financialAccounts, transactions } from "@/db/schema";
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
} from "@/features/dashboard/lancamento-filters";
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
import {
buildPeriodWindow,
@@ -38,8 +38,8 @@ export async function fetchIncomeExpenseBalance(
userId: string,
currentPeriod: string,
): Promise<IncomeExpenseBalanceData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { months: [] };
}
@@ -48,22 +48,25 @@ export async function fetchIncomeExpenseBalance(
// Single query: GROUP BY period + transactionType instead of 12 separate queries
const rows = await db
.select({
period: lancamentos.period,
transactionType: lancamentos.transactionType,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
period: transactions.period,
transactionType: transactions.transactionType,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(lancamentos)
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildDashboardAdminFilters({ userId, adminPagadorId }),
inArray(lancamentos.period, periods),
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
...buildDashboardAdminFilters({ userId, adminPayerId }),
inArray(transactions.period, periods),
inArray(transactions.transactionType, ["Receita", "Despesa"]),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
),
)
.groupBy(lancamentos.period, lancamentos.transactionType);
.groupBy(transactions.period, transactions.transactionType);
// Build lookup from query results
const dataMap = new Map<string, { income: number; expense: number }>();

View File

@@ -1,5 +1,5 @@
import { and, eq, ilike, isNotNull, sql } from "drizzle-orm";
import { cartoes, faturas, lancamentos, pagadores } from "@/db/schema";
import { cards, invoices, payers, transactions } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import {
@@ -28,14 +28,14 @@ type RawDashboardInvoice = {
type RawInvoiceBreakdownRow = {
cardId: string | null;
period: string | null;
pagadorId: string | null;
payerId: string | null;
pagadorName: string | null;
pagadorAvatar: string | null;
amount: number | string | null;
};
export type InvoicePagadorBreakdown = {
pagadorId: string | null;
payerId: string | null;
pagadorName: string;
pagadorAvatar: string | null;
amount: number;
@@ -74,15 +74,15 @@ export async function fetchDashboardInvoices(
): Promise<DashboardInvoicesSnapshot> {
const paymentRows = await db
.select({
note: lancamentos.note,
purchaseDate: lancamentos.purchaseDate,
createdAt: lancamentos.createdAt,
note: transactions.note,
purchaseDate: transactions.purchaseDate,
createdAt: transactions.createdAt,
})
.from(lancamentos)
.from(transactions)
.where(
and(
eq(lancamentos.userId, userId),
ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`),
eq(transactions.userId, userId),
ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`),
),
);
@@ -117,80 +117,77 @@ export async function fetchDashboardInvoices(
}
}
const [rows, breakdownRows]: [
RawDashboardInvoice[],
RawInvoiceBreakdownRow[],
] = await Promise.all([
const [rows, breakdownRows] = (await Promise.all([
db
.select({
invoiceId: faturas.id,
cardId: cartoes.id,
cardName: cartoes.name,
logo: cartoes.logo,
dueDay: cartoes.dueDay,
period: faturas.period,
paymentStatus: faturas.paymentStatus,
invoiceCreatedAt: faturas.createdAt,
invoiceId: invoices.id,
cardId: cards.id,
cardName: cards.name,
logo: cards.logo,
dueDay: cards.dueDay,
period: invoices.period,
paymentStatus: invoices.paymentStatus,
invoiceCreatedAt: invoices.createdAt,
totalAmount: sql<number | null>`
COALESCE(SUM(${lancamentos.amount}), 0)
COALESCE(SUM(${transactions.amount}), 0)
`,
transactionCount: sql<number | null>`COUNT(${lancamentos.id})`,
transactionCount: sql<number | null>`COUNT(${transactions.id})`,
})
.from(cartoes)
.from(cards)
.leftJoin(
faturas,
invoices,
and(
eq(faturas.cartaoId, cartoes.id),
eq(faturas.userId, userId),
eq(faturas.period, period),
eq(invoices.cardId, cards.id),
eq(invoices.userId, userId),
eq(invoices.period, period),
),
)
.leftJoin(
lancamentos,
transactions,
and(
eq(lancamentos.cartaoId, cartoes.id),
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(transactions.cardId, cards.id),
eq(transactions.userId, userId),
eq(transactions.period, period),
),
)
.where(eq(cartoes.userId, userId))
.where(eq(cards.userId, userId))
.groupBy(
faturas.id,
cartoes.id,
cartoes.name,
cartoes.brand,
cartoes.status,
cartoes.logo,
cartoes.dueDay,
faturas.period,
faturas.paymentStatus,
invoices.id,
cards.id,
cards.name,
cards.brand,
cards.status,
cards.logo,
cards.dueDay,
invoices.period,
invoices.paymentStatus,
),
db
.select({
cardId: lancamentos.cartaoId,
period: lancamentos.period,
pagadorId: lancamentos.pagadorId,
pagadorName: pagadores.name,
pagadorAvatar: pagadores.avatarUrl,
amount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
cardId: transactions.cardId,
period: transactions.period,
payerId: transactions.payerId,
pagadorName: payers.name,
pagadorAvatar: payers.avatarUrl,
amount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.from(transactions)
.leftJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
isNotNull(lancamentos.cartaoId),
eq(transactions.userId, userId),
eq(transactions.period, period),
isNotNull(transactions.cardId),
),
)
.groupBy(
lancamentos.cartaoId,
lancamentos.period,
lancamentos.pagadorId,
pagadores.name,
pagadores.avatarUrl,
transactions.cardId,
transactions.period,
transactions.payerId,
payers.name,
payers.avatarUrl,
),
]);
])) as [RawDashboardInvoice[], RawInvoiceBreakdownRow[]];
const breakdownMap = new Map<string, InvoicePagadorBreakdown[]>();
for (const row of breakdownRows) {
@@ -205,7 +202,7 @@ export async function fetchDashboardInvoices(
const key = `${row.cardId}:${resolvedPeriod}`;
const current = breakdownMap.get(key) ?? [];
current.push({
pagadorId: row.pagadorId ?? null,
payerId: row.payerId ?? null,
pagadorName: row.pagadorName?.trim() || "Sem pagador",
pagadorAvatar: row.pagadorAvatar ?? null,
amount,
@@ -213,7 +210,7 @@ export async function fetchDashboardInvoices(
breakdownMap.set(key, current);
}
const invoices: DashboardInvoice[] = [];
const invoiceList: DashboardInvoice[] = [];
for (const row of rows) {
if (!row) {
@@ -242,7 +239,7 @@ export async function fetchDashboardInvoices(
? (paymentMap.get(paymentKey) ?? toDateOnlyString(row.invoiceCreatedAt))
: null;
invoices.push({
invoiceList.push({
id: row.invoiceId ?? buildFallbackId(row.cardId, period),
cardId: row.cardId,
cardName: row.cardName,
@@ -260,12 +257,12 @@ export async function fetchDashboardInvoices(
});
}
invoices.sort((a, b) => {
invoiceList.sort((a, b) => {
// Ordena do maior valor para o menor
return Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
});
const totalPending = invoices.reduce((total, invoice) => {
const totalPending = invoiceList.reduce((total, invoice) => {
if (invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PENDING) {
return total;
}
@@ -273,7 +270,7 @@ export async function fetchDashboardInvoices(
}, 0);
return {
invoices,
invoices: invoiceList,
totalPending,
};
}

View File

@@ -7,7 +7,7 @@ export const mapDashboardNoteToNote = (note: DashboardNote): Note => ({
description: note.description,
type: note.type,
tasks: note.tasks,
arquivada: note.arquivada,
archived: note.archived,
createdAt: note.createdAt,
});

View File

@@ -1,5 +1,5 @@
import { and, eq } from "drizzle-orm";
import { anotacoes } from "@/db/schema";
import { notes } from "@/db/schema";
import { db } from "@/shared/lib/db";
export type DashboardTask = {
@@ -14,7 +14,7 @@ export type DashboardNote = {
description: string;
type: "nota" | "tarefa";
tasks?: DashboardTask[];
arquivada: boolean;
archived: boolean;
createdAt: string;
};
@@ -55,19 +55,19 @@ const parseTasks = (value: string | null): DashboardTask[] | undefined => {
export async function fetchDashboardNotes(
userId: string,
): Promise<DashboardNote[]> {
const notes = await db.query.anotacoes.findMany({
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)),
const noteRows = await db.query.notes.findMany({
where: and(eq(notes.userId, userId), eq(notes.archived, false)),
orderBy: (note, { desc }) => [desc(note.createdAt)],
limit: 5,
});
return notes.map((note) => ({
return noteRows.map((note) => ({
id: note.id,
title: (note.title ?? "").trim(),
description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa",
tasks: parseTasks(note.tasks),
arquivada: note.arquivada,
archived: note.archived,
createdAt: note.createdAt.toISOString(),
}));
}

View File

@@ -2,15 +2,15 @@
import { and, eq, lt, ne, sql } from "drizzle-orm";
import {
cartoes,
categorias,
faturas,
lancamentos,
orcamentos,
budgets,
cards,
categories,
invoices,
transactions,
} from "@/db/schema";
import { db } from "@/shared/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import {
buildDateOnlyStringFromPeriodDay,
getBusinessDateString,
@@ -67,128 +67,126 @@ export async function fetchDashboardNotifications(
const today = getBusinessDateString();
const DAYS_THRESHOLD = 5;
const adminPagadorId = await getAdminPagadorId(userId);
const adminPayerId = await getAdminPayerId(userId);
// --- Faturas atrasadas (períodos anteriores) ---
const overdueInvoices = await db
.select({
invoiceId: faturas.id,
cardId: cartoes.id,
cardName: cartoes.name,
cardLogo: cartoes.logo,
dueDay: cartoes.dueDay,
period: faturas.period,
invoiceId: invoices.id,
cardId: cards.id,
cardName: cards.name,
cardLogo: cards.logo,
dueDay: cards.dueDay,
period: invoices.period,
totalAmount: sql<number | null>`
COALESCE(
(SELECT SUM(${lancamentos.amount})
FROM ${lancamentos}
WHERE ${lancamentos.cartaoId} = ${cartoes.id}
AND ${lancamentos.period} = ${faturas.period}
AND ${lancamentos.userId} = ${faturas.userId}),
(SELECT SUM(${transactions.amount})
FROM ${transactions}
WHERE ${transactions.cardId} = ${cards.id}
AND ${transactions.period} = ${invoices.period}
AND ${transactions.userId} = ${invoices.userId}),
0
)
`,
})
.from(faturas)
.innerJoin(cartoes, eq(faturas.cartaoId, cartoes.id))
.from(invoices)
.innerJoin(cards, eq(invoices.cardId, cards.id))
.where(
and(
eq(faturas.userId, userId),
eq(faturas.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING),
lt(faturas.period, currentPeriod),
eq(invoices.userId, userId),
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING),
lt(invoices.period, currentPeriod),
),
);
// --- Faturas do período atual ---
const currentInvoices = await db
.select({
invoiceId: faturas.id,
cardId: cartoes.id,
cardName: cartoes.name,
cardLogo: cartoes.logo,
dueDay: cartoes.dueDay,
period: sql<string>`COALESCE(${faturas.period}, ${currentPeriod})`,
paymentStatus: faturas.paymentStatus,
invoiceId: invoices.id,
cardId: cards.id,
cardName: cards.name,
cardLogo: cards.logo,
dueDay: cards.dueDay,
period: sql<string>`COALESCE(${invoices.period}, ${currentPeriod})`,
paymentStatus: invoices.paymentStatus,
totalAmount: sql<number | null>`
COALESCE(SUM(${lancamentos.amount}), 0)
COALESCE(SUM(${transactions.amount}), 0)
`,
transactionCount: sql<number | null>`COUNT(${lancamentos.id})`,
transactionCount: sql<number | null>`COUNT(${transactions.id})`,
})
.from(cartoes)
.from(cards)
.leftJoin(
faturas,
invoices,
and(
eq(faturas.cartaoId, cartoes.id),
eq(faturas.userId, userId),
eq(faturas.period, currentPeriod),
eq(invoices.cardId, cards.id),
eq(invoices.userId, userId),
eq(invoices.period, currentPeriod),
),
)
.leftJoin(
lancamentos,
transactions,
and(
eq(lancamentos.cartaoId, cartoes.id),
eq(lancamentos.userId, userId),
eq(lancamentos.period, currentPeriod),
eq(transactions.cardId, cards.id),
eq(transactions.userId, userId),
eq(transactions.period, currentPeriod),
),
)
.where(eq(cartoes.userId, userId))
.where(eq(cards.userId, userId))
.groupBy(
faturas.id,
cartoes.id,
cartoes.name,
cartoes.logo,
cartoes.dueDay,
faturas.period,
faturas.paymentStatus,
invoices.id,
cards.id,
cards.name,
cards.logo,
cards.dueDay,
invoices.period,
invoices.paymentStatus,
);
// --- Boletos não pagos ---
const boletosConditions = [
eq(lancamentos.userId, userId),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(lancamentos.isSettled, false),
eq(transactions.userId, userId),
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(transactions.isSettled, false),
];
if (adminPagadorId) {
boletosConditions.push(eq(lancamentos.pagadorId, adminPagadorId));
if (adminPayerId) {
boletosConditions.push(eq(transactions.payerId, adminPayerId));
}
const boletosRows = await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
dueDate: lancamentos.dueDate,
period: lancamentos.period,
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
dueDate: transactions.dueDate,
period: transactions.period,
})
.from(lancamentos)
.from(transactions)
.where(and(...boletosConditions));
// --- Orçamentos do período atual ---
const budgetJoinConditions = [
eq(lancamentos.categoriaId, orcamentos.categoriaId),
eq(lancamentos.userId, orcamentos.userId),
eq(lancamentos.period, orcamentos.period),
eq(lancamentos.transactionType, "Despesa"),
ne(lancamentos.condition, "cancelado"),
eq(transactions.categoryId, budgets.categoryId),
eq(transactions.userId, budgets.userId),
eq(transactions.period, budgets.period),
eq(transactions.transactionType, "Despesa"),
ne(transactions.condition, "cancelado"),
];
if (adminPagadorId) {
budgetJoinConditions.push(eq(lancamentos.pagadorId, adminPagadorId));
if (adminPayerId) {
budgetJoinConditions.push(eq(transactions.payerId, adminPayerId));
}
const budgetRows = await db
.select({
orcamentoId: orcamentos.id,
budgetAmount: orcamentos.amount,
categoriaName: categorias.name,
spentAmount: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`,
orcamentoId: budgets.id,
budgetAmount: budgets.amount,
categoriaName: categories.name,
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
})
.from(orcamentos)
.innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id))
.leftJoin(lancamentos, and(...budgetJoinConditions))
.where(
and(eq(orcamentos.userId, userId), eq(orcamentos.period, currentPeriod)),
)
.groupBy(orcamentos.id, orcamentos.amount, categorias.name);
.from(budgets)
.innerJoin(categories, eq(budgets.categoryId, categories.id))
.leftJoin(transactions, and(...budgetJoinConditions))
.where(and(eq(budgets.userId, userId), eq(budgets.period, currentPeriod)))
.groupBy(budgets.id, budgets.amount, categories.name);
// =====================
// Processar notificações

View File

@@ -1,8 +1,8 @@
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import { payers, transactions } 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 { PAYER_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";
@@ -18,49 +18,49 @@ export type DashboardPagador = {
isAdmin: boolean;
};
export type DashboardPagadoresSnapshot = {
pagadores: DashboardPagador[];
export type DashboardPayersSnapshot = {
payers: DashboardPagador[];
totalExpenses: number;
};
export async function fetchDashboardPagadores(
export async function fetchDashboardPayers(
userId: string,
period: string,
): Promise<DashboardPagadoresSnapshot> {
): Promise<DashboardPayersSnapshot> {
const previousPeriod = getPreviousPeriod(period);
const rows = await db
.select({
id: pagadores.id,
name: pagadores.name,
email: pagadores.email,
avatarUrl: pagadores.avatarUrl,
role: pagadores.role,
period: lancamentos.period,
totalExpenses: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`,
id: payers.id,
name: payers.name,
email: payers.email,
avatarUrl: payers.avatarUrl,
role: payers.role,
period: transactions.period,
totalExpenses: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(lancamentos.userId, userId),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Despesa"),
eq(transactions.userId, userId),
inArray(transactions.period, [period, previousPeriod]),
eq(transactions.transactionType, "Despesa"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
isNull(transactions.note),
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(
pagadores.id,
pagadores.name,
pagadores.email,
pagadores.avatarUrl,
pagadores.role,
lancamentos.period,
payers.id,
payers.name,
payers.email,
payers.avatarUrl,
payers.role,
transactions.period,
)
.orderBy(desc(sql`SUM(ABS(${lancamentos.amount}))`));
.orderBy(desc(sql`SUM(ABS(${transactions.amount}))`));
const groupedPagadores = new Map<
string,
@@ -81,7 +81,7 @@ export async function fetchDashboardPagadores(
name: row.name,
email: row.email,
avatarUrl: row.avatarUrl,
isAdmin: row.role === PAGADOR_ROLE_ADMIN,
isAdmin: row.role === PAYER_ROLE_ADMIN,
currentExpenses: 0,
previousExpenses: 0,
};
@@ -96,7 +96,7 @@ export async function fetchDashboardPagadores(
groupedPagadores.set(row.id, entry);
}
const pagadoresList = Array.from(groupedPagadores.values())
const payerList = Array.from(groupedPagadores.values())
.filter((p) => p.currentExpenses > 0)
.map((pagador) => ({
id: pagador.id,
@@ -113,13 +113,13 @@ export async function fetchDashboardPagadores(
}))
.sort((a, b) => b.totalExpenses - a.totalExpenses);
const totalExpenses = pagadoresList.reduce(
const totalExpenses = payerList.reduce(
(sum, p) => sum + p.totalExpenses,
0,
);
return {
pagadores: pagadoresList,
payers: payerList,
totalExpenses,
};
}

View File

@@ -1,11 +1,11 @@
import { and, eq, sql } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import { transactions } from "@/db/schema";
import {
buildDashboardAdminPeriodFilters,
excludeAutoGeneratedEntryNotes,
} from "@/features/dashboard/lancamento-filters";
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
export type PaymentConditionSummary = {
@@ -23,30 +23,30 @@ export async function fetchPaymentConditions(
userId: string,
period: string,
): Promise<PaymentConditionsData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { conditions: [] };
}
const rows = await db
.select({
condition: lancamentos.condition,
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
transactions: sql<number>`count(${lancamentos.id})`,
condition: transactions.condition,
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
transactions: sql<number>`count(${transactions.id})`,
})
.from(lancamentos)
.from(transactions)
.where(
and(
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
adminPayerId,
}),
eq(lancamentos.transactionType, "Despesa"),
eq(transactions.transactionType, "Despesa"),
excludeAutoGeneratedEntryNotes(),
),
)
.groupBy(lancamentos.condition);
.groupBy(transactions.condition);
const summaries = rows.map((row: (typeof rows)[number]) => {
const totalAmount = Math.abs(toNumber(row.totalAmount));

View File

@@ -1,11 +1,11 @@
import { and, eq, sql } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import { transactions } from "@/db/schema";
import {
buildDashboardAdminPeriodFilters,
excludeAutoGeneratedEntryNotes,
} from "@/features/dashboard/lancamento-filters";
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
export type PaymentMethodSummary = {
@@ -23,30 +23,30 @@ export async function fetchPaymentMethods(
userId: string,
period: string,
): Promise<PaymentMethodsData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { methods: [] };
}
const rows = await db
.select({
paymentMethod: lancamentos.paymentMethod,
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
transactions: sql<number>`count(${lancamentos.id})`,
paymentMethod: transactions.paymentMethod,
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
transactions: sql<number>`count(${transactions.id})`,
})
.from(lancamentos)
.from(transactions)
.where(
and(
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
adminPayerId,
}),
eq(lancamentos.transactionType, "Despesa"),
eq(transactions.transactionType, "Despesa"),
excludeAutoGeneratedEntryNotes(),
),
)
.groupBy(lancamentos.paymentMethod);
.groupBy(transactions.paymentMethod);
const summaries = rows.map((row: (typeof rows)[number]) => {
const amount = Math.abs(toNumber(row.totalAmount));

View File

@@ -1,11 +1,11 @@
import { and, inArray, sql } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import { transactions } from "@/db/schema";
import {
buildDashboardAdminPeriodFilters,
excludeAutoInvoiceEntries,
} from "@/features/dashboard/lancamento-filters";
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
export type PaymentStatusCategory = {
@@ -29,41 +29,41 @@ export async function fetchPaymentStatus(
userId: string,
period: string,
): Promise<PaymentStatusData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { income: emptyCategory(), expenses: emptyCategory() };
}
// Single query: GROUP BY transactionType instead of 2 separate queries
const rows = await db
.select({
transactionType: lancamentos.transactionType,
transactionType: transactions.transactionType,
confirmed: sql<number>`
coalesce(
sum(case when ${lancamentos.isSettled} = true then ${lancamentos.amount} else 0 end),
sum(case when ${transactions.isSettled} = true then ${transactions.amount} else 0 end),
0
)
`,
pending: sql<number>`
coalesce(
sum(case when ${lancamentos.isSettled} = false or ${lancamentos.isSettled} is null then ${lancamentos.amount} else 0 end),
sum(case when ${transactions.isSettled} = false or ${transactions.isSettled} is null then ${transactions.amount} else 0 end),
0
)
`,
})
.from(lancamentos)
.from(transactions)
.where(
and(
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
adminPayerId,
}),
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
inArray(transactions.transactionType, ["Receita", "Despesa"]),
excludeAutoInvoiceEntries(),
),
)
.groupBy(lancamentos.transactionType);
.groupBy(transactions.transactionType);
const result = { income: emptyCategory(), expenses: emptyCategory() };

View File

@@ -11,10 +11,10 @@ export async function fetchUserDashboardPreferences(
): Promise<UserDashboardPreferences> {
const result = await db
.select({
dashboardWidgets: schema.preferenciasUsuario.dashboardWidgets,
dashboardWidgets: schema.userPreferences.dashboardWidgets,
})
.from(schema.preferenciasUsuario)
.where(eq(schema.preferenciasUsuario.userId, userId))
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, userId))
.limit(1);
return {

View File

@@ -1,11 +1,16 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import { cartoes, categorias, contas, lancamentos } from "@/db/schema";
import {
cards,
categories,
financialAccounts,
transactions,
} from "@/db/schema";
import {
buildDashboardAdminPeriodFilters,
excludeAutoGeneratedEntryNotes,
} from "@/features/dashboard/lancamento-filters";
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
export type CategoryOption = {
@@ -45,39 +50,42 @@ export async function fetchPurchasesByCategory(
userId: string,
period: string,
): Promise<PurchasesByCategoryData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { categories: [], transactionsByCategory: {} };
}
const transactionsRows = await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
purchaseDate: lancamentos.purchaseDate,
categoryId: lancamentos.categoriaId,
categoryName: categorias.name,
categoryType: categorias.type,
cardLogo: cartoes.logo,
accountLogo: contas.logo,
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
purchaseDate: transactions.purchaseDate,
categoryId: transactions.categoryId,
categoryName: categories.name,
categoryType: categories.type,
cardLogo: cards.logo,
accountLogo: financialAccounts.logo,
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(cards, eq(transactions.cardId, cards.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
adminPayerId,
}),
inArray(categorias.type, ["despesa", "receita"]),
inArray(categories.type, ["despesa", "receita"]),
excludeAutoGeneratedEntryNotes(),
),
)
.orderBy(desc(lancamentos.purchaseDate));
.orderBy(desc(transactions.purchaseDate));
const transactionsByCategory: Record<string, CategoryTransaction[]> = {};
const categoriesMap = new Map<string, CategoryOption>();
@@ -120,8 +128,8 @@ export async function fetchPurchasesByCategory(
}
}
// Ordena as categorias: receitas primeiro, depois despesas (alfabeticamente dentro de cada tipo)
const categories = Array.from(categoriesMap.values()).sort((a, b) => {
// Ordena as categories: receitas primeiro, depois despesas (alfabeticamente dentro de cada tipo)
const categoryList = Array.from(categoriesMap.values()).sort((a, b) => {
// Receita vem antes de despesa
if (a.type !== b.type) {
return a.type === "receita" ? -1 : 1;
@@ -131,7 +139,7 @@ export async function fetchPurchasesByCategory(
});
return {
categories,
categories: categoryList,
transactionsByCategory,
};
}

View File

@@ -1,8 +1,8 @@
import { and, eq, inArray } from "drizzle-orm";
import type { RecurringSeriesTemplate } from "@/db/schema";
import { categorias, recurringSeries } from "@/db/schema";
import { categories, recurringSeries } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
import { addMonthsToPeriod } from "@/shared/utils/period";
@@ -26,7 +26,7 @@ export type RecurringSeriesData = {
export async function fetchRecurringSeries(
userId: string,
): Promise<RecurringSeriesData> {
const adminPagadorId = await getAdminPagadorId(userId);
const adminPayerId = await getAdminPayerId(userId);
const rows = await db
.select({
@@ -50,19 +50,19 @@ export async function fetchRecurringSeries(
// Fetch category names for all series in one query
const categoryIds = rows
.map((r) => (r.templateData as RecurringSeriesTemplate).categoriaId)
.map((r) => (r.templateData as RecurringSeriesTemplate).categoryId)
.filter((id): id is string => id !== null);
const categoryMap = new Map<string, { name: string; icon: string | null }>();
if (categoryIds.length > 0) {
const cats = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
id: categories.id,
name: categories.name,
icon: categories.icon,
})
.from(categorias)
.where(inArray(categorias.id, categoryIds));
.from(categories)
.where(inArray(categories.id, categoryIds));
for (const cat of cats) {
categoryMap.set(cat.id, { name: cat.name, icon: cat.icon });
}
@@ -71,16 +71,14 @@ export async function fetchRecurringSeries(
const series = rows
.filter((row) => {
// If admin pagador exists, only show series belonging to admin
if (!adminPagadorId) return true;
if (!adminPayerId) return true;
const template = row.templateData as RecurringSeriesTemplate;
return (
template.pagadorId === adminPagadorId || template.pagadorId === null
);
return template.payerId === adminPayerId || template.payerId === null;
})
.map((row): RecurringSeriesItem => {
const template = row.templateData as RecurringSeriesTemplate;
const category = template.categoriaId
? categoryMap.get(template.categoriaId)
const category = template.categoryId
? categoryMap.get(template.categoryId)
: null;
return {
id: row.id,

View File

@@ -1,11 +1,11 @@
import { and, eq, sql } from "drizzle-orm";
import { cartoes, contas, lancamentos } from "@/db/schema";
import { cards, financialAccounts, transactions } from "@/db/schema";
import {
buildDashboardAdminPeriodFilters,
excludeAutoGeneratedEntryNotes,
} from "@/features/dashboard/lancamento-filters";
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
export type TopEstablishment = {
@@ -38,36 +38,41 @@ export async function fetchTopEstablishments(
userId: string,
period: string,
): Promise<TopEstablishmentsData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { establishments: [] };
}
const rows = await db
.select({
name: lancamentos.name,
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
occurrences: sql<number>`count(${lancamentos.id})`,
logo: sql<string | null>`max(coalesce(${cartoes.logo}, ${contas.logo}))`,
name: transactions.name,
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
occurrences: sql<number>`count(${transactions.id})`,
logo: sql<
string | null
>`max(coalesce(${cards.logo}, ${financialAccounts.logo}))`,
})
.from(lancamentos)
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.from(transactions)
.leftJoin(cards, eq(transactions.cardId, cards.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
adminPayerId,
}),
eq(lancamentos.transactionType, "Despesa"),
eq(transactions.transactionType, "Despesa"),
excludeAutoGeneratedEntryNotes(),
),
)
.groupBy(lancamentos.name)
.groupBy(transactions.name)
.orderBy(
sql`count(${lancamentos.id}) DESC`,
sql`ABS(sum(${lancamentos.amount})) DESC`,
sql`count(${transactions.id}) DESC`,
sql`ABS(sum(${transactions.amount})) DESC`,
)
.limit(10);

View File

@@ -1,5 +1,5 @@
import { and, eq, ilike, isNull, ne, not, or } from "drizzle-orm";
import { contas, lancamentos } from "@/db/schema";
import { financialAccounts, transactions } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
@@ -7,7 +7,7 @@ import {
type DashboardAdminFiltersParams = {
userId: string;
adminPagadorId: string;
adminPayerId: string;
};
type DashboardAdminPeriodFiltersParams = DashboardAdminFiltersParams & {
@@ -16,41 +16,41 @@ type DashboardAdminPeriodFiltersParams = DashboardAdminFiltersParams & {
export const buildDashboardAdminFilters = ({
userId,
adminPagadorId,
adminPayerId,
}: DashboardAdminFiltersParams) =>
[
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
eq(transactions.userId, userId),
eq(transactions.payerId, adminPayerId),
] as const;
export const buildDashboardAdminPeriodFilters = ({
userId,
period,
adminPagadorId,
adminPayerId,
}: DashboardAdminPeriodFiltersParams) =>
[
...buildDashboardAdminFilters({ userId, adminPagadorId }),
eq(lancamentos.period, period),
...buildDashboardAdminFilters({ userId, adminPayerId }),
eq(transactions.period, period),
] as const;
export const excludeAutoInvoiceEntries = () =>
or(
isNull(lancamentos.note),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
isNull(transactions.note),
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
);
export const excludeAutoGeneratedEntryNotes = () =>
or(
isNull(lancamentos.note),
isNull(transactions.note),
and(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
ne(transactions.note, INITIAL_BALANCE_NOTE),
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
);
export const excludeInitialBalanceWhenConfigured = () =>
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
);

View File

@@ -10,7 +10,7 @@ import {
type PaymentDialogController,
usePaymentDialogController,
} from "@/features/dashboard/use-payment-dialog-controller";
import { toggleLancamentoSettlementAction } from "@/features/transactions/actions";
import { toggleTransactionSettlementAction } from "@/features/transactions/actions";
const EMPTY_BILLS: DashboardBill[] = [];
@@ -31,7 +31,7 @@ export function useBillWidgetController(
getItemId: (bill) => bill.id,
isItemConfirmed: (bill) => bill.isSettled,
executeConfirm: (bill) =>
toggleLancamentoSettlementAction({
toggleTransactionSettlementAction({
id: bill.id,
value: true,
}),

View File

@@ -31,7 +31,7 @@ export function useInvoicesWidgetController(
isItemConfirmed: (invoice) => isInvoicePaid(invoice.paymentStatus),
executeConfirm: (invoice) =>
updateInvoicePaymentStatusAction({
cartaoId: invoice.cardId,
cardId: invoice.cardId,
period: invoice.period,
status: INVOICE_PAYMENT_STATUS.PAID,
}),

View File

@@ -18,21 +18,21 @@ export async function updateWidgetPreferences(
// Check if preferences exist
const existing = await db
.select({ id: schema.preferenciasUsuario.id })
.from(schema.preferenciasUsuario)
.where(eq(schema.preferenciasUsuario.userId, user.id))
.select({ id: schema.userPreferences.id })
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, user.id))
.limit(1);
if (existing.length > 0) {
await db
.update(schema.preferenciasUsuario)
.update(schema.userPreferences)
.set({
dashboardWidgets: preferences,
updatedAt: new Date(),
})
.where(eq(schema.preferenciasUsuario.userId, user.id));
.where(eq(schema.userPreferences.userId, user.id));
} else {
await db.insert(schema.preferenciasUsuario).values({
await db.insert(schema.userPreferences).values({
userId: user.id,
dashboardWidgets: preferences,
});
@@ -54,12 +54,12 @@ export async function resetWidgetPreferences(): Promise<{
const user = await getUser();
await db
.update(schema.preferenciasUsuario)
.update(schema.userPreferences)
.set({
dashboardWidgets: null,
updatedAt: new Date(),
})
.where(eq(schema.preferenciasUsuario.userId, user.id));
.where(eq(schema.userPreferences.userId, user.id));
revalidatePath("/dashboard");
return { success: true };

View File

@@ -97,7 +97,7 @@ export const widgetsConfig: WidgetConfig[] = [
subtitle: "Despesas por pagador no período",
icon: <RiGroupLine className="size-4" />,
component: ({ data }) => (
<PayersWidget pagadores={data.pagadoresSnapshot.pagadores} />
<PayersWidget payers={data.pagadoresSnapshot.payers} />
),
action: (
<Link