forked from git.gladyson/openmonetis
refactor: migrate from ESLint to Biome and extract SQL queries to data.ts
- Replace ESLint with Biome for linting and formatting - Configure Biome with tabs, double quotes, and organized imports - Move all SQL/Drizzle queries from page.tsx files to data.ts files - Create new data.ts files for: ajustes, dashboard, relatorios/categorias - Update existing data.ts files: extrato, fatura (add lancamentos queries) - Remove all drizzle-orm imports from page.tsx files - Update README.md with new tooling info Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,49 +1,49 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
|
||||
type RawDashboardAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
logo: string | null;
|
||||
initialBalance: string | number | null;
|
||||
balanceMovements: unknown;
|
||||
id: string;
|
||||
name: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
logo: string | null;
|
||||
initialBalance: string | number | null;
|
||||
balanceMovements: unknown;
|
||||
};
|
||||
|
||||
export type DashboardAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
logo: string | null;
|
||||
initialBalance: number;
|
||||
balance: number;
|
||||
excludeFromBalance: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
logo: string | null;
|
||||
initialBalance: number;
|
||||
balance: number;
|
||||
excludeFromBalance: boolean;
|
||||
};
|
||||
|
||||
export type DashboardAccountsSnapshot = {
|
||||
totalBalance: number;
|
||||
accounts: DashboardAccount[];
|
||||
totalBalance: number;
|
||||
accounts: DashboardAccount[];
|
||||
};
|
||||
|
||||
export async function fetchDashboardAccounts(
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<DashboardAccountsSnapshot> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: contas.id,
|
||||
name: contas.name,
|
||||
accountType: contas.accountType,
|
||||
status: contas.status,
|
||||
logo: contas.logo,
|
||||
initialBalance: contas.initialBalance,
|
||||
excludeFromBalance: contas.excludeFromBalance,
|
||||
balanceMovements: sql<number>`
|
||||
const rows = await db
|
||||
.select({
|
||||
id: contas.id,
|
||||
name: contas.name,
|
||||
accountType: contas.accountType,
|
||||
status: contas.status,
|
||||
logo: contas.logo,
|
||||
initialBalance: contas.initialBalance,
|
||||
excludeFromBalance: contas.excludeFromBalance,
|
||||
balanceMovements: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
@@ -54,60 +54,61 @@ export async function fetchDashboardAccounts(
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(contas)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.contaId, contas.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.isSettled, true)
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
pagadores,
|
||||
eq(lancamentos.pagadorId, pagadores.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(contas.userId, userId),
|
||||
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`
|
||||
)
|
||||
)
|
||||
.groupBy(
|
||||
contas.id,
|
||||
contas.name,
|
||||
contas.accountType,
|
||||
contas.status,
|
||||
contas.logo,
|
||||
contas.initialBalance,
|
||||
contas.excludeFromBalance
|
||||
);
|
||||
})
|
||||
.from(contas)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.contaId, contas.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.isSettled, true),
|
||||
),
|
||||
)
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(contas.userId, userId),
|
||||
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
contas.id,
|
||||
contas.name,
|
||||
contas.accountType,
|
||||
contas.status,
|
||||
contas.logo,
|
||||
contas.initialBalance,
|
||||
contas.excludeFromBalance,
|
||||
);
|
||||
|
||||
const accounts = rows
|
||||
.map((row: RawDashboardAccount & { excludeFromBalance: boolean }): DashboardAccount => {
|
||||
const initialBalance = toNumber(row.initialBalance);
|
||||
const balanceMovements = toNumber(row.balanceMovements);
|
||||
const accounts = rows
|
||||
.map(
|
||||
(
|
||||
row: RawDashboardAccount & { excludeFromBalance: boolean },
|
||||
): DashboardAccount => {
|
||||
const initialBalance = toNumber(row.initialBalance);
|
||||
const balanceMovements = toNumber(row.balanceMovements);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
accountType: row.accountType,
|
||||
status: row.status,
|
||||
logo: row.logo,
|
||||
initialBalance,
|
||||
balance: initialBalance + balanceMovements,
|
||||
excludeFromBalance: row.excludeFromBalance,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.balance - a.balance);
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
accountType: row.accountType,
|
||||
status: row.status,
|
||||
logo: row.logo,
|
||||
initialBalance,
|
||||
balance: initialBalance + balanceMovements,
|
||||
excludeFromBalance: row.excludeFromBalance,
|
||||
};
|
||||
},
|
||||
)
|
||||
.sort((a, b) => b.balance - a.balance);
|
||||
|
||||
const totalBalance = accounts
|
||||
.filter(account => !account.excludeFromBalance)
|
||||
.reduce((total, account) => total + account.balance, 0);
|
||||
const totalBalance = accounts
|
||||
.filter((account) => !account.excludeFromBalance)
|
||||
.reduce((total, account) => total + account.balance, 0);
|
||||
|
||||
return {
|
||||
totalBalance,
|
||||
accounts,
|
||||
};
|
||||
return {
|
||||
totalBalance,
|
||||
accounts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,106 +1,106 @@
|
||||
"use server";
|
||||
|
||||
import { lancamentos, pagadores } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { lancamentos, pagadores } from "@/db/schema";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||
|
||||
type RawDashboardBoleto = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: string | number | null;
|
||||
dueDate: string | Date | null;
|
||||
boletoPaymentDate: string | Date | null;
|
||||
isSettled: boolean | null;
|
||||
id: string;
|
||||
name: string;
|
||||
amount: string | number | null;
|
||||
dueDate: string | Date | null;
|
||||
boletoPaymentDate: string | Date | null;
|
||||
isSettled: boolean | null;
|
||||
};
|
||||
|
||||
export type DashboardBoleto = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
dueDate: string | null;
|
||||
boletoPaymentDate: string | null;
|
||||
isSettled: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
dueDate: string | null;
|
||||
boletoPaymentDate: string | null;
|
||||
isSettled: boolean;
|
||||
};
|
||||
|
||||
export type DashboardBoletosSnapshot = {
|
||||
boletos: DashboardBoleto[];
|
||||
totalPendingAmount: number;
|
||||
pendingCount: number;
|
||||
boletos: DashboardBoleto[];
|
||||
totalPendingAmount: number;
|
||||
pendingCount: number;
|
||||
};
|
||||
|
||||
const toISODate = (value: Date | string | null) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
};
|
||||
|
||||
export async function fetchDashboardBoletos(
|
||||
userId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<DashboardBoletosSnapshot> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
dueDate: lancamentos.dueDate,
|
||||
boletoPaymentDate: lancamentos.boletoPaymentDate,
|
||||
isSettled: lancamentos.isSettled,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
eq(pagadores.role, "admin")
|
||||
)
|
||||
)
|
||||
.orderBy(
|
||||
asc(lancamentos.isSettled),
|
||||
asc(lancamentos.dueDate),
|
||||
asc(lancamentos.name)
|
||||
);
|
||||
const rows = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
dueDate: lancamentos.dueDate,
|
||||
boletoPaymentDate: lancamentos.boletoPaymentDate,
|
||||
isSettled: lancamentos.isSettled,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
eq(pagadores.role, "admin"),
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
asc(lancamentos.isSettled),
|
||||
asc(lancamentos.dueDate),
|
||||
asc(lancamentos.name),
|
||||
);
|
||||
|
||||
const boletos = rows.map((row: RawDashboardBoleto): DashboardBoleto => {
|
||||
const amount = Math.abs(toNumber(row.amount));
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount,
|
||||
dueDate: toISODate(row.dueDate),
|
||||
boletoPaymentDate: toISODate(row.boletoPaymentDate),
|
||||
isSettled: Boolean(row.isSettled),
|
||||
};
|
||||
});
|
||||
const boletos = rows.map((row: RawDashboardBoleto): DashboardBoleto => {
|
||||
const amount = Math.abs(toNumber(row.amount));
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount,
|
||||
dueDate: toISODate(row.dueDate),
|
||||
boletoPaymentDate: toISODate(row.boletoPaymentDate),
|
||||
isSettled: Boolean(row.isSettled),
|
||||
};
|
||||
});
|
||||
|
||||
let totalPendingAmount = 0;
|
||||
let pendingCount = 0;
|
||||
let totalPendingAmount = 0;
|
||||
let pendingCount = 0;
|
||||
|
||||
for (const boleto of boletos) {
|
||||
if (!boleto.isSettled) {
|
||||
totalPendingAmount += boleto.amount;
|
||||
pendingCount += 1;
|
||||
}
|
||||
}
|
||||
for (const boleto of boletos) {
|
||||
if (!boleto.isSettled) {
|
||||
totalPendingAmount += boleto.amount;
|
||||
pendingCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
boletos,
|
||||
totalPendingAmount,
|
||||
pendingCount,
|
||||
};
|
||||
return {
|
||||
boletos,
|
||||
totalPendingAmount,
|
||||
pendingCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,148 +1,152 @@
|
||||
import { categorias, lancamentos, pagadores, contas } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import { and, desc, eq, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import { categorias, contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import type { CategoryType } from "@/lib/categorias/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { db } from "@/lib/db";
|
||||
import { mapLancamentosData } from "@/lib/lancamentos/page-helpers";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||
import { and, desc, eq, isNull, or, sql, ne } from "drizzle-orm";
|
||||
|
||||
type MappedLancamentos = ReturnType<typeof mapLancamentosData>;
|
||||
|
||||
export type CategoryDetailData = {
|
||||
category: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: CategoryType;
|
||||
};
|
||||
period: string;
|
||||
previousPeriod: string;
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
percentageChange: number | null;
|
||||
transactions: MappedLancamentos;
|
||||
category: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: CategoryType;
|
||||
};
|
||||
period: string;
|
||||
previousPeriod: string;
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
percentageChange: number | null;
|
||||
transactions: MappedLancamentos;
|
||||
};
|
||||
|
||||
const calculatePercentageChange = (
|
||||
current: number,
|
||||
previous: number
|
||||
current: number,
|
||||
previous: number,
|
||||
): number | null => {
|
||||
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
|
||||
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
|
||||
|
||||
if (Math.abs(previous) < EPSILON) {
|
||||
if (Math.abs(current) < EPSILON) return null;
|
||||
return current > 0 ? 100 : -100;
|
||||
}
|
||||
if (Math.abs(previous) < EPSILON) {
|
||||
if (Math.abs(current) < EPSILON) return null;
|
||||
return current > 0 ? 100 : -100;
|
||||
}
|
||||
|
||||
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||
|
||||
// Protege contra valores absurdos (retorna null se > 1 milhão %)
|
||||
return Number.isFinite(change) && Math.abs(change) < 1000000 ? change : null;
|
||||
// Protege contra valores absurdos (retorna null se > 1 milhão %)
|
||||
return Number.isFinite(change) && Math.abs(change) < 1000000 ? change : null;
|
||||
};
|
||||
|
||||
export async function fetchCategoryDetails(
|
||||
userId: string,
|
||||
categoryId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
categoryId: string,
|
||||
period: string,
|
||||
): Promise<CategoryDetailData | null> {
|
||||
const category = await db.query.categorias.findFirst({
|
||||
where: and(eq(categorias.userId, userId), eq(categorias.id, categoryId)),
|
||||
});
|
||||
const category = await db.query.categorias.findFirst({
|
||||
where: and(eq(categorias.userId, userId), eq(categorias.id, categoryId)),
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return null;
|
||||
}
|
||||
if (!category) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
const transactionType = category.type === "receita" ? "Receita" : "Despesa";
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
const transactionType = category.type === "receita" ? "Receita" : "Despesa";
|
||||
|
||||
const sanitizedNote = or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
);
|
||||
const sanitizedNote = or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
);
|
||||
|
||||
const currentRows = await db.query.lancamentos.findMany({
|
||||
where: and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.categoriaId, categoryId),
|
||||
eq(lancamentos.transactionType, transactionType),
|
||||
eq(lancamentos.period, period),
|
||||
sanitizedNote
|
||||
),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
orderBy: [desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)],
|
||||
});
|
||||
const currentRows = await db.query.lancamentos.findMany({
|
||||
where: and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.categoriaId, categoryId),
|
||||
eq(lancamentos.transactionType, transactionType),
|
||||
eq(lancamentos.period, period),
|
||||
sanitizedNote,
|
||||
),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
orderBy: [desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)],
|
||||
});
|
||||
|
||||
const filteredRows = currentRows.filter(
|
||||
(row) => {
|
||||
// Filtrar apenas pagadores admin
|
||||
if (row.pagador?.role !== PAGADOR_ROLE_ADMIN) return false;
|
||||
const filteredRows = currentRows.filter((row) => {
|
||||
// Filtrar apenas pagadores admin
|
||||
if (row.pagador?.role !== PAGADOR_ROLE_ADMIN) return false;
|
||||
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
if (row.note === INITIAL_BALANCE_NOTE && row.conta?.excludeInitialBalanceFromIncome) {
|
||||
return false;
|
||||
}
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
if (
|
||||
row.note === INITIAL_BALANCE_NOTE &&
|
||||
row.conta?.excludeInitialBalanceFromIncome
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
return true;
|
||||
});
|
||||
|
||||
const transactions = mapLancamentosData(filteredRows);
|
||||
const transactions = mapLancamentosData(filteredRows);
|
||||
|
||||
const currentTotal = transactions.reduce(
|
||||
(total, transaction) => total + Math.abs(toNumber(transaction.amount)),
|
||||
0
|
||||
);
|
||||
const currentTotal = transactions.reduce(
|
||||
(total, transaction) => total + Math.abs(toNumber(transaction.amount)),
|
||||
0,
|
||||
);
|
||||
|
||||
const [previousTotalRow] = await db
|
||||
.select({
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.categoriaId, categoryId),
|
||||
eq(lancamentos.transactionType, transactionType),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
sanitizedNote,
|
||||
eq(lancamentos.period, previousPeriod),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false)
|
||||
)
|
||||
)
|
||||
);
|
||||
const [previousTotalRow] = await db
|
||||
.select({
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.categoriaId, categoryId),
|
||||
eq(lancamentos.transactionType, transactionType),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
sanitizedNote,
|
||||
eq(lancamentos.period, previousPeriod),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const previousTotal = Math.abs(toNumber(previousTotalRow?.total ?? 0));
|
||||
const percentageChange = calculatePercentageChange(
|
||||
currentTotal,
|
||||
previousTotal
|
||||
);
|
||||
const previousTotal = Math.abs(toNumber(previousTotalRow?.total ?? 0));
|
||||
const percentageChange = calculatePercentageChange(
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
);
|
||||
|
||||
return {
|
||||
category: {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
icon: category.icon,
|
||||
type: category.type as CategoryType,
|
||||
},
|
||||
period,
|
||||
previousPeriod,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
percentageChange,
|
||||
transactions,
|
||||
};
|
||||
return {
|
||||
category: {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
icon: category.icon,
|
||||
type: category.type as CategoryType,
|
||||
},
|
||||
period,
|
||||
previousPeriod,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
percentageChange,
|
||||
transactions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,60 +1,60 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { categorias, lancamentos, pagadores } from "@/db/schema";
|
||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { addMonths, format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { categorias, lancamentos, pagadores } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
|
||||
export type CategoryOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: "receita" | "despesa";
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: "receita" | "despesa";
|
||||
};
|
||||
|
||||
export type CategoryHistoryItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
color: string;
|
||||
data: Record<string, number>;
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
color: string;
|
||||
data: Record<string, number>;
|
||||
};
|
||||
|
||||
export type CategoryHistoryData = {
|
||||
months: string[]; // ["NOV", "DEZ", "JAN", ...]
|
||||
categories: CategoryHistoryItem[];
|
||||
chartData: Array<{
|
||||
month: string;
|
||||
[categoryName: string]: number | string;
|
||||
}>;
|
||||
allCategories: CategoryOption[];
|
||||
months: string[]; // ["NOV", "DEZ", "JAN", ...]
|
||||
categories: CategoryHistoryItem[];
|
||||
chartData: Array<{
|
||||
month: string;
|
||||
[categoryName: string]: number | string;
|
||||
}>;
|
||||
allCategories: CategoryOption[];
|
||||
};
|
||||
|
||||
const CHART_COLORS = [
|
||||
"#ef4444", // red-500
|
||||
"#3b82f6", // blue-500
|
||||
"#10b981", // emerald-500
|
||||
"#f59e0b", // amber-500
|
||||
"#8b5cf6", // violet-500
|
||||
"#ef4444", // red-500
|
||||
"#3b82f6", // blue-500
|
||||
"#10b981", // emerald-500
|
||||
"#f59e0b", // amber-500
|
||||
"#8b5cf6", // violet-500
|
||||
];
|
||||
|
||||
export async function fetchAllCategories(
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<CategoryOption[]> {
|
||||
const result = await db
|
||||
.select({
|
||||
id: categorias.id,
|
||||
name: categorias.name,
|
||||
icon: categorias.icon,
|
||||
type: categorias.type,
|
||||
})
|
||||
.from(categorias)
|
||||
.where(eq(categorias.userId, userId))
|
||||
.orderBy(categorias.type, categorias.name);
|
||||
const result = await db
|
||||
.select({
|
||||
id: categorias.id,
|
||||
name: categorias.name,
|
||||
icon: categorias.icon,
|
||||
type: categorias.type,
|
||||
})
|
||||
.from(categorias)
|
||||
.where(eq(categorias.userId, userId))
|
||||
.orderBy(categorias.type, categorias.name);
|
||||
|
||||
return result as CategoryOption[];
|
||||
return result as CategoryOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,141 +62,141 @@ export async function fetchAllCategories(
|
||||
* Widget will allow user to select up to 5 to display
|
||||
*/
|
||||
export async function fetchCategoryHistory(
|
||||
userId: string,
|
||||
currentPeriod: string
|
||||
userId: string,
|
||||
currentPeriod: string,
|
||||
): Promise<CategoryHistoryData> {
|
||||
// Generate last 8 months, current month, and next month (10 total)
|
||||
const periods: string[] = [];
|
||||
const monthLabels: string[] = [];
|
||||
// Generate last 8 months, current month, and next month (10 total)
|
||||
const periods: string[] = [];
|
||||
const monthLabels: string[] = [];
|
||||
|
||||
const [year, month] = currentPeriod.split("-").map(Number);
|
||||
const currentDate = new Date(year, month - 1, 1);
|
||||
const [year, month] = currentPeriod.split("-").map(Number);
|
||||
const currentDate = new Date(year, month - 1, 1);
|
||||
|
||||
// Generate months from -8 to +1 (relative to current)
|
||||
for (let i = 8; i >= -1; i--) {
|
||||
const date = addMonths(currentDate, -i);
|
||||
const period = format(date, "yyyy-MM");
|
||||
const label = format(date, "MMM", { locale: ptBR }).toUpperCase();
|
||||
periods.push(period);
|
||||
monthLabels.push(label);
|
||||
}
|
||||
// Generate months from -8 to +1 (relative to current)
|
||||
for (let i = 8; i >= -1; i--) {
|
||||
const date = addMonths(currentDate, -i);
|
||||
const period = format(date, "yyyy-MM");
|
||||
const label = format(date, "MMM", { locale: ptBR }).toUpperCase();
|
||||
periods.push(period);
|
||||
monthLabels.push(label);
|
||||
}
|
||||
|
||||
// Fetch all categories for the selector
|
||||
const allCategories = await fetchAllCategories(userId);
|
||||
// Fetch all categories for the selector
|
||||
const allCategories = await fetchAllCategories(userId);
|
||||
|
||||
// Fetch monthly data for ALL categories with transactions
|
||||
const monthlyDataQuery = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
period: lancamentos.period,
|
||||
totalAmount: sql<string>`SUM(ABS(${lancamentos.amount}))`.as(
|
||||
"total_amount"
|
||||
),
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(categorias.userId, userId),
|
||||
inArray(lancamentos.period, periods),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
lancamentos.period
|
||||
);
|
||||
// Fetch monthly data for ALL categories with transactions
|
||||
const monthlyDataQuery = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
period: lancamentos.period,
|
||||
totalAmount: sql<string>`SUM(ABS(${lancamentos.amount}))`.as(
|
||||
"total_amount",
|
||||
),
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(categorias.userId, userId),
|
||||
inArray(lancamentos.period, periods),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
lancamentos.period,
|
||||
);
|
||||
|
||||
if (monthlyDataQuery.length === 0) {
|
||||
return {
|
||||
months: monthLabels,
|
||||
categories: [],
|
||||
chartData: monthLabels.map((month) => ({ month })),
|
||||
allCategories,
|
||||
};
|
||||
}
|
||||
if (monthlyDataQuery.length === 0) {
|
||||
return {
|
||||
months: monthLabels,
|
||||
categories: [],
|
||||
chartData: monthLabels.map((month) => ({ month })),
|
||||
allCategories,
|
||||
};
|
||||
}
|
||||
|
||||
// Get unique categories from query results
|
||||
const uniqueCategories = Array.from(
|
||||
new Map(
|
||||
monthlyDataQuery.map((row) => [
|
||||
row.categoryId,
|
||||
{
|
||||
id: row.categoryId,
|
||||
name: row.categoryName,
|
||||
icon: row.categoryIcon,
|
||||
},
|
||||
])
|
||||
).values()
|
||||
);
|
||||
// Get unique categories from query results
|
||||
const uniqueCategories = Array.from(
|
||||
new Map(
|
||||
monthlyDataQuery.map((row) => [
|
||||
row.categoryId,
|
||||
{
|
||||
id: row.categoryId,
|
||||
name: row.categoryName,
|
||||
icon: row.categoryIcon,
|
||||
},
|
||||
]),
|
||||
).values(),
|
||||
);
|
||||
|
||||
// Transform data into chart-ready format
|
||||
const categoriesMap = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
color: string;
|
||||
data: Record<string, number>;
|
||||
}
|
||||
>();
|
||||
// Transform data into chart-ready format
|
||||
const categoriesMap = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
color: string;
|
||||
data: Record<string, number>;
|
||||
}
|
||||
>();
|
||||
|
||||
// Initialize ALL categories with transactions with all months set to 0
|
||||
uniqueCategories.forEach((cat, index) => {
|
||||
const monthData: Record<string, number> = {};
|
||||
periods.forEach((period, periodIndex) => {
|
||||
monthData[monthLabels[periodIndex]] = 0;
|
||||
});
|
||||
// Initialize ALL categories with transactions with all months set to 0
|
||||
uniqueCategories.forEach((cat, index) => {
|
||||
const monthData: Record<string, number> = {};
|
||||
periods.forEach((_period, periodIndex) => {
|
||||
monthData[monthLabels[periodIndex]] = 0;
|
||||
});
|
||||
|
||||
categoriesMap.set(cat.id, {
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
icon: cat.icon,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
data: monthData,
|
||||
});
|
||||
});
|
||||
categoriesMap.set(cat.id, {
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
icon: cat.icon,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
data: monthData,
|
||||
});
|
||||
});
|
||||
|
||||
// Fill in actual values from monthly data
|
||||
monthlyDataQuery.forEach((row) => {
|
||||
const category = categoriesMap.get(row.categoryId);
|
||||
if (category) {
|
||||
const periodIndex = periods.indexOf(row.period);
|
||||
if (periodIndex !== -1) {
|
||||
const monthLabel = monthLabels[periodIndex];
|
||||
category.data[monthLabel] = toNumber(row.totalAmount);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Fill in actual values from monthly data
|
||||
monthlyDataQuery.forEach((row) => {
|
||||
const category = categoriesMap.get(row.categoryId);
|
||||
if (category) {
|
||||
const periodIndex = periods.indexOf(row.period);
|
||||
if (periodIndex !== -1) {
|
||||
const monthLabel = monthLabels[periodIndex];
|
||||
category.data[monthLabel] = toNumber(row.totalAmount);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to chart data format
|
||||
const chartData = monthLabels.map((month) => {
|
||||
const dataPoint: Record<string, number | string> = { month };
|
||||
// Convert to chart data format
|
||||
const chartData = monthLabels.map((month) => {
|
||||
const dataPoint: Record<string, number | string> = { month };
|
||||
|
||||
categoriesMap.forEach((category) => {
|
||||
dataPoint[category.name] = category.data[month];
|
||||
});
|
||||
categoriesMap.forEach((category) => {
|
||||
dataPoint[category.name] = category.data[month];
|
||||
});
|
||||
|
||||
return dataPoint;
|
||||
});
|
||||
return dataPoint;
|
||||
});
|
||||
|
||||
return {
|
||||
months: monthLabels,
|
||||
categories: Array.from(categoriesMap.values()),
|
||||
chartData,
|
||||
allCategories,
|
||||
};
|
||||
return {
|
||||
months: monthLabels,
|
||||
categories: Array.from(categoriesMap.values()),
|
||||
chartData,
|
||||
allCategories,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,163 +1,168 @@
|
||||
import { and, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { categorias, lancamentos, orcamentos, pagadores } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||
import { and, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
|
||||
export type CategoryExpenseItem = {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
categoryIcon: string | null;
|
||||
currentAmount: number;
|
||||
previousAmount: number;
|
||||
percentageChange: number | null;
|
||||
percentageOfTotal: number;
|
||||
budgetAmount: number | null;
|
||||
budgetUsedPercentage: number | null;
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
categoryIcon: string | null;
|
||||
currentAmount: number;
|
||||
previousAmount: number;
|
||||
percentageChange: number | null;
|
||||
percentageOfTotal: number;
|
||||
budgetAmount: number | null;
|
||||
budgetUsedPercentage: number | null;
|
||||
};
|
||||
|
||||
export type ExpensesByCategoryData = {
|
||||
categories: CategoryExpenseItem[];
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
categories: CategoryExpenseItem[];
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
};
|
||||
|
||||
const calculatePercentageChange = (
|
||||
current: number,
|
||||
previous: number
|
||||
current: number,
|
||||
previous: number,
|
||||
): number | null => {
|
||||
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
|
||||
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
|
||||
|
||||
if (Math.abs(previous) < EPSILON) {
|
||||
if (Math.abs(current) < EPSILON) return null;
|
||||
return current > 0 ? 100 : -100;
|
||||
}
|
||||
if (Math.abs(previous) < EPSILON) {
|
||||
if (Math.abs(current) < EPSILON) return null;
|
||||
return current > 0 ? 100 : -100;
|
||||
}
|
||||
|
||||
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||
|
||||
// Protege contra valores absurdos (retorna null se > 1 milhão %)
|
||||
return Number.isFinite(change) && Math.abs(change) < 1000000 ? change : null;
|
||||
// Protege contra valores absurdos (retorna null se > 1 milhão %)
|
||||
return Number.isFinite(change) && Math.abs(change) < 1000000 ? change : null;
|
||||
};
|
||||
|
||||
export async function fetchExpensesByCategory(
|
||||
userId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<ExpensesByCategoryData> {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
|
||||
// Busca despesas do período atual agrupadas por categoria
|
||||
const currentPeriodRows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
budgetAmount: orcamentos.amount,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(
|
||||
orcamentos,
|
||||
and(
|
||||
eq(orcamentos.categoriaId, categorias.id),
|
||||
eq(orcamentos.period, period),
|
||||
eq(orcamentos.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "despesa"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(categorias.id, categorias.name, categorias.icon, orcamentos.amount);
|
||||
// Busca despesas do período atual agrupadas por categoria
|
||||
const currentPeriodRows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
budgetAmount: orcamentos.amount,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(
|
||||
orcamentos,
|
||||
and(
|
||||
eq(orcamentos.categoriaId, categorias.id),
|
||||
eq(orcamentos.period, period),
|
||||
eq(orcamentos.userId, userId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "despesa"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
orcamentos.amount,
|
||||
);
|
||||
|
||||
// Busca despesas do período anterior agrupadas por categoria
|
||||
const previousPeriodRows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, previousPeriod),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "despesa"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(categorias.id);
|
||||
// Busca despesas do período anterior agrupadas por categoria
|
||||
const previousPeriodRows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, previousPeriod),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "despesa"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(categorias.id);
|
||||
|
||||
// Cria um mapa do período anterior para busca rápida
|
||||
const previousMap = new Map<string, number>();
|
||||
let previousTotal = 0;
|
||||
// Cria um mapa do período anterior para busca rápida
|
||||
const previousMap = new Map<string, number>();
|
||||
let previousTotal = 0;
|
||||
|
||||
for (const row of previousPeriodRows) {
|
||||
const amount = Math.abs(toNumber(row.total));
|
||||
previousMap.set(row.categoryId, amount);
|
||||
previousTotal += amount;
|
||||
}
|
||||
for (const row of previousPeriodRows) {
|
||||
const amount = Math.abs(toNumber(row.total));
|
||||
previousMap.set(row.categoryId, amount);
|
||||
previousTotal += amount;
|
||||
}
|
||||
|
||||
// Calcula o total do período atual
|
||||
let currentTotal = 0;
|
||||
for (const row of currentPeriodRows) {
|
||||
currentTotal += Math.abs(toNumber(row.total));
|
||||
}
|
||||
// Calcula o total do período atual
|
||||
let currentTotal = 0;
|
||||
for (const row of currentPeriodRows) {
|
||||
currentTotal += Math.abs(toNumber(row.total));
|
||||
}
|
||||
|
||||
// Monta os dados de cada categoria
|
||||
const categories: CategoryExpenseItem[] = currentPeriodRows.map((row) => {
|
||||
const currentAmount = Math.abs(toNumber(row.total));
|
||||
const previousAmount = previousMap.get(row.categoryId) ?? 0;
|
||||
const percentageChange = calculatePercentageChange(
|
||||
currentAmount,
|
||||
previousAmount
|
||||
);
|
||||
const percentageOfTotal =
|
||||
currentTotal > 0 ? (currentAmount / currentTotal) * 100 : 0;
|
||||
// Monta os dados de cada categoria
|
||||
const categories: CategoryExpenseItem[] = currentPeriodRows.map((row) => {
|
||||
const currentAmount = Math.abs(toNumber(row.total));
|
||||
const previousAmount = previousMap.get(row.categoryId) ?? 0;
|
||||
const percentageChange = calculatePercentageChange(
|
||||
currentAmount,
|
||||
previousAmount,
|
||||
);
|
||||
const percentageOfTotal =
|
||||
currentTotal > 0 ? (currentAmount / currentTotal) * 100 : 0;
|
||||
|
||||
const budgetAmount = row.budgetAmount ? toNumber(row.budgetAmount) : null;
|
||||
const budgetUsedPercentage =
|
||||
budgetAmount && budgetAmount > 0
|
||||
? (currentAmount / budgetAmount) * 100
|
||||
: null;
|
||||
const budgetAmount = row.budgetAmount ? toNumber(row.budgetAmount) : null;
|
||||
const budgetUsedPercentage =
|
||||
budgetAmount && budgetAmount > 0
|
||||
? (currentAmount / budgetAmount) * 100
|
||||
: null;
|
||||
|
||||
return {
|
||||
categoryId: row.categoryId,
|
||||
categoryName: row.categoryName,
|
||||
categoryIcon: row.categoryIcon,
|
||||
currentAmount,
|
||||
previousAmount,
|
||||
percentageChange,
|
||||
percentageOfTotal,
|
||||
budgetAmount,
|
||||
budgetUsedPercentage,
|
||||
};
|
||||
});
|
||||
return {
|
||||
categoryId: row.categoryId,
|
||||
categoryName: row.categoryName,
|
||||
categoryIcon: row.categoryIcon,
|
||||
currentAmount,
|
||||
previousAmount,
|
||||
percentageChange,
|
||||
percentageOfTotal,
|
||||
budgetAmount,
|
||||
budgetUsedPercentage,
|
||||
};
|
||||
});
|
||||
|
||||
// Ordena por valor atual (maior para menor)
|
||||
categories.sort((a, b) => b.currentAmount - a.currentAmount);
|
||||
// Ordena por valor atual (maior para menor)
|
||||
categories.sort((a, b) => b.currentAmount - a.currentAmount);
|
||||
|
||||
return {
|
||||
categories,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
};
|
||||
return {
|
||||
categories,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,161 +1,177 @@
|
||||
import { categorias, lancamentos, orcamentos, pagadores, contas } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
categorias,
|
||||
contas,
|
||||
lancamentos,
|
||||
orcamentos,
|
||||
pagadores,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||
import { calculatePercentageChange } from "@/lib/utils/math";
|
||||
import { safeToNumber } from "@/lib/utils/number";
|
||||
import { and, eq, isNull, or, sql, ne } from "drizzle-orm";
|
||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||
|
||||
export type CategoryIncomeItem = {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
categoryIcon: string | null;
|
||||
currentAmount: number;
|
||||
previousAmount: number;
|
||||
percentageChange: number | null;
|
||||
percentageOfTotal: number;
|
||||
budgetAmount: number | null;
|
||||
budgetUsedPercentage: number | null;
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
categoryIcon: string | null;
|
||||
currentAmount: number;
|
||||
previousAmount: number;
|
||||
percentageChange: number | null;
|
||||
percentageOfTotal: number;
|
||||
budgetAmount: number | null;
|
||||
budgetUsedPercentage: number | null;
|
||||
};
|
||||
|
||||
export type IncomeByCategoryData = {
|
||||
categories: CategoryIncomeItem[];
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
categories: CategoryIncomeItem[];
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
};
|
||||
|
||||
export async function fetchIncomeByCategory(
|
||||
userId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<IncomeByCategoryData> {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
|
||||
// Busca receitas do período atual agrupadas por categoria
|
||||
const currentPeriodRows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
budgetAmount: orcamentos.amount,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.leftJoin(
|
||||
orcamentos,
|
||||
and(
|
||||
eq(orcamentos.categoriaId, categorias.id),
|
||||
eq(orcamentos.period, period),
|
||||
eq(orcamentos.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Receita"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "receita"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false)
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(categorias.id, categorias.name, categorias.icon, orcamentos.amount);
|
||||
// Busca receitas do período atual agrupadas por categoria
|
||||
const currentPeriodRows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
budgetAmount: orcamentos.amount,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.leftJoin(
|
||||
orcamentos,
|
||||
and(
|
||||
eq(orcamentos.categoriaId, categorias.id),
|
||||
eq(orcamentos.period, period),
|
||||
eq(orcamentos.userId, userId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Receita"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "receita"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false),
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
orcamentos.amount,
|
||||
);
|
||||
|
||||
// Busca receitas do período anterior agrupadas por categoria
|
||||
const previousPeriodRows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, previousPeriod),
|
||||
eq(lancamentos.transactionType, "Receita"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "receita"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false)
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(categorias.id);
|
||||
// Busca receitas do período anterior agrupadas por categoria
|
||||
const previousPeriodRows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, previousPeriod),
|
||||
eq(lancamentos.transactionType, "Receita"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "receita"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false),
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(categorias.id);
|
||||
|
||||
// Cria um mapa do período anterior para busca rápida
|
||||
const previousMap = new Map<string, number>();
|
||||
let previousTotal = 0;
|
||||
// Cria um mapa do período anterior para busca rápida
|
||||
const previousMap = new Map<string, number>();
|
||||
let previousTotal = 0;
|
||||
|
||||
for (const row of previousPeriodRows) {
|
||||
const amount = Math.abs(safeToNumber(row.total));
|
||||
previousMap.set(row.categoryId, amount);
|
||||
previousTotal += amount;
|
||||
}
|
||||
for (const row of previousPeriodRows) {
|
||||
const amount = Math.abs(safeToNumber(row.total));
|
||||
previousMap.set(row.categoryId, amount);
|
||||
previousTotal += amount;
|
||||
}
|
||||
|
||||
// Calcula o total do período atual
|
||||
let currentTotal = 0;
|
||||
for (const row of currentPeriodRows) {
|
||||
currentTotal += Math.abs(safeToNumber(row.total));
|
||||
}
|
||||
// Calcula o total do período atual
|
||||
let currentTotal = 0;
|
||||
for (const row of currentPeriodRows) {
|
||||
currentTotal += Math.abs(safeToNumber(row.total));
|
||||
}
|
||||
|
||||
// Monta os dados de cada categoria
|
||||
const categories: CategoryIncomeItem[] = currentPeriodRows.map((row) => {
|
||||
const currentAmount = Math.abs(safeToNumber(row.total));
|
||||
const previousAmount = previousMap.get(row.categoryId) ?? 0;
|
||||
const percentageChange = calculatePercentageChange(
|
||||
currentAmount,
|
||||
previousAmount
|
||||
);
|
||||
const percentageOfTotal =
|
||||
currentTotal > 0 ? (currentAmount / currentTotal) * 100 : 0;
|
||||
// Monta os dados de cada categoria
|
||||
const categories: CategoryIncomeItem[] = currentPeriodRows.map((row) => {
|
||||
const currentAmount = Math.abs(safeToNumber(row.total));
|
||||
const previousAmount = previousMap.get(row.categoryId) ?? 0;
|
||||
const percentageChange = calculatePercentageChange(
|
||||
currentAmount,
|
||||
previousAmount,
|
||||
);
|
||||
const percentageOfTotal =
|
||||
currentTotal > 0 ? (currentAmount / currentTotal) * 100 : 0;
|
||||
|
||||
const budgetAmount = row.budgetAmount ? safeToNumber(row.budgetAmount) : null;
|
||||
const budgetUsedPercentage =
|
||||
budgetAmount && budgetAmount > 0
|
||||
? (currentAmount / budgetAmount) * 100
|
||||
: null;
|
||||
const budgetAmount = row.budgetAmount
|
||||
? safeToNumber(row.budgetAmount)
|
||||
: null;
|
||||
const budgetUsedPercentage =
|
||||
budgetAmount && budgetAmount > 0
|
||||
? (currentAmount / budgetAmount) * 100
|
||||
: null;
|
||||
|
||||
return {
|
||||
categoryId: row.categoryId,
|
||||
categoryName: row.categoryName,
|
||||
categoryIcon: row.categoryIcon,
|
||||
currentAmount,
|
||||
previousAmount,
|
||||
percentageChange,
|
||||
percentageOfTotal,
|
||||
budgetAmount,
|
||||
budgetUsedPercentage,
|
||||
};
|
||||
});
|
||||
return {
|
||||
categoryId: row.categoryId,
|
||||
categoryName: row.categoryName,
|
||||
categoryIcon: row.categoryIcon,
|
||||
currentAmount,
|
||||
previousAmount,
|
||||
percentageChange,
|
||||
percentageOfTotal,
|
||||
budgetAmount,
|
||||
budgetUsedPercentage,
|
||||
};
|
||||
});
|
||||
|
||||
// Ordena por valor atual (maior para menor)
|
||||
categories.sort((a, b) => b.currentAmount - a.currentAmount);
|
||||
// Ordena por valor atual (maior para menor)
|
||||
categories.sort((a, b) => b.currentAmount - a.currentAmount);
|
||||
|
||||
return {
|
||||
categories,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
};
|
||||
return {
|
||||
categories,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Common utilities and helpers for dashboard queries
|
||||
*/
|
||||
|
||||
import { safeToNumber } from "@/lib/utils/number";
|
||||
import { calculatePercentageChange } from "@/lib/utils/math";
|
||||
import { safeToNumber } from "@/lib/utils/number";
|
||||
|
||||
export { safeToNumber, calculatePercentageChange };
|
||||
|
||||
|
||||
@@ -1,179 +1,179 @@
|
||||
import { and, eq, isNotNull, isNull, or, sql } from "drizzle-orm";
|
||||
import { cartoes, lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { and, eq, isNotNull, isNull, or, sql } from "drizzle-orm";
|
||||
|
||||
// Calcula a data de vencimento baseada no período e dia de vencimento do cartão
|
||||
function calculateDueDate(period: string, dueDay: string | null): Date | null {
|
||||
if (!dueDay) return null;
|
||||
if (!dueDay) return null;
|
||||
|
||||
try {
|
||||
const [year, month] = period.split("-");
|
||||
if (!year || !month) return null;
|
||||
try {
|
||||
const [year, month] = period.split("-");
|
||||
if (!year || !month) return null;
|
||||
|
||||
const day = parseInt(dueDay, 10);
|
||||
if (isNaN(day)) return null;
|
||||
const day = parseInt(dueDay, 10);
|
||||
if (Number.isNaN(day)) return null;
|
||||
|
||||
// Criar data ao meio-dia para evitar problemas de timezone
|
||||
return new Date(parseInt(year), parseInt(month) - 1, day, 12, 0, 0);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Criar data ao meio-dia para evitar problemas de timezone
|
||||
return new Date(parseInt(year, 10), parseInt(month, 10) - 1, day, 12, 0, 0);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type InstallmentDetail = {
|
||||
id: string;
|
||||
currentInstallment: number;
|
||||
amount: number;
|
||||
dueDate: Date | null;
|
||||
period: string;
|
||||
isAnticipated: boolean;
|
||||
purchaseDate: Date;
|
||||
isSettled: boolean;
|
||||
id: string;
|
||||
currentInstallment: number;
|
||||
amount: number;
|
||||
dueDate: Date | null;
|
||||
period: string;
|
||||
isAnticipated: boolean;
|
||||
purchaseDate: Date;
|
||||
isSettled: boolean;
|
||||
};
|
||||
|
||||
export type InstallmentGroup = {
|
||||
seriesId: string;
|
||||
name: string;
|
||||
paymentMethod: string;
|
||||
cartaoId: string | null;
|
||||
cartaoName: string | null;
|
||||
cartaoDueDay: string | null;
|
||||
cartaoLogo: string | null;
|
||||
totalInstallments: number;
|
||||
paidInstallments: number;
|
||||
pendingInstallments: InstallmentDetail[];
|
||||
totalPendingAmount: number;
|
||||
firstPurchaseDate: Date;
|
||||
seriesId: string;
|
||||
name: string;
|
||||
paymentMethod: string;
|
||||
cartaoId: string | null;
|
||||
cartaoName: string | null;
|
||||
cartaoDueDay: string | null;
|
||||
cartaoLogo: string | null;
|
||||
totalInstallments: number;
|
||||
paidInstallments: number;
|
||||
pendingInstallments: InstallmentDetail[];
|
||||
totalPendingAmount: number;
|
||||
firstPurchaseDate: Date;
|
||||
};
|
||||
|
||||
export type InstallmentAnalysisData = {
|
||||
installmentGroups: InstallmentGroup[];
|
||||
totalPendingInstallments: number;
|
||||
installmentGroups: InstallmentGroup[];
|
||||
totalPendingInstallments: number;
|
||||
};
|
||||
|
||||
export async function fetchInstallmentAnalysis(
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<InstallmentAnalysisData> {
|
||||
// 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,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.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),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(lancamentos.purchaseDate, lancamentos.currentInstallment);
|
||||
// 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,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.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),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(lancamentos.purchaseDate, lancamentos.currentInstallment);
|
||||
|
||||
// Agrupar por seriesId
|
||||
const seriesMap = new Map<string, InstallmentGroup>();
|
||||
// Agrupar por seriesId
|
||||
const seriesMap = new Map<string, InstallmentGroup>();
|
||||
|
||||
for (const row of installmentRows) {
|
||||
if (!row.seriesId) continue;
|
||||
for (const row of installmentRows) {
|
||||
if (!row.seriesId) continue;
|
||||
|
||||
const amount = Math.abs(toNumber(row.amount));
|
||||
const amount = Math.abs(toNumber(row.amount));
|
||||
|
||||
// Calcular vencimento correto baseado no período e dia de vencimento do cartão
|
||||
const calculatedDueDate = row.cartaoDueDay
|
||||
? calculateDueDate(row.period, row.cartaoDueDay)
|
||||
: row.dueDate;
|
||||
// Calcular vencimento correto baseado no período e dia de vencimento do cartão
|
||||
const calculatedDueDate = row.cartaoDueDay
|
||||
? calculateDueDate(row.period, row.cartaoDueDay)
|
||||
: row.dueDate;
|
||||
|
||||
const installmentDetail: InstallmentDetail = {
|
||||
id: row.id,
|
||||
currentInstallment: row.currentInstallment ?? 1,
|
||||
amount,
|
||||
dueDate: calculatedDueDate,
|
||||
period: row.period,
|
||||
isAnticipated: row.isAnticipated ?? false,
|
||||
purchaseDate: row.purchaseDate,
|
||||
isSettled: row.isSettled ?? false,
|
||||
};
|
||||
const installmentDetail: InstallmentDetail = {
|
||||
id: row.id,
|
||||
currentInstallment: row.currentInstallment ?? 1,
|
||||
amount,
|
||||
dueDate: calculatedDueDate,
|
||||
period: row.period,
|
||||
isAnticipated: row.isAnticipated ?? false,
|
||||
purchaseDate: row.purchaseDate,
|
||||
isSettled: row.isSettled ?? false,
|
||||
};
|
||||
|
||||
if (seriesMap.has(row.seriesId)) {
|
||||
const group = seriesMap.get(row.seriesId)!;
|
||||
group.pendingInstallments.push(installmentDetail);
|
||||
group.totalPendingAmount += amount;
|
||||
} else {
|
||||
seriesMap.set(row.seriesId, {
|
||||
seriesId: row.seriesId,
|
||||
name: row.name,
|
||||
paymentMethod: row.paymentMethod,
|
||||
cartaoId: row.cartaoId,
|
||||
cartaoName: row.cartaoName,
|
||||
cartaoDueDay: row.cartaoDueDay,
|
||||
cartaoLogo: row.cartaoLogo,
|
||||
totalInstallments: row.installmentCount ?? 0,
|
||||
paidInstallments: 0,
|
||||
pendingInstallments: [installmentDetail],
|
||||
totalPendingAmount: amount,
|
||||
firstPurchaseDate: row.purchaseDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (seriesMap.has(row.seriesId)) {
|
||||
const group = seriesMap.get(row.seriesId)!;
|
||||
group.pendingInstallments.push(installmentDetail);
|
||||
group.totalPendingAmount += amount;
|
||||
} else {
|
||||
seriesMap.set(row.seriesId, {
|
||||
seriesId: row.seriesId,
|
||||
name: row.name,
|
||||
paymentMethod: row.paymentMethod,
|
||||
cartaoId: row.cartaoId,
|
||||
cartaoName: row.cartaoName,
|
||||
cartaoDueDay: row.cartaoDueDay,
|
||||
cartaoLogo: row.cartaoLogo,
|
||||
totalInstallments: row.installmentCount ?? 0,
|
||||
paidInstallments: 0,
|
||||
pendingInstallments: [installmentDetail],
|
||||
totalPendingAmount: amount,
|
||||
firstPurchaseDate: row.purchaseDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calcular quantas parcelas já foram pagas para cada grupo
|
||||
const installmentGroups = Array.from(seriesMap.values())
|
||||
.map((group) => {
|
||||
// Contar quantas parcelas estão marcadas como pagas (settled)
|
||||
const paidCount = group.pendingInstallments.filter(
|
||||
(i) => i.isSettled
|
||||
).length;
|
||||
group.paidInstallments = paidCount;
|
||||
return group;
|
||||
})
|
||||
// Filtrar apenas séries que têm pelo menos uma parcela em aberto (não paga)
|
||||
.filter((group) => {
|
||||
const hasUnpaidInstallments = group.pendingInstallments.some(
|
||||
(i) => !i.isSettled
|
||||
);
|
||||
return hasUnpaidInstallments;
|
||||
});
|
||||
// Calcular quantas parcelas já foram pagas para cada grupo
|
||||
const installmentGroups = Array.from(seriesMap.values())
|
||||
.map((group) => {
|
||||
// Contar quantas parcelas estão marcadas como pagas (settled)
|
||||
const paidCount = group.pendingInstallments.filter(
|
||||
(i) => i.isSettled,
|
||||
).length;
|
||||
group.paidInstallments = paidCount;
|
||||
return group;
|
||||
})
|
||||
// Filtrar apenas séries que têm pelo menos uma parcela em aberto (não paga)
|
||||
.filter((group) => {
|
||||
const hasUnpaidInstallments = group.pendingInstallments.some(
|
||||
(i) => !i.isSettled,
|
||||
);
|
||||
return hasUnpaidInstallments;
|
||||
});
|
||||
|
||||
// Calcular totais
|
||||
const totalPendingInstallments = installmentGroups.reduce(
|
||||
(sum, group) => sum + group.totalPendingAmount,
|
||||
0
|
||||
);
|
||||
// Calcular totais
|
||||
const totalPendingInstallments = installmentGroups.reduce(
|
||||
(sum, group) => sum + group.totalPendingAmount,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
installmentGroups,
|
||||
totalPendingInstallments,
|
||||
};
|
||||
return {
|
||||
installmentGroups,
|
||||
totalPendingInstallments,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,96 +1,96 @@
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
|
||||
export type InstallmentExpense = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
paymentMethod: string;
|
||||
currentInstallment: number | null;
|
||||
installmentCount: number | null;
|
||||
dueDate: Date | null;
|
||||
purchaseDate: Date;
|
||||
period: string;
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
paymentMethod: string;
|
||||
currentInstallment: number | null;
|
||||
installmentCount: number | null;
|
||||
dueDate: Date | null;
|
||||
purchaseDate: Date;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export type InstallmentExpensesData = {
|
||||
expenses: InstallmentExpense[];
|
||||
expenses: InstallmentExpense[];
|
||||
};
|
||||
|
||||
export async function fetchInstallmentExpenses(
|
||||
userId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<InstallmentExpensesData> {
|
||||
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,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(lancamentos.condition, "Parcelado"),
|
||||
eq(lancamentos.isAnticipated, false),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
|
||||
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,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(lancamentos.condition, "Parcelado"),
|
||||
eq(lancamentos.isAnticipated, false),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
|
||||
|
||||
const expenses = rows
|
||||
.map(
|
||||
(row): InstallmentExpense => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
paymentMethod: row.paymentMethod,
|
||||
currentInstallment: row.currentInstallment,
|
||||
installmentCount: row.installmentCount,
|
||||
dueDate: row.dueDate ?? null,
|
||||
purchaseDate: row.purchaseDate,
|
||||
period: row.period,
|
||||
})
|
||||
)
|
||||
.sort((a, b) => {
|
||||
// Calcula parcelas restantes para cada item
|
||||
const remainingA =
|
||||
a.installmentCount && a.currentInstallment
|
||||
? a.installmentCount - a.currentInstallment
|
||||
: 0;
|
||||
const remainingB =
|
||||
b.installmentCount && b.currentInstallment
|
||||
? b.installmentCount - b.currentInstallment
|
||||
: 0;
|
||||
const expenses = rows
|
||||
.map(
|
||||
(row): InstallmentExpense => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
paymentMethod: row.paymentMethod,
|
||||
currentInstallment: row.currentInstallment,
|
||||
installmentCount: row.installmentCount,
|
||||
dueDate: row.dueDate ?? null,
|
||||
purchaseDate: row.purchaseDate,
|
||||
period: row.period,
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
// Calcula parcelas restantes para cada item
|
||||
const remainingA =
|
||||
a.installmentCount && a.currentInstallment
|
||||
? a.installmentCount - a.currentInstallment
|
||||
: 0;
|
||||
const remainingB =
|
||||
b.installmentCount && b.currentInstallment
|
||||
? b.installmentCount - b.currentInstallment
|
||||
: 0;
|
||||
|
||||
// Ordena do menor número de parcelas restantes para o maior
|
||||
return remainingA - remainingB;
|
||||
});
|
||||
// Ordena do menor número de parcelas restantes para o maior
|
||||
return remainingA - remainingB;
|
||||
});
|
||||
|
||||
return {
|
||||
expenses,
|
||||
};
|
||||
return {
|
||||
expenses,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,66 +1,68 @@
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
|
||||
export type RecurringExpense = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
paymentMethod: string;
|
||||
recurrenceCount: number | null;
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
paymentMethod: string;
|
||||
recurrenceCount: number | null;
|
||||
};
|
||||
|
||||
export type RecurringExpensesData = {
|
||||
expenses: RecurringExpense[];
|
||||
expenses: RecurringExpense[];
|
||||
};
|
||||
|
||||
export async function fetchRecurringExpenses(
|
||||
userId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<RecurringExpensesData> {
|
||||
const results = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
paymentMethod: lancamentos.paymentMethod,
|
||||
recurrenceCount: lancamentos.recurrenceCount,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(lancamentos.condition, "Recorrente"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
|
||||
const results = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
paymentMethod: lancamentos.paymentMethod,
|
||||
recurrenceCount: lancamentos.recurrenceCount,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(lancamentos.condition, "Recorrente"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
|
||||
|
||||
const expenses = results.map((row): RecurringExpense => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
paymentMethod: row.paymentMethod,
|
||||
recurrenceCount: row.recurrenceCount,
|
||||
}));
|
||||
const expenses = results.map(
|
||||
(row): RecurringExpense => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
paymentMethod: row.paymentMethod,
|
||||
recurrenceCount: row.recurrenceCount,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
expenses,
|
||||
};
|
||||
return {
|
||||
expenses,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
import { and, asc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { cartoes, contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { and, asc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
|
||||
export type TopExpense = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
purchaseDate: Date;
|
||||
paymentMethod: string;
|
||||
logo?: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
purchaseDate: Date;
|
||||
paymentMethod: string;
|
||||
logo?: string | null;
|
||||
};
|
||||
|
||||
export type TopExpensesData = {
|
||||
expenses: TopExpense[];
|
||||
expenses: TopExpense[];
|
||||
};
|
||||
|
||||
export async function fetchTopExpenses(
|
||||
userId: string,
|
||||
period: string,
|
||||
cardOnly: boolean = false
|
||||
userId: string,
|
||||
period: string,
|
||||
cardOnly: boolean = false,
|
||||
): Promise<TopExpensesData> {
|
||||
const conditions = [
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
),
|
||||
];
|
||||
const conditions = [
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
// Se cardOnly for true, filtra apenas pagamentos com cartão
|
||||
if (cardOnly) {
|
||||
conditions.push(eq(lancamentos.paymentMethod, "Cartão de Crédito"));
|
||||
}
|
||||
// Se cardOnly for true, filtra apenas pagamentos com cartão
|
||||
if (cardOnly) {
|
||||
conditions.push(eq(lancamentos.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,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(lancamentos.amount))
|
||||
.limit(10);
|
||||
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,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(lancamentos.amount))
|
||||
.limit(10);
|
||||
|
||||
const expenses = results.map(
|
||||
(row): TopExpense => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
purchaseDate: row.purchaseDate,
|
||||
paymentMethod: row.paymentMethod,
|
||||
logo: row.cardLogo ?? row.accountLogo ?? null,
|
||||
})
|
||||
);
|
||||
const expenses = results.map(
|
||||
(row): TopExpense => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
purchaseDate: row.purchaseDate,
|
||||
paymentMethod: row.paymentMethod,
|
||||
logo: row.cardLogo ?? row.accountLogo ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
expenses,
|
||||
};
|
||||
return {
|
||||
expenses,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,66 +17,66 @@ import { fetchRecentTransactions } from "./recent-transactions";
|
||||
import { fetchTopEstablishments } from "./top-establishments";
|
||||
|
||||
export async function fetchDashboardData(userId: string, period: string) {
|
||||
const [
|
||||
metrics,
|
||||
accountsSnapshot,
|
||||
invoicesSnapshot,
|
||||
boletosSnapshot,
|
||||
notificationsSnapshot,
|
||||
paymentStatusData,
|
||||
incomeExpenseBalanceData,
|
||||
recentTransactionsData,
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
recurringExpensesData,
|
||||
installmentExpensesData,
|
||||
topEstablishmentsData,
|
||||
topExpensesAll,
|
||||
topExpensesCardOnly,
|
||||
purchasesByCategoryData,
|
||||
incomeByCategoryData,
|
||||
expensesByCategoryData,
|
||||
] = await Promise.all([
|
||||
fetchDashboardCardMetrics(userId, period),
|
||||
fetchDashboardAccounts(userId),
|
||||
fetchDashboardInvoices(userId, period),
|
||||
fetchDashboardBoletos(userId, period),
|
||||
fetchDashboardNotifications(userId, period),
|
||||
fetchPaymentStatus(userId, period),
|
||||
fetchIncomeExpenseBalance(userId, period),
|
||||
fetchRecentTransactions(userId, period),
|
||||
fetchPaymentConditions(userId, period),
|
||||
fetchPaymentMethods(userId, period),
|
||||
fetchRecurringExpenses(userId, period),
|
||||
fetchInstallmentExpenses(userId, period),
|
||||
fetchTopEstablishments(userId, period),
|
||||
fetchTopExpenses(userId, period, false),
|
||||
fetchTopExpenses(userId, period, true),
|
||||
fetchPurchasesByCategory(userId, period),
|
||||
fetchIncomeByCategory(userId, period),
|
||||
fetchExpensesByCategory(userId, period),
|
||||
]);
|
||||
const [
|
||||
metrics,
|
||||
accountsSnapshot,
|
||||
invoicesSnapshot,
|
||||
boletosSnapshot,
|
||||
notificationsSnapshot,
|
||||
paymentStatusData,
|
||||
incomeExpenseBalanceData,
|
||||
recentTransactionsData,
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
recurringExpensesData,
|
||||
installmentExpensesData,
|
||||
topEstablishmentsData,
|
||||
topExpensesAll,
|
||||
topExpensesCardOnly,
|
||||
purchasesByCategoryData,
|
||||
incomeByCategoryData,
|
||||
expensesByCategoryData,
|
||||
] = await Promise.all([
|
||||
fetchDashboardCardMetrics(userId, period),
|
||||
fetchDashboardAccounts(userId),
|
||||
fetchDashboardInvoices(userId, period),
|
||||
fetchDashboardBoletos(userId, period),
|
||||
fetchDashboardNotifications(userId, period),
|
||||
fetchPaymentStatus(userId, period),
|
||||
fetchIncomeExpenseBalance(userId, period),
|
||||
fetchRecentTransactions(userId, period),
|
||||
fetchPaymentConditions(userId, period),
|
||||
fetchPaymentMethods(userId, period),
|
||||
fetchRecurringExpenses(userId, period),
|
||||
fetchInstallmentExpenses(userId, period),
|
||||
fetchTopEstablishments(userId, period),
|
||||
fetchTopExpenses(userId, period, false),
|
||||
fetchTopExpenses(userId, period, true),
|
||||
fetchPurchasesByCategory(userId, period),
|
||||
fetchIncomeByCategory(userId, period),
|
||||
fetchExpensesByCategory(userId, period),
|
||||
]);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
accountsSnapshot,
|
||||
invoicesSnapshot,
|
||||
boletosSnapshot,
|
||||
notificationsSnapshot,
|
||||
paymentStatusData,
|
||||
incomeExpenseBalanceData,
|
||||
recentTransactionsData,
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
recurringExpensesData,
|
||||
installmentExpensesData,
|
||||
topEstablishmentsData,
|
||||
topExpensesAll,
|
||||
topExpensesCardOnly,
|
||||
purchasesByCategoryData,
|
||||
incomeByCategoryData,
|
||||
expensesByCategoryData,
|
||||
};
|
||||
return {
|
||||
metrics,
|
||||
accountsSnapshot,
|
||||
invoicesSnapshot,
|
||||
boletosSnapshot,
|
||||
notificationsSnapshot,
|
||||
paymentStatusData,
|
||||
incomeExpenseBalanceData,
|
||||
recentTransactionsData,
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
recurringExpensesData,
|
||||
installmentExpensesData,
|
||||
topEstablishmentsData,
|
||||
topExpensesAll,
|
||||
topExpensesCardOnly,
|
||||
purchasesByCategoryData,
|
||||
incomeByCategoryData,
|
||||
expensesByCategoryData,
|
||||
};
|
||||
}
|
||||
|
||||
export type DashboardData = Awaited<ReturnType<typeof fetchDashboardData>>;
|
||||
|
||||
@@ -1,145 +1,148 @@
|
||||
import { lancamentos, pagadores, contas } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import { contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { and, eq, sql, or, isNull, ne } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export type MonthData = {
|
||||
month: string;
|
||||
monthLabel: string;
|
||||
income: number;
|
||||
expense: number;
|
||||
balance: number;
|
||||
month: string;
|
||||
monthLabel: string;
|
||||
income: number;
|
||||
expense: number;
|
||||
balance: number;
|
||||
};
|
||||
|
||||
export type IncomeExpenseBalanceData = {
|
||||
months: MonthData[];
|
||||
months: MonthData[];
|
||||
};
|
||||
|
||||
const MONTH_LABELS: Record<string, string> = {
|
||||
"01": "jan",
|
||||
"02": "fev",
|
||||
"03": "mar",
|
||||
"04": "abr",
|
||||
"05": "mai",
|
||||
"06": "jun",
|
||||
"07": "jul",
|
||||
"08": "ago",
|
||||
"09": "set",
|
||||
"10": "out",
|
||||
"11": "nov",
|
||||
"12": "dez",
|
||||
"01": "jan",
|
||||
"02": "fev",
|
||||
"03": "mar",
|
||||
"04": "abr",
|
||||
"05": "mai",
|
||||
"06": "jun",
|
||||
"07": "jul",
|
||||
"08": "ago",
|
||||
"09": "set",
|
||||
"10": "out",
|
||||
"11": "nov",
|
||||
"12": "dez",
|
||||
};
|
||||
|
||||
const generateLast6Months = (currentPeriod: string): string[] => {
|
||||
const [yearStr, monthStr] = currentPeriod.split("-");
|
||||
let year = Number.parseInt(yearStr ?? "", 10);
|
||||
let month = Number.parseInt(monthStr ?? "", 10);
|
||||
const [yearStr, monthStr] = currentPeriod.split("-");
|
||||
let year = Number.parseInt(yearStr ?? "", 10);
|
||||
let month = Number.parseInt(monthStr ?? "", 10);
|
||||
|
||||
if (Number.isNaN(year) || Number.isNaN(month)) {
|
||||
const now = new Date();
|
||||
year = now.getFullYear();
|
||||
month = now.getMonth() + 1;
|
||||
}
|
||||
if (Number.isNaN(year) || Number.isNaN(month)) {
|
||||
const now = new Date();
|
||||
year = now.getFullYear();
|
||||
month = now.getMonth() + 1;
|
||||
}
|
||||
|
||||
const periods: string[] = [];
|
||||
const periods: string[] = [];
|
||||
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
let targetMonth = month - i;
|
||||
let targetYear = year;
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
let targetMonth = month - i;
|
||||
let targetYear = year;
|
||||
|
||||
while (targetMonth <= 0) {
|
||||
targetMonth += 12;
|
||||
targetYear -= 1;
|
||||
}
|
||||
while (targetMonth <= 0) {
|
||||
targetMonth += 12;
|
||||
targetYear -= 1;
|
||||
}
|
||||
|
||||
periods.push(`${targetYear}-${String(targetMonth).padStart(2, "0")}`);
|
||||
}
|
||||
periods.push(`${targetYear}-${String(targetMonth).padStart(2, "0")}`);
|
||||
}
|
||||
|
||||
return periods;
|
||||
return periods;
|
||||
};
|
||||
|
||||
export async function fetchIncomeExpenseBalance(
|
||||
userId: string,
|
||||
currentPeriod: string
|
||||
userId: string,
|
||||
currentPeriod: string,
|
||||
): Promise<IncomeExpenseBalanceData> {
|
||||
const periods = generateLast6Months(currentPeriod);
|
||||
const periods = generateLast6Months(currentPeriod);
|
||||
|
||||
const results = await Promise.all(
|
||||
periods.map(async (period) => {
|
||||
// Busca receitas do período
|
||||
const [incomeRow] = await db
|
||||
.select({
|
||||
total: sql<number>`
|
||||
const results = await Promise.all(
|
||||
periods.map(async (period) => {
|
||||
// Busca receitas do período
|
||||
const [incomeRow] = await db
|
||||
.select({
|
||||
total: sql<number>`
|
||||
coalesce(
|
||||
sum(${lancamentos.amount}),
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Receita"),
|
||||
eq(pagadores.role, "admin"),
|
||||
sql`(${lancamentos.note} IS NULL OR ${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false)
|
||||
)
|
||||
)
|
||||
);
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Receita"),
|
||||
eq(pagadores.role, "admin"),
|
||||
sql`(${lancamentos.note} IS NULL OR ${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Busca despesas do período
|
||||
const [expenseRow] = await db
|
||||
.select({
|
||||
total: sql<number>`
|
||||
// Busca despesas do período
|
||||
const [expenseRow] = await db
|
||||
.select({
|
||||
total: sql<number>`
|
||||
coalesce(
|
||||
sum(${lancamentos.amount}),
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, "admin"),
|
||||
sql`(${lancamentos.note} IS NULL OR ${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`
|
||||
)
|
||||
);
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, "admin"),
|
||||
sql`(${lancamentos.note} IS NULL OR ${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
|
||||
),
|
||||
);
|
||||
|
||||
const income = Math.abs(toNumber(incomeRow?.total));
|
||||
const expense = Math.abs(toNumber(expenseRow?.total));
|
||||
const balance = income - expense;
|
||||
const income = Math.abs(toNumber(incomeRow?.total));
|
||||
const expense = Math.abs(toNumber(expenseRow?.total));
|
||||
const balance = income - expense;
|
||||
|
||||
const [, monthPart] = period.split("-");
|
||||
const monthLabel = MONTH_LABELS[monthPart ?? "01"] ?? monthPart;
|
||||
const [, monthPart] = period.split("-");
|
||||
const monthLabel = MONTH_LABELS[monthPart ?? "01"] ?? monthPart;
|
||||
|
||||
return {
|
||||
month: period,
|
||||
monthLabel: monthLabel ?? "",
|
||||
income,
|
||||
expense,
|
||||
balance,
|
||||
};
|
||||
})
|
||||
);
|
||||
return {
|
||||
month: period,
|
||||
monthLabel: monthLabel ?? "",
|
||||
income,
|
||||
expense,
|
||||
balance,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
months: results,
|
||||
};
|
||||
return {
|
||||
months: results,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,279 +1,279 @@
|
||||
import { and, eq, ilike, isNotNull, sql } from "drizzle-orm";
|
||||
import { cartoes, faturas, lancamentos, pagadores } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
INVOICE_PAYMENT_STATUS,
|
||||
INVOICE_STATUS_VALUES,
|
||||
type InvoicePaymentStatus,
|
||||
INVOICE_PAYMENT_STATUS,
|
||||
INVOICE_STATUS_VALUES,
|
||||
type InvoicePaymentStatus,
|
||||
} from "@/lib/faturas";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { and, eq, ilike, isNotNull, sql } from "drizzle-orm";
|
||||
|
||||
type RawDashboardInvoice = {
|
||||
invoiceId: string | null;
|
||||
cardId: string;
|
||||
cardName: string;
|
||||
cardBrand: string | null;
|
||||
cardStatus: string | null;
|
||||
logo: string | null;
|
||||
dueDay: string;
|
||||
period: string | null;
|
||||
paymentStatus: string | null;
|
||||
totalAmount: string | number | null;
|
||||
transactionCount: string | number | null;
|
||||
invoiceCreatedAt: Date | null;
|
||||
invoiceId: string | null;
|
||||
cardId: string;
|
||||
cardName: string;
|
||||
cardBrand: string | null;
|
||||
cardStatus: string | null;
|
||||
logo: string | null;
|
||||
dueDay: string;
|
||||
period: string | null;
|
||||
paymentStatus: string | null;
|
||||
totalAmount: string | number | null;
|
||||
transactionCount: string | number | null;
|
||||
invoiceCreatedAt: Date | null;
|
||||
};
|
||||
|
||||
export type InvoicePagadorBreakdown = {
|
||||
pagadorId: string | null;
|
||||
pagadorName: string;
|
||||
pagadorAvatar: string | null;
|
||||
amount: number;
|
||||
pagadorId: string | null;
|
||||
pagadorName: string;
|
||||
pagadorAvatar: string | null;
|
||||
amount: number;
|
||||
};
|
||||
|
||||
export type DashboardInvoice = {
|
||||
id: string;
|
||||
cardId: string;
|
||||
cardName: string;
|
||||
cardBrand: string | null;
|
||||
cardStatus: string | null;
|
||||
logo: string | null;
|
||||
dueDay: string;
|
||||
period: string;
|
||||
paymentStatus: InvoicePaymentStatus;
|
||||
totalAmount: number;
|
||||
paidAt: string | null;
|
||||
pagadorBreakdown: InvoicePagadorBreakdown[];
|
||||
id: string;
|
||||
cardId: string;
|
||||
cardName: string;
|
||||
cardBrand: string | null;
|
||||
cardStatus: string | null;
|
||||
logo: string | null;
|
||||
dueDay: string;
|
||||
period: string;
|
||||
paymentStatus: InvoicePaymentStatus;
|
||||
totalAmount: number;
|
||||
paidAt: string | null;
|
||||
pagadorBreakdown: InvoicePagadorBreakdown[];
|
||||
};
|
||||
|
||||
export type DashboardInvoicesSnapshot = {
|
||||
invoices: DashboardInvoice[];
|
||||
totalPending: number;
|
||||
invoices: DashboardInvoice[];
|
||||
totalPending: number;
|
||||
};
|
||||
|
||||
const toISODate = (value: Date | string | null | undefined) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
|
||||
typeof value === "string" &&
|
||||
(INVOICE_STATUS_VALUES as string[]).includes(value);
|
||||
typeof value === "string" &&
|
||||
(INVOICE_STATUS_VALUES as string[]).includes(value);
|
||||
|
||||
const buildFallbackId = (cardId: string, period: string) =>
|
||||
`${cardId}:${period}`;
|
||||
`${cardId}:${period}`;
|
||||
|
||||
export async function fetchDashboardInvoices(
|
||||
userId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<DashboardInvoicesSnapshot> {
|
||||
const paymentRows = await db
|
||||
.select({
|
||||
note: lancamentos.note,
|
||||
purchaseDate: lancamentos.purchaseDate,
|
||||
createdAt: lancamentos.createdAt,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)
|
||||
)
|
||||
);
|
||||
const paymentRows = await db
|
||||
.select({
|
||||
note: lancamentos.note,
|
||||
purchaseDate: lancamentos.purchaseDate,
|
||||
createdAt: lancamentos.createdAt,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`),
|
||||
),
|
||||
);
|
||||
|
||||
const paymentMap = new Map<string, string>();
|
||||
for (const row of paymentRows) {
|
||||
const note = row.note;
|
||||
if (!note || !note.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
const parts = note.split(":");
|
||||
if (parts.length < 3) {
|
||||
continue;
|
||||
}
|
||||
const cardIdPart = parts[1];
|
||||
const periodPart = parts[2];
|
||||
if (!cardIdPart || !periodPart) {
|
||||
continue;
|
||||
}
|
||||
const key = `${cardIdPart}:${periodPart}`;
|
||||
const resolvedDate =
|
||||
row.purchaseDate instanceof Date && !Number.isNaN(row.purchaseDate.valueOf())
|
||||
? row.purchaseDate
|
||||
: row.createdAt;
|
||||
const isoDate = toISODate(resolvedDate);
|
||||
if (!isoDate) {
|
||||
continue;
|
||||
}
|
||||
const existing = paymentMap.get(key);
|
||||
if (!existing || existing < isoDate) {
|
||||
paymentMap.set(key, isoDate);
|
||||
}
|
||||
}
|
||||
const paymentMap = new Map<string, string>();
|
||||
for (const row of paymentRows) {
|
||||
const note = row.note;
|
||||
if (!note || !note.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
const parts = note.split(":");
|
||||
if (parts.length < 3) {
|
||||
continue;
|
||||
}
|
||||
const cardIdPart = parts[1];
|
||||
const periodPart = parts[2];
|
||||
if (!cardIdPart || !periodPart) {
|
||||
continue;
|
||||
}
|
||||
const key = `${cardIdPart}:${periodPart}`;
|
||||
const resolvedDate =
|
||||
row.purchaseDate instanceof Date &&
|
||||
!Number.isNaN(row.purchaseDate.valueOf())
|
||||
? row.purchaseDate
|
||||
: row.createdAt;
|
||||
const isoDate = toISODate(resolvedDate);
|
||||
if (!isoDate) {
|
||||
continue;
|
||||
}
|
||||
const existing = paymentMap.get(key);
|
||||
if (!existing || existing < isoDate) {
|
||||
paymentMap.set(key, isoDate);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
totalAmount: sql<number | null>`
|
||||
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,
|
||||
totalAmount: sql<number | null>`
|
||||
COALESCE(SUM(${lancamentos.amount}), 0)
|
||||
`,
|
||||
transactionCount: sql<number | null>`COUNT(${lancamentos.id})`,
|
||||
})
|
||||
.from(cartoes)
|
||||
.leftJoin(
|
||||
faturas,
|
||||
and(
|
||||
eq(faturas.cartaoId, cartoes.id),
|
||||
eq(faturas.userId, userId),
|
||||
eq(faturas.period, period)
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.cartaoId, cartoes.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period)
|
||||
)
|
||||
)
|
||||
.where(eq(cartoes.userId, userId))
|
||||
.groupBy(
|
||||
faturas.id,
|
||||
cartoes.id,
|
||||
cartoes.name,
|
||||
cartoes.brand,
|
||||
cartoes.status,
|
||||
cartoes.logo,
|
||||
cartoes.dueDay,
|
||||
faturas.period,
|
||||
faturas.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)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
isNotNull(lancamentos.cartaoId)
|
||||
)
|
||||
)
|
||||
.groupBy(
|
||||
lancamentos.cartaoId,
|
||||
lancamentos.period,
|
||||
lancamentos.pagadorId,
|
||||
pagadores.name,
|
||||
pagadores.avatarUrl
|
||||
),
|
||||
]);
|
||||
transactionCount: sql<number | null>`COUNT(${lancamentos.id})`,
|
||||
})
|
||||
.from(cartoes)
|
||||
.leftJoin(
|
||||
faturas,
|
||||
and(
|
||||
eq(faturas.cartaoId, cartoes.id),
|
||||
eq(faturas.userId, userId),
|
||||
eq(faturas.period, period),
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.cartaoId, cartoes.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
),
|
||||
)
|
||||
.where(eq(cartoes.userId, userId))
|
||||
.groupBy(
|
||||
faturas.id,
|
||||
cartoes.id,
|
||||
cartoes.name,
|
||||
cartoes.brand,
|
||||
cartoes.status,
|
||||
cartoes.logo,
|
||||
cartoes.dueDay,
|
||||
faturas.period,
|
||||
faturas.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)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
isNotNull(lancamentos.cartaoId),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
lancamentos.cartaoId,
|
||||
lancamentos.period,
|
||||
lancamentos.pagadorId,
|
||||
pagadores.name,
|
||||
pagadores.avatarUrl,
|
||||
),
|
||||
]);
|
||||
|
||||
const breakdownMap = new Map<string, InvoicePagadorBreakdown[]>();
|
||||
for (const row of breakdownRows) {
|
||||
if (!row.cardId) {
|
||||
continue;
|
||||
}
|
||||
const resolvedPeriod = row.period ?? period;
|
||||
const amount = Math.abs(toNumber(row.amount));
|
||||
if (amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key = `${row.cardId}:${resolvedPeriod}`;
|
||||
const current = breakdownMap.get(key) ?? [];
|
||||
current.push({
|
||||
pagadorId: row.pagadorId ?? null,
|
||||
pagadorName: row.pagadorName?.trim() || "Sem pagador",
|
||||
pagadorAvatar: row.pagadorAvatar ?? null,
|
||||
amount,
|
||||
});
|
||||
breakdownMap.set(key, current);
|
||||
}
|
||||
const breakdownMap = new Map<string, InvoicePagadorBreakdown[]>();
|
||||
for (const row of breakdownRows) {
|
||||
if (!row.cardId) {
|
||||
continue;
|
||||
}
|
||||
const resolvedPeriod = row.period ?? period;
|
||||
const amount = Math.abs(toNumber(row.amount));
|
||||
if (amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key = `${row.cardId}:${resolvedPeriod}`;
|
||||
const current = breakdownMap.get(key) ?? [];
|
||||
current.push({
|
||||
pagadorId: row.pagadorId ?? null,
|
||||
pagadorName: row.pagadorName?.trim() || "Sem pagador",
|
||||
pagadorAvatar: row.pagadorAvatar ?? null,
|
||||
amount,
|
||||
});
|
||||
breakdownMap.set(key, current);
|
||||
}
|
||||
|
||||
const invoices = rows
|
||||
.map((row: RawDashboardInvoice | null) => {
|
||||
if (!row) return null;
|
||||
const invoices = rows
|
||||
.map((row: RawDashboardInvoice | null) => {
|
||||
if (!row) return null;
|
||||
|
||||
const totalAmount = toNumber(row.totalAmount);
|
||||
const transactionCount = toNumber(row.transactionCount);
|
||||
const paymentStatus = isInvoiceStatus(row.paymentStatus)
|
||||
? row.paymentStatus
|
||||
: INVOICE_PAYMENT_STATUS.PENDING;
|
||||
const totalAmount = toNumber(row.totalAmount);
|
||||
const transactionCount = toNumber(row.transactionCount);
|
||||
const paymentStatus = isInvoiceStatus(row.paymentStatus)
|
||||
? row.paymentStatus
|
||||
: INVOICE_PAYMENT_STATUS.PENDING;
|
||||
|
||||
const shouldInclude =
|
||||
transactionCount > 0 ||
|
||||
Math.abs(totalAmount) > 0 ||
|
||||
row.invoiceId !== null;
|
||||
const shouldInclude =
|
||||
transactionCount > 0 ||
|
||||
Math.abs(totalAmount) > 0 ||
|
||||
row.invoiceId !== null;
|
||||
|
||||
if (!shouldInclude) {
|
||||
return null;
|
||||
}
|
||||
if (!shouldInclude) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedPeriod = row.period ?? period;
|
||||
const paymentKey = `${row.cardId}:${resolvedPeriod}`;
|
||||
const paidAt =
|
||||
paymentStatus === INVOICE_PAYMENT_STATUS.PAID
|
||||
? paymentMap.get(paymentKey) ??
|
||||
toISODate(row.invoiceCreatedAt)
|
||||
: null;
|
||||
const resolvedPeriod = row.period ?? period;
|
||||
const paymentKey = `${row.cardId}:${resolvedPeriod}`;
|
||||
const paidAt =
|
||||
paymentStatus === INVOICE_PAYMENT_STATUS.PAID
|
||||
? (paymentMap.get(paymentKey) ?? toISODate(row.invoiceCreatedAt))
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: row.invoiceId ?? buildFallbackId(row.cardId, period),
|
||||
cardId: row.cardId,
|
||||
cardName: row.cardName,
|
||||
cardBrand: row.cardBrand,
|
||||
cardStatus: row.cardStatus,
|
||||
logo: row.logo,
|
||||
dueDay: row.dueDay,
|
||||
period: resolvedPeriod,
|
||||
paymentStatus,
|
||||
totalAmount,
|
||||
paidAt,
|
||||
pagadorBreakdown: (
|
||||
breakdownMap.get(`${row.cardId}:${resolvedPeriod}`) ?? []
|
||||
).sort((a, b) => b.amount - a.amount),
|
||||
} satisfies DashboardInvoice;
|
||||
})
|
||||
.filter((invoice): invoice is DashboardInvoice => invoice !== null)
|
||||
.sort((a, b) => {
|
||||
// Ordena do maior valor para o menor
|
||||
return Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
|
||||
});
|
||||
return {
|
||||
id: row.invoiceId ?? buildFallbackId(row.cardId, period),
|
||||
cardId: row.cardId,
|
||||
cardName: row.cardName,
|
||||
cardBrand: row.cardBrand,
|
||||
cardStatus: row.cardStatus,
|
||||
logo: row.logo,
|
||||
dueDay: row.dueDay,
|
||||
period: resolvedPeriod,
|
||||
paymentStatus,
|
||||
totalAmount,
|
||||
paidAt,
|
||||
pagadorBreakdown: (
|
||||
breakdownMap.get(`${row.cardId}:${resolvedPeriod}`) ?? []
|
||||
).sort((a, b) => b.amount - a.amount),
|
||||
} satisfies DashboardInvoice;
|
||||
})
|
||||
.filter((invoice): invoice is DashboardInvoice => invoice !== null)
|
||||
.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) => {
|
||||
if (invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PENDING) {
|
||||
return total;
|
||||
}
|
||||
return total + invoice.totalAmount;
|
||||
}, 0);
|
||||
const totalPending = invoices.reduce((total, invoice) => {
|
||||
if (invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PENDING) {
|
||||
return total;
|
||||
}
|
||||
return total + invoice.totalAmount;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
invoices,
|
||||
totalPending,
|
||||
};
|
||||
return {
|
||||
invoices,
|
||||
totalPending,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,162 +1,171 @@
|
||||
import { lancamentos, pagadores, contas } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
eq,
|
||||
ilike,
|
||||
isNull,
|
||||
lte,
|
||||
ne,
|
||||
not,
|
||||
or,
|
||||
sum,
|
||||
} from "drizzle-orm";
|
||||
import { contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import {
|
||||
getPreviousPeriod,
|
||||
buildPeriodRange,
|
||||
comparePeriods,
|
||||
} from "@/lib/utils/period";
|
||||
import { safeToNumber } from "@/lib/utils/number";
|
||||
import { and, asc, eq, ilike, isNull, lte, not, or, sum, ne } from "drizzle-orm";
|
||||
import {
|
||||
buildPeriodRange,
|
||||
comparePeriods,
|
||||
getPreviousPeriod,
|
||||
} from "@/lib/utils/period";
|
||||
|
||||
const RECEITA = "Receita";
|
||||
const DESPESA = "Despesa";
|
||||
const TRANSFERENCIA = "Transferência";
|
||||
|
||||
type MetricPair = {
|
||||
current: number;
|
||||
previous: number;
|
||||
current: number;
|
||||
previous: number;
|
||||
};
|
||||
|
||||
export type DashboardCardMetrics = {
|
||||
period: string;
|
||||
previousPeriod: string;
|
||||
receitas: MetricPair;
|
||||
despesas: MetricPair;
|
||||
balanco: MetricPair;
|
||||
previsto: MetricPair;
|
||||
period: string;
|
||||
previousPeriod: string;
|
||||
receitas: MetricPair;
|
||||
despesas: MetricPair;
|
||||
balanco: MetricPair;
|
||||
previsto: MetricPair;
|
||||
};
|
||||
|
||||
type PeriodTotals = {
|
||||
receitas: number;
|
||||
despesas: number;
|
||||
balanco: number;
|
||||
receitas: number;
|
||||
despesas: number;
|
||||
balanco: number;
|
||||
};
|
||||
|
||||
const createEmptyTotals = (): PeriodTotals => ({
|
||||
receitas: 0,
|
||||
despesas: 0,
|
||||
balanco: 0,
|
||||
receitas: 0,
|
||||
despesas: 0,
|
||||
balanco: 0,
|
||||
});
|
||||
|
||||
const ensurePeriodTotals = (
|
||||
store: Map<string, PeriodTotals>,
|
||||
period: string
|
||||
store: Map<string, PeriodTotals>,
|
||||
period: string,
|
||||
): PeriodTotals => {
|
||||
if (!store.has(period)) {
|
||||
store.set(period, createEmptyTotals());
|
||||
}
|
||||
const totals = store.get(period);
|
||||
// This should always exist since we just set it above
|
||||
if (!totals) {
|
||||
const emptyTotals = createEmptyTotals();
|
||||
store.set(period, emptyTotals);
|
||||
return emptyTotals;
|
||||
}
|
||||
return totals;
|
||||
if (!store.has(period)) {
|
||||
store.set(period, createEmptyTotals());
|
||||
}
|
||||
const totals = store.get(period);
|
||||
// This should always exist since we just set it above
|
||||
if (!totals) {
|
||||
const emptyTotals = createEmptyTotals();
|
||||
store.set(period, emptyTotals);
|
||||
return emptyTotals;
|
||||
}
|
||||
return totals;
|
||||
};
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export { getPreviousPeriod };
|
||||
|
||||
export async function fetchDashboardCardMetrics(
|
||||
userId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<DashboardCardMetrics> {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
period: lancamentos.period,
|
||||
transactionType: lancamentos.transactionType,
|
||||
totalAmount: sum(lancamentos.amount).as("total"),
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
lte(lancamentos.period, period),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
ne(lancamentos.transactionType, TRANSFERENCIA),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
not(
|
||||
ilike(
|
||||
lancamentos.note,
|
||||
`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`
|
||||
)
|
||||
)
|
||||
),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false)
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(lancamentos.period, lancamentos.transactionType)
|
||||
.orderBy(asc(lancamentos.period), asc(lancamentos.transactionType));
|
||||
const rows = await db
|
||||
.select({
|
||||
period: lancamentos.period,
|
||||
transactionType: lancamentos.transactionType,
|
||||
totalAmount: sum(lancamentos.amount).as("total"),
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
lte(lancamentos.period, period),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
ne(lancamentos.transactionType, TRANSFERENCIA),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
|
||||
),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false),
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.period, lancamentos.transactionType)
|
||||
.orderBy(asc(lancamentos.period), asc(lancamentos.transactionType));
|
||||
|
||||
const periodTotals = new Map<string, PeriodTotals>();
|
||||
const periodTotals = new Map<string, PeriodTotals>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.period) continue;
|
||||
const totals = ensurePeriodTotals(periodTotals, row.period);
|
||||
const total = safeToNumber(row.totalAmount);
|
||||
if (row.transactionType === RECEITA) {
|
||||
totals.receitas += total;
|
||||
} else if (row.transactionType === DESPESA) {
|
||||
totals.despesas += Math.abs(total);
|
||||
}
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (!row.period) continue;
|
||||
const totals = ensurePeriodTotals(periodTotals, row.period);
|
||||
const total = safeToNumber(row.totalAmount);
|
||||
if (row.transactionType === RECEITA) {
|
||||
totals.receitas += total;
|
||||
} else if (row.transactionType === DESPESA) {
|
||||
totals.despesas += Math.abs(total);
|
||||
}
|
||||
}
|
||||
|
||||
ensurePeriodTotals(periodTotals, period);
|
||||
ensurePeriodTotals(periodTotals, previousPeriod);
|
||||
ensurePeriodTotals(periodTotals, period);
|
||||
ensurePeriodTotals(periodTotals, previousPeriod);
|
||||
|
||||
const earliestPeriod =
|
||||
periodTotals.size > 0 ? Array.from(periodTotals.keys()).sort()[0] : period;
|
||||
const earliestPeriod =
|
||||
periodTotals.size > 0 ? Array.from(periodTotals.keys()).sort()[0] : period;
|
||||
|
||||
const startPeriod =
|
||||
comparePeriods(earliestPeriod, previousPeriod) <= 0
|
||||
? earliestPeriod
|
||||
: previousPeriod;
|
||||
const startPeriod =
|
||||
comparePeriods(earliestPeriod, previousPeriod) <= 0
|
||||
? earliestPeriod
|
||||
: previousPeriod;
|
||||
|
||||
const periodRange = buildPeriodRange(startPeriod, period);
|
||||
const forecastByPeriod = new Map<string, number>();
|
||||
let runningForecast = 0;
|
||||
const periodRange = buildPeriodRange(startPeriod, period);
|
||||
const forecastByPeriod = new Map<string, number>();
|
||||
let runningForecast = 0;
|
||||
|
||||
for (const key of periodRange) {
|
||||
const totals = ensurePeriodTotals(periodTotals, key);
|
||||
totals.balanco = totals.receitas - totals.despesas;
|
||||
runningForecast += totals.balanco;
|
||||
forecastByPeriod.set(key, runningForecast);
|
||||
}
|
||||
for (const key of periodRange) {
|
||||
const totals = ensurePeriodTotals(periodTotals, key);
|
||||
totals.balanco = totals.receitas - totals.despesas;
|
||||
runningForecast += totals.balanco;
|
||||
forecastByPeriod.set(key, runningForecast);
|
||||
}
|
||||
|
||||
const currentTotals = ensurePeriodTotals(periodTotals, period);
|
||||
const previousTotals = ensurePeriodTotals(periodTotals, previousPeriod);
|
||||
const currentTotals = ensurePeriodTotals(periodTotals, period);
|
||||
const previousTotals = ensurePeriodTotals(periodTotals, previousPeriod);
|
||||
|
||||
return {
|
||||
period,
|
||||
previousPeriod,
|
||||
receitas: {
|
||||
current: currentTotals.receitas,
|
||||
previous: previousTotals.receitas,
|
||||
},
|
||||
despesas: {
|
||||
current: currentTotals.despesas,
|
||||
previous: previousTotals.despesas,
|
||||
},
|
||||
balanco: {
|
||||
current: currentTotals.balanco,
|
||||
previous: previousTotals.balanco,
|
||||
},
|
||||
previsto: {
|
||||
current: forecastByPeriod.get(period) ?? runningForecast,
|
||||
previous: forecastByPeriod.get(previousPeriod) ?? 0,
|
||||
},
|
||||
};
|
||||
return {
|
||||
period,
|
||||
previousPeriod,
|
||||
receitas: {
|
||||
current: currentTotals.receitas,
|
||||
previous: previousTotals.receitas,
|
||||
},
|
||||
despesas: {
|
||||
current: currentTotals.despesas,
|
||||
previous: previousTotals.despesas,
|
||||
},
|
||||
balanco: {
|
||||
current: currentTotals.balanco,
|
||||
previous: previousTotals.balanco,
|
||||
},
|
||||
previsto: {
|
||||
current: forecastByPeriod.get(period) ?? runningForecast,
|
||||
previous: forecastByPeriod.get(previousPeriod) ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, lt, sql } from "drizzle-orm";
|
||||
import { cartoes, faturas, lancamentos, pagadores } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
|
||||
import { and, eq, lt, sql } from "drizzle-orm";
|
||||
|
||||
export type NotificationType = "overdue" | "due_soon";
|
||||
|
||||
export type DashboardNotification = {
|
||||
id: string;
|
||||
type: "invoice" | "boleto";
|
||||
name: string;
|
||||
dueDate: string;
|
||||
status: NotificationType;
|
||||
amount: number;
|
||||
period?: string;
|
||||
showAmount: boolean; // Controla se o valor deve ser exibido no card
|
||||
id: string;
|
||||
type: "invoice" | "boleto";
|
||||
name: string;
|
||||
dueDate: string;
|
||||
status: NotificationType;
|
||||
amount: number;
|
||||
period?: string;
|
||||
showAmount: boolean; // Controla se o valor deve ser exibido no card
|
||||
};
|
||||
|
||||
export type DashboardNotificationsSnapshot = {
|
||||
notifications: DashboardNotification[];
|
||||
totalCount: number;
|
||||
notifications: DashboardNotification[];
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||
@@ -32,67 +32,72 @@ const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||
* @returns Data de vencimento no formato YYYY-MM-DD
|
||||
*/
|
||||
function calculateDueDate(period: string, dueDay: string): string {
|
||||
const [year, month] = period.split("-");
|
||||
const yearNumber = Number(year);
|
||||
const monthNumber = Number(month);
|
||||
const hasValidMonth =
|
||||
Number.isInteger(yearNumber) &&
|
||||
Number.isInteger(monthNumber) &&
|
||||
monthNumber >= 1 &&
|
||||
monthNumber <= 12;
|
||||
const [year, month] = period.split("-");
|
||||
const yearNumber = Number(year);
|
||||
const monthNumber = Number(month);
|
||||
const hasValidMonth =
|
||||
Number.isInteger(yearNumber) &&
|
||||
Number.isInteger(monthNumber) &&
|
||||
monthNumber >= 1 &&
|
||||
monthNumber <= 12;
|
||||
|
||||
const daysInMonth = hasValidMonth
|
||||
? new Date(yearNumber, monthNumber, 0).getDate()
|
||||
: null;
|
||||
const daysInMonth = hasValidMonth
|
||||
? new Date(yearNumber, monthNumber, 0).getDate()
|
||||
: null;
|
||||
|
||||
const dueDayNumber = Number(dueDay);
|
||||
const hasValidDueDay = Number.isInteger(dueDayNumber) && dueDayNumber > 0;
|
||||
const dueDayNumber = Number(dueDay);
|
||||
const hasValidDueDay = Number.isInteger(dueDayNumber) && dueDayNumber > 0;
|
||||
|
||||
const clampedDay =
|
||||
hasValidMonth && hasValidDueDay && daysInMonth
|
||||
? Math.min(dueDayNumber, daysInMonth)
|
||||
: hasValidDueDay
|
||||
? dueDayNumber
|
||||
: null;
|
||||
const clampedDay =
|
||||
hasValidMonth && hasValidDueDay && daysInMonth
|
||||
? Math.min(dueDayNumber, daysInMonth)
|
||||
: hasValidDueDay
|
||||
? dueDayNumber
|
||||
: null;
|
||||
|
||||
const day = clampedDay
|
||||
? String(clampedDay).padStart(2, "0")
|
||||
: dueDay.padStart(2, "0");
|
||||
const day = clampedDay
|
||||
? String(clampedDay).padStart(2, "0")
|
||||
: dueDay.padStart(2, "0");
|
||||
|
||||
const normalizedMonth =
|
||||
hasValidMonth && month.length < 2 ? month.padStart(2, "0") : month;
|
||||
const normalizedMonth =
|
||||
hasValidMonth && month.length < 2 ? month.padStart(2, "0") : month;
|
||||
|
||||
return `${year}-${normalizedMonth}-${day}`;
|
||||
return `${year}-${normalizedMonth}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normaliza uma data para o início do dia em UTC (00:00:00)
|
||||
*/
|
||||
function normalizeDate(date: Date): Date {
|
||||
return new Date(Date.UTC(
|
||||
date.getUTCFullYear(),
|
||||
date.getUTCMonth(),
|
||||
date.getUTCDate(),
|
||||
0, 0, 0, 0
|
||||
));
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
date.getUTCFullYear(),
|
||||
date.getUTCMonth(),
|
||||
date.getUTCDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converte string "YYYY-MM-DD" para Date em UTC (evita problemas de timezone)
|
||||
*/
|
||||
function parseUTCDate(dateString: string): Date {
|
||||
const [year, month, day] = dateString.split("-").map(Number);
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
const [year, month, day] = dateString.split("-").map(Number);
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se uma data está atrasada (antes do dia atual, não incluindo hoje)
|
||||
*/
|
||||
function isOverdue(dueDate: string, today: Date): boolean {
|
||||
const due = parseUTCDate(dueDate);
|
||||
const dueNormalized = normalizeDate(due);
|
||||
const due = parseUTCDate(dueDate);
|
||||
const dueNormalized = normalizeDate(due);
|
||||
|
||||
return dueNormalized < today;
|
||||
return dueNormalized < today;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,19 +105,19 @@ function isOverdue(dueDate: string, today: Date): boolean {
|
||||
* Exemplo: Se hoje é dia 4 e daysThreshold = 5, retorna true para datas de 4 a 8
|
||||
*/
|
||||
function isDueWithinDays(
|
||||
dueDate: string,
|
||||
today: Date,
|
||||
daysThreshold: number
|
||||
dueDate: string,
|
||||
today: Date,
|
||||
daysThreshold: number,
|
||||
): boolean {
|
||||
const due = parseUTCDate(dueDate);
|
||||
const dueNormalized = normalizeDate(due);
|
||||
const due = parseUTCDate(dueDate);
|
||||
const dueNormalized = normalizeDate(due);
|
||||
|
||||
// Data limite: hoje + daysThreshold dias (em UTC)
|
||||
const limitDate = new Date(today);
|
||||
limitDate.setUTCDate(limitDate.getUTCDate() + daysThreshold);
|
||||
// Data limite: hoje + daysThreshold dias (em UTC)
|
||||
const limitDate = new Date(today);
|
||||
limitDate.setUTCDate(limitDate.getUTCDate() + daysThreshold);
|
||||
|
||||
// Vence se está entre hoje (inclusive) e a data limite (inclusive)
|
||||
return dueNormalized >= today && dueNormalized <= limitDate;
|
||||
// Vence se está entre hoje (inclusive) e a data limite (inclusive)
|
||||
return dueNormalized >= today && dueNormalized <= limitDate;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,22 +132,22 @@ function isDueWithinDays(
|
||||
* - "due_soon": vencimento no dia atual ou nos próximos dias
|
||||
*/
|
||||
export async function fetchDashboardNotifications(
|
||||
userId: string,
|
||||
currentPeriod: string
|
||||
userId: string,
|
||||
currentPeriod: string,
|
||||
): Promise<DashboardNotificationsSnapshot> {
|
||||
const today = normalizeDate(new Date());
|
||||
const DAYS_THRESHOLD = 5;
|
||||
const today = normalizeDate(new Date());
|
||||
const DAYS_THRESHOLD = 5;
|
||||
|
||||
// Buscar faturas pendentes de períodos anteriores
|
||||
// Apenas faturas com registro na tabela (períodos antigos devem ter sido finalizados)
|
||||
const overdueInvoices = await db
|
||||
.select({
|
||||
invoiceId: faturas.id,
|
||||
cardId: cartoes.id,
|
||||
cardName: cartoes.name,
|
||||
dueDay: cartoes.dueDay,
|
||||
period: faturas.period,
|
||||
totalAmount: sql<number | null>`
|
||||
// Buscar faturas pendentes de períodos anteriores
|
||||
// Apenas faturas com registro na tabela (períodos antigos devem ter sido finalizados)
|
||||
const overdueInvoices = await db
|
||||
.select({
|
||||
invoiceId: faturas.id,
|
||||
cardId: cartoes.id,
|
||||
cardName: cartoes.name,
|
||||
dueDay: cartoes.dueDay,
|
||||
period: faturas.period,
|
||||
totalAmount: sql<number | null>`
|
||||
COALESCE(
|
||||
(SELECT SUM(${lancamentos.amount})
|
||||
FROM ${lancamentos}
|
||||
@@ -152,222 +157,223 @@ export async function fetchDashboardNotifications(
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(faturas)
|
||||
.innerJoin(cartoes, eq(faturas.cartaoId, cartoes.id))
|
||||
.where(
|
||||
and(
|
||||
eq(faturas.userId, userId),
|
||||
eq(faturas.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING),
|
||||
lt(faturas.period, currentPeriod)
|
||||
)
|
||||
);
|
||||
})
|
||||
.from(faturas)
|
||||
.innerJoin(cartoes, eq(faturas.cartaoId, cartoes.id))
|
||||
.where(
|
||||
and(
|
||||
eq(faturas.userId, userId),
|
||||
eq(faturas.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING),
|
||||
lt(faturas.period, currentPeriod),
|
||||
),
|
||||
);
|
||||
|
||||
// Buscar faturas do período atual
|
||||
// Usa LEFT JOIN para incluir cartões com lançamentos mesmo sem registro em faturas
|
||||
const currentInvoices = await db
|
||||
.select({
|
||||
invoiceId: faturas.id,
|
||||
cardId: cartoes.id,
|
||||
cardName: cartoes.name,
|
||||
dueDay: cartoes.dueDay,
|
||||
period: sql<string>`COALESCE(${faturas.period}, ${currentPeriod})`,
|
||||
paymentStatus: faturas.paymentStatus,
|
||||
totalAmount: sql<number | null>`
|
||||
// Buscar faturas do período atual
|
||||
// Usa LEFT JOIN para incluir cartões com lançamentos mesmo sem registro em faturas
|
||||
const currentInvoices = await db
|
||||
.select({
|
||||
invoiceId: faturas.id,
|
||||
cardId: cartoes.id,
|
||||
cardName: cartoes.name,
|
||||
dueDay: cartoes.dueDay,
|
||||
period: sql<string>`COALESCE(${faturas.period}, ${currentPeriod})`,
|
||||
paymentStatus: faturas.paymentStatus,
|
||||
totalAmount: sql<number | null>`
|
||||
COALESCE(SUM(${lancamentos.amount}), 0)
|
||||
`,
|
||||
transactionCount: sql<number | null>`COUNT(${lancamentos.id})`,
|
||||
})
|
||||
.from(cartoes)
|
||||
.leftJoin(
|
||||
faturas,
|
||||
and(
|
||||
eq(faturas.cartaoId, cartoes.id),
|
||||
eq(faturas.userId, userId),
|
||||
eq(faturas.period, currentPeriod)
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.cartaoId, cartoes.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, currentPeriod)
|
||||
)
|
||||
)
|
||||
.where(eq(cartoes.userId, userId))
|
||||
.groupBy(
|
||||
faturas.id,
|
||||
cartoes.id,
|
||||
cartoes.name,
|
||||
cartoes.dueDay,
|
||||
faturas.period,
|
||||
faturas.paymentStatus
|
||||
);
|
||||
transactionCount: sql<number | null>`COUNT(${lancamentos.id})`,
|
||||
})
|
||||
.from(cartoes)
|
||||
.leftJoin(
|
||||
faturas,
|
||||
and(
|
||||
eq(faturas.cartaoId, cartoes.id),
|
||||
eq(faturas.userId, userId),
|
||||
eq(faturas.period, currentPeriod),
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.cartaoId, cartoes.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, currentPeriod),
|
||||
),
|
||||
)
|
||||
.where(eq(cartoes.userId, userId))
|
||||
.groupBy(
|
||||
faturas.id,
|
||||
cartoes.id,
|
||||
cartoes.name,
|
||||
cartoes.dueDay,
|
||||
faturas.period,
|
||||
faturas.paymentStatus,
|
||||
);
|
||||
|
||||
// Buscar boletos não pagos
|
||||
const boletosRows = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
dueDate: lancamentos.dueDate,
|
||||
period: lancamentos.period,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
eq(lancamentos.isSettled, false),
|
||||
eq(pagadores.role, "admin")
|
||||
)
|
||||
);
|
||||
// Buscar boletos não pagos
|
||||
const boletosRows = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
dueDate: lancamentos.dueDate,
|
||||
period: lancamentos.period,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
eq(lancamentos.isSettled, false),
|
||||
eq(pagadores.role, "admin"),
|
||||
),
|
||||
);
|
||||
|
||||
const notifications: DashboardNotification[] = [];
|
||||
const notifications: DashboardNotification[] = [];
|
||||
|
||||
// Processar faturas atrasadas (períodos anteriores)
|
||||
for (const invoice of overdueInvoices) {
|
||||
if (!invoice.period || !invoice.dueDay) continue;
|
||||
// Processar faturas atrasadas (períodos anteriores)
|
||||
for (const invoice of overdueInvoices) {
|
||||
if (!invoice.period || !invoice.dueDay) continue;
|
||||
|
||||
const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
|
||||
const amount =
|
||||
typeof invoice.totalAmount === "number"
|
||||
? invoice.totalAmount
|
||||
: Number(invoice.totalAmount) || 0;
|
||||
const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
|
||||
const amount =
|
||||
typeof invoice.totalAmount === "number"
|
||||
? invoice.totalAmount
|
||||
: Number(invoice.totalAmount) || 0;
|
||||
|
||||
const notificationId = invoice.invoiceId
|
||||
? `invoice-${invoice.invoiceId}`
|
||||
: `invoice-${invoice.cardId}-${invoice.period}`;
|
||||
const notificationId = invoice.invoiceId
|
||||
? `invoice-${invoice.invoiceId}`
|
||||
: `invoice-${invoice.cardId}-${invoice.period}`;
|
||||
|
||||
notifications.push({
|
||||
id: notificationId,
|
||||
type: "invoice",
|
||||
name: invoice.cardName,
|
||||
dueDate,
|
||||
status: "overdue",
|
||||
amount: Math.abs(amount),
|
||||
period: invoice.period,
|
||||
showAmount: true, // Mostrar valor para itens de períodos anteriores
|
||||
});
|
||||
}
|
||||
notifications.push({
|
||||
id: notificationId,
|
||||
type: "invoice",
|
||||
name: invoice.cardName,
|
||||
dueDate,
|
||||
status: "overdue",
|
||||
amount: Math.abs(amount),
|
||||
period: invoice.period,
|
||||
showAmount: true, // Mostrar valor para itens de períodos anteriores
|
||||
});
|
||||
}
|
||||
|
||||
// Processar faturas do período atual (atrasadas + vencimento iminente)
|
||||
for (const invoice of currentInvoices) {
|
||||
if (!invoice.period || !invoice.dueDay) continue;
|
||||
// Processar faturas do período atual (atrasadas + vencimento iminente)
|
||||
for (const invoice of currentInvoices) {
|
||||
if (!invoice.period || !invoice.dueDay) continue;
|
||||
|
||||
const amount =
|
||||
typeof invoice.totalAmount === "number"
|
||||
? invoice.totalAmount
|
||||
: Number(invoice.totalAmount) || 0;
|
||||
const amount =
|
||||
typeof invoice.totalAmount === "number"
|
||||
? invoice.totalAmount
|
||||
: Number(invoice.totalAmount) || 0;
|
||||
|
||||
const transactionCount =
|
||||
typeof invoice.transactionCount === "number"
|
||||
? invoice.transactionCount
|
||||
: Number(invoice.transactionCount) || 0;
|
||||
const transactionCount =
|
||||
typeof invoice.transactionCount === "number"
|
||||
? invoice.transactionCount
|
||||
: Number(invoice.transactionCount) || 0;
|
||||
|
||||
const paymentStatus = invoice.paymentStatus ?? INVOICE_PAYMENT_STATUS.PENDING;
|
||||
const paymentStatus =
|
||||
invoice.paymentStatus ?? INVOICE_PAYMENT_STATUS.PENDING;
|
||||
|
||||
// Ignora se não tem lançamentos e não tem registro de fatura
|
||||
const shouldInclude =
|
||||
transactionCount > 0 ||
|
||||
Math.abs(amount) > 0 ||
|
||||
invoice.invoiceId !== null;
|
||||
// Ignora se não tem lançamentos e não tem registro de fatura
|
||||
const shouldInclude =
|
||||
transactionCount > 0 ||
|
||||
Math.abs(amount) > 0 ||
|
||||
invoice.invoiceId !== null;
|
||||
|
||||
if (!shouldInclude) continue;
|
||||
if (!shouldInclude) continue;
|
||||
|
||||
// Ignora se já foi paga
|
||||
if (paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
|
||||
// Ignora se já foi paga
|
||||
if (paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
|
||||
|
||||
const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
|
||||
const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
|
||||
|
||||
const invoiceIsOverdue = isOverdue(dueDate, today);
|
||||
const invoiceIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
|
||||
const invoiceIsOverdue = isOverdue(dueDate, today);
|
||||
const invoiceIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
|
||||
|
||||
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
|
||||
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
|
||||
|
||||
const notificationId = invoice.invoiceId
|
||||
? `invoice-${invoice.invoiceId}`
|
||||
: `invoice-${invoice.cardId}-${invoice.period}`;
|
||||
const notificationId = invoice.invoiceId
|
||||
? `invoice-${invoice.invoiceId}`
|
||||
: `invoice-${invoice.cardId}-${invoice.period}`;
|
||||
|
||||
notifications.push({
|
||||
id: notificationId,
|
||||
type: "invoice",
|
||||
name: invoice.cardName,
|
||||
dueDate,
|
||||
status: invoiceIsOverdue ? "overdue" : "due_soon",
|
||||
amount: Math.abs(amount),
|
||||
period: invoice.period,
|
||||
showAmount: invoiceIsOverdue,
|
||||
});
|
||||
}
|
||||
notifications.push({
|
||||
id: notificationId,
|
||||
type: "invoice",
|
||||
name: invoice.cardName,
|
||||
dueDate,
|
||||
status: invoiceIsOverdue ? "overdue" : "due_soon",
|
||||
amount: Math.abs(amount),
|
||||
period: invoice.period,
|
||||
showAmount: invoiceIsOverdue,
|
||||
});
|
||||
}
|
||||
|
||||
// Processar boletos
|
||||
for (const boleto of boletosRows) {
|
||||
if (!boleto.dueDate) continue;
|
||||
// Processar boletos
|
||||
for (const boleto of boletosRows) {
|
||||
if (!boleto.dueDate) continue;
|
||||
|
||||
// Converter para string no formato YYYY-MM-DD (UTC)
|
||||
const dueDate =
|
||||
boleto.dueDate instanceof Date
|
||||
? `${boleto.dueDate.getUTCFullYear()}-${String(boleto.dueDate.getUTCMonth() + 1).padStart(2, "0")}-${String(boleto.dueDate.getUTCDate()).padStart(2, "0")}`
|
||||
: boleto.dueDate;
|
||||
// Converter para string no formato YYYY-MM-DD (UTC)
|
||||
const dueDate =
|
||||
boleto.dueDate instanceof Date
|
||||
? `${boleto.dueDate.getUTCFullYear()}-${String(boleto.dueDate.getUTCMonth() + 1).padStart(2, "0")}-${String(boleto.dueDate.getUTCDate()).padStart(2, "0")}`
|
||||
: boleto.dueDate;
|
||||
|
||||
const boletoIsOverdue = isOverdue(dueDate, today);
|
||||
const boletoIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
|
||||
const boletoIsOverdue = isOverdue(dueDate, today);
|
||||
const boletoIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
|
||||
|
||||
const isOldPeriod = boleto.period < currentPeriod;
|
||||
const isCurrentPeriod = boleto.period === currentPeriod;
|
||||
const isOldPeriod = boleto.period < currentPeriod;
|
||||
const isCurrentPeriod = boleto.period === currentPeriod;
|
||||
|
||||
// Período anterior: incluir todos (sempre atrasado)
|
||||
if (isOldPeriod) {
|
||||
const amount =
|
||||
typeof boleto.amount === "number"
|
||||
? boleto.amount
|
||||
: Number(boleto.amount) || 0;
|
||||
// Período anterior: incluir todos (sempre atrasado)
|
||||
if (isOldPeriod) {
|
||||
const amount =
|
||||
typeof boleto.amount === "number"
|
||||
? boleto.amount
|
||||
: Number(boleto.amount) || 0;
|
||||
|
||||
notifications.push({
|
||||
id: `boleto-${boleto.id}`,
|
||||
type: "boleto",
|
||||
name: boleto.name,
|
||||
dueDate,
|
||||
status: "overdue",
|
||||
amount: Math.abs(amount),
|
||||
period: boleto.period,
|
||||
showAmount: true, // Mostrar valor para períodos anteriores
|
||||
});
|
||||
}
|
||||
notifications.push({
|
||||
id: `boleto-${boleto.id}`,
|
||||
type: "boleto",
|
||||
name: boleto.name,
|
||||
dueDate,
|
||||
status: "overdue",
|
||||
amount: Math.abs(amount),
|
||||
period: boleto.period,
|
||||
showAmount: true, // Mostrar valor para períodos anteriores
|
||||
});
|
||||
}
|
||||
|
||||
// Período atual: incluir atrasados e os que vencem em breve (sem valor)
|
||||
else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) {
|
||||
const status: NotificationType = boletoIsOverdue ? "overdue" : "due_soon";
|
||||
const amount =
|
||||
typeof boleto.amount === "number"
|
||||
? boleto.amount
|
||||
: Number(boleto.amount) || 0;
|
||||
// Período atual: incluir atrasados e os que vencem em breve (sem valor)
|
||||
else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) {
|
||||
const status: NotificationType = boletoIsOverdue ? "overdue" : "due_soon";
|
||||
const amount =
|
||||
typeof boleto.amount === "number"
|
||||
? boleto.amount
|
||||
: Number(boleto.amount) || 0;
|
||||
|
||||
notifications.push({
|
||||
id: `boleto-${boleto.id}`,
|
||||
type: "boleto",
|
||||
name: boleto.name,
|
||||
dueDate,
|
||||
status,
|
||||
amount: Math.abs(amount),
|
||||
period: boleto.period,
|
||||
showAmount: boletoIsOverdue,
|
||||
});
|
||||
}
|
||||
}
|
||||
notifications.push({
|
||||
id: `boleto-${boleto.id}`,
|
||||
type: "boleto",
|
||||
name: boleto.name,
|
||||
dueDate,
|
||||
status,
|
||||
amount: Math.abs(amount),
|
||||
period: boleto.period,
|
||||
showAmount: boletoIsOverdue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ordenar: atrasados primeiro, depois por data de vencimento
|
||||
notifications.sort((a, b) => {
|
||||
if (a.status === "overdue" && b.status !== "overdue") return -1;
|
||||
if (a.status !== "overdue" && b.status === "overdue") return 1;
|
||||
return a.dueDate.localeCompare(b.dueDate);
|
||||
});
|
||||
// Ordenar: atrasados primeiro, depois por data de vencimento
|
||||
notifications.sort((a, b) => {
|
||||
if (a.status === "overdue" && b.status !== "overdue") return -1;
|
||||
if (a.status !== "overdue" && b.status === "overdue") return 1;
|
||||
return a.dueDate.localeCompare(b.dueDate);
|
||||
});
|
||||
|
||||
return {
|
||||
notifications,
|
||||
totalCount: notifications.length,
|
||||
};
|
||||
return {
|
||||
notifications,
|
||||
totalCount: notifications.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,79 +1,79 @@
|
||||
import { and, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { and, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
|
||||
export type PaymentConditionSummary = {
|
||||
condition: string;
|
||||
amount: number;
|
||||
percentage: number;
|
||||
transactions: number;
|
||||
condition: string;
|
||||
amount: number;
|
||||
percentage: number;
|
||||
transactions: number;
|
||||
};
|
||||
|
||||
export type PaymentConditionsData = {
|
||||
conditions: PaymentConditionSummary[];
|
||||
conditions: PaymentConditionSummary[];
|
||||
};
|
||||
|
||||
export async function fetchPaymentConditions(
|
||||
userId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<PaymentConditionsData> {
|
||||
const rows = await db
|
||||
.select({
|
||||
condition: lancamentos.condition,
|
||||
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
transactions: sql<number>`count(${lancamentos.id})`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(lancamentos.condition);
|
||||
const rows = await db
|
||||
.select({
|
||||
condition: lancamentos.condition,
|
||||
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
transactions: sql<number>`count(${lancamentos.id})`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.condition);
|
||||
|
||||
const summaries = rows.map((row) => {
|
||||
const totalAmount = Math.abs(toNumber(row.totalAmount));
|
||||
const transactions = Number(row.transactions ?? 0);
|
||||
const summaries = rows.map((row) => {
|
||||
const totalAmount = Math.abs(toNumber(row.totalAmount));
|
||||
const transactions = Number(row.transactions ?? 0);
|
||||
|
||||
return {
|
||||
condition: row.condition,
|
||||
amount: totalAmount,
|
||||
transactions,
|
||||
};
|
||||
});
|
||||
return {
|
||||
condition: row.condition,
|
||||
amount: totalAmount,
|
||||
transactions,
|
||||
};
|
||||
});
|
||||
|
||||
const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0);
|
||||
const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0);
|
||||
|
||||
const conditions = summaries
|
||||
.map((item) => ({
|
||||
condition: item.condition,
|
||||
amount: item.amount,
|
||||
transactions: item.transactions,
|
||||
percentage:
|
||||
overallTotal > 0
|
||||
? Number(((item.amount / overallTotal) * 100).toFixed(2))
|
||||
: 0,
|
||||
}))
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
const conditions = summaries
|
||||
.map((item) => ({
|
||||
condition: item.condition,
|
||||
amount: item.amount,
|
||||
transactions: item.transactions,
|
||||
percentage:
|
||||
overallTotal > 0
|
||||
? Number(((item.amount / overallTotal) * 100).toFixed(2))
|
||||
: 0,
|
||||
}))
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
|
||||
return {
|
||||
conditions,
|
||||
};
|
||||
return {
|
||||
conditions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,79 +1,79 @@
|
||||
import { and, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { and, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
|
||||
export type PaymentMethodSummary = {
|
||||
paymentMethod: string;
|
||||
amount: number;
|
||||
percentage: number;
|
||||
transactions: number;
|
||||
paymentMethod: string;
|
||||
amount: number;
|
||||
percentage: number;
|
||||
transactions: number;
|
||||
};
|
||||
|
||||
export type PaymentMethodsData = {
|
||||
methods: PaymentMethodSummary[];
|
||||
methods: PaymentMethodSummary[];
|
||||
};
|
||||
|
||||
export async function fetchPaymentMethods(
|
||||
userId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<PaymentMethodsData> {
|
||||
const rows = await db
|
||||
.select({
|
||||
paymentMethod: lancamentos.paymentMethod,
|
||||
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
transactions: sql<number>`count(${lancamentos.id})`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(lancamentos.paymentMethod);
|
||||
const rows = await db
|
||||
.select({
|
||||
paymentMethod: lancamentos.paymentMethod,
|
||||
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
transactions: sql<number>`count(${lancamentos.id})`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.paymentMethod);
|
||||
|
||||
const summaries = rows.map((row) => {
|
||||
const amount = Math.abs(toNumber(row.totalAmount));
|
||||
const transactions = Number(row.transactions ?? 0);
|
||||
const summaries = rows.map((row) => {
|
||||
const amount = Math.abs(toNumber(row.totalAmount));
|
||||
const transactions = Number(row.transactions ?? 0);
|
||||
|
||||
return {
|
||||
paymentMethod: row.paymentMethod,
|
||||
amount,
|
||||
transactions,
|
||||
};
|
||||
});
|
||||
return {
|
||||
paymentMethod: row.paymentMethod,
|
||||
amount,
|
||||
transactions,
|
||||
};
|
||||
});
|
||||
|
||||
const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0);
|
||||
const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0);
|
||||
|
||||
const methods = summaries
|
||||
.map((item) => ({
|
||||
paymentMethod: item.paymentMethod,
|
||||
amount: item.amount,
|
||||
transactions: item.transactions,
|
||||
percentage:
|
||||
overallTotal > 0
|
||||
? Number(((item.amount / overallTotal) * 100).toFixed(2))
|
||||
: 0,
|
||||
}))
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
const methods = summaries
|
||||
.map((item) => ({
|
||||
paymentMethod: item.paymentMethod,
|
||||
amount: item.amount,
|
||||
transactions: item.transactions,
|
||||
percentage:
|
||||
overallTotal > 0
|
||||
? Number(((item.amount / overallTotal) * 100).toFixed(2))
|
||||
: 0,
|
||||
}))
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
|
||||
return {
|
||||
methods,
|
||||
};
|
||||
return {
|
||||
methods,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { lancamentos, pagadores } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export type PaymentStatusCategory = {
|
||||
total: number;
|
||||
confirmed: number;
|
||||
pending: number;
|
||||
total: number;
|
||||
confirmed: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
export type PaymentStatusData = {
|
||||
income: PaymentStatusCategory;
|
||||
expenses: PaymentStatusCategory;
|
||||
income: PaymentStatusCategory;
|
||||
expenses: PaymentStatusCategory;
|
||||
};
|
||||
|
||||
export async function fetchPaymentStatus(
|
||||
userId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<PaymentStatusData> {
|
||||
// Busca receitas confirmadas e pendentes para o período do pagador admin
|
||||
// Exclui lançamentos de pagamento de fatura (para evitar contagem duplicada)
|
||||
const incomeResult = await db
|
||||
.select({
|
||||
confirmed: sql<number>`
|
||||
// Busca receitas confirmadas e pendentes para o período do pagador admin
|
||||
// Exclui lançamentos de pagamento de fatura (para evitar contagem duplicada)
|
||||
const incomeResult = await db
|
||||
.select({
|
||||
confirmed: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
@@ -34,7 +34,7 @@ export async function fetchPaymentStatus(
|
||||
0
|
||||
)
|
||||
`,
|
||||
pending: sql<number>`
|
||||
pending: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
@@ -45,24 +45,24 @@ export async function fetchPaymentStatus(
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Receita"),
|
||||
eq(pagadores.role, "admin"),
|
||||
sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`
|
||||
)
|
||||
);
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Receita"),
|
||||
eq(pagadores.role, "admin"),
|
||||
sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
|
||||
),
|
||||
);
|
||||
|
||||
// Busca despesas confirmadas e pendentes para o período do pagador admin
|
||||
// Exclui lançamentos de pagamento de fatura (para evitar contagem duplicada)
|
||||
const expensesResult = await db
|
||||
.select({
|
||||
confirmed: sql<number>`
|
||||
// Busca despesas confirmadas e pendentes para o período do pagador admin
|
||||
// Exclui lançamentos de pagamento de fatura (para evitar contagem duplicada)
|
||||
const expensesResult = await db
|
||||
.select({
|
||||
confirmed: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
@@ -73,7 +73,7 @@ export async function fetchPaymentStatus(
|
||||
0
|
||||
)
|
||||
`,
|
||||
pending: sql<number>`
|
||||
pending: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
@@ -84,37 +84,37 @@ export async function fetchPaymentStatus(
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, "admin"),
|
||||
sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`
|
||||
)
|
||||
);
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, "admin"),
|
||||
sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
|
||||
),
|
||||
);
|
||||
|
||||
const incomeData = incomeResult[0] ?? { confirmed: 0, pending: 0 };
|
||||
const confirmedIncome = toNumber(incomeData.confirmed);
|
||||
const pendingIncome = toNumber(incomeData.pending);
|
||||
const incomeData = incomeResult[0] ?? { confirmed: 0, pending: 0 };
|
||||
const confirmedIncome = toNumber(incomeData.confirmed);
|
||||
const pendingIncome = toNumber(incomeData.pending);
|
||||
|
||||
const expensesData = expensesResult[0] ?? { confirmed: 0, pending: 0 };
|
||||
const confirmedExpenses = toNumber(expensesData.confirmed);
|
||||
const pendingExpenses = toNumber(expensesData.pending);
|
||||
const expensesData = expensesResult[0] ?? { confirmed: 0, pending: 0 };
|
||||
const confirmedExpenses = toNumber(expensesData.confirmed);
|
||||
const pendingExpenses = toNumber(expensesData.pending);
|
||||
|
||||
return {
|
||||
income: {
|
||||
total: confirmedIncome + pendingIncome,
|
||||
confirmed: confirmedIncome,
|
||||
pending: pendingIncome,
|
||||
},
|
||||
expenses: {
|
||||
total: confirmedExpenses + pendingExpenses,
|
||||
confirmed: confirmedExpenses,
|
||||
pending: pendingExpenses,
|
||||
},
|
||||
};
|
||||
return {
|
||||
income: {
|
||||
total: confirmedIncome + pendingIncome,
|
||||
confirmed: confirmedIncome,
|
||||
pending: pendingIncome,
|
||||
},
|
||||
expenses: {
|
||||
total: confirmedExpenses + pendingExpenses,
|
||||
confirmed: confirmedExpenses,
|
||||
pending: pendingExpenses,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,145 +1,145 @@
|
||||
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
cartoes,
|
||||
categorias,
|
||||
contas,
|
||||
lancamentos,
|
||||
pagadores,
|
||||
cartoes,
|
||||
categorias,
|
||||
contas,
|
||||
lancamentos,
|
||||
pagadores,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
|
||||
export type CategoryOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type CategoryTransaction = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
purchaseDate: Date;
|
||||
logo: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
purchaseDate: Date;
|
||||
logo: string | null;
|
||||
};
|
||||
|
||||
export type PurchasesByCategoryData = {
|
||||
categories: CategoryOption[];
|
||||
transactionsByCategory: Record<string, CategoryTransaction[]>;
|
||||
categories: CategoryOption[];
|
||||
transactionsByCategory: Record<string, CategoryTransaction[]>;
|
||||
};
|
||||
|
||||
const shouldIncludeTransaction = (name: string) => {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
const normalized = name.trim().toLowerCase();
|
||||
|
||||
if (normalized === "saldo inicial") {
|
||||
return false;
|
||||
}
|
||||
if (normalized === "saldo inicial") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.includes("fatura")) {
|
||||
return false;
|
||||
}
|
||||
if (normalized.includes("fatura")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return true;
|
||||
};
|
||||
|
||||
export async function fetchPurchasesByCategory(
|
||||
userId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<PurchasesByCategoryData> {
|
||||
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,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
inArray(categorias.type, ["despesa", "receita"]),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate));
|
||||
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,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
inArray(categorias.type, ["despesa", "receita"]),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate));
|
||||
|
||||
const transactionsByCategory: Record<string, CategoryTransaction[]> = {};
|
||||
const categoriesMap = new Map<string, CategoryOption>();
|
||||
const transactionsByCategory: Record<string, CategoryTransaction[]> = {};
|
||||
const categoriesMap = new Map<string, CategoryOption>();
|
||||
|
||||
for (const row of transactionsRows) {
|
||||
const categoryId = row.categoryId;
|
||||
for (const row of transactionsRows) {
|
||||
const categoryId = row.categoryId;
|
||||
|
||||
if (!categoryId) {
|
||||
continue;
|
||||
}
|
||||
if (!categoryId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!shouldIncludeTransaction(row.name)) {
|
||||
continue;
|
||||
}
|
||||
if (!shouldIncludeTransaction(row.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Adiciona a categoria ao mapa se ainda não existir
|
||||
if (!categoriesMap.has(categoryId)) {
|
||||
categoriesMap.set(categoryId, {
|
||||
id: categoryId,
|
||||
name: row.categoryName,
|
||||
type: row.categoryType,
|
||||
});
|
||||
}
|
||||
// Adiciona a categoria ao mapa se ainda não existir
|
||||
if (!categoriesMap.has(categoryId)) {
|
||||
categoriesMap.set(categoryId, {
|
||||
id: categoryId,
|
||||
name: row.categoryName,
|
||||
type: row.categoryType,
|
||||
});
|
||||
}
|
||||
|
||||
const entry: CategoryTransaction = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
purchaseDate: row.purchaseDate,
|
||||
logo: row.cardLogo ?? row.accountLogo ?? null,
|
||||
};
|
||||
const entry: CategoryTransaction = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
purchaseDate: row.purchaseDate,
|
||||
logo: row.cardLogo ?? row.accountLogo ?? null,
|
||||
};
|
||||
|
||||
if (!transactionsByCategory[categoryId]) {
|
||||
transactionsByCategory[categoryId] = [];
|
||||
}
|
||||
if (!transactionsByCategory[categoryId]) {
|
||||
transactionsByCategory[categoryId] = [];
|
||||
}
|
||||
|
||||
const categoryTransactions = transactionsByCategory[categoryId];
|
||||
if (categoryTransactions && categoryTransactions.length < 10) {
|
||||
categoryTransactions.push(entry);
|
||||
}
|
||||
}
|
||||
const categoryTransactions = transactionsByCategory[categoryId];
|
||||
if (categoryTransactions && categoryTransactions.length < 10) {
|
||||
categoryTransactions.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Ordena as categorias: receitas primeiro, depois despesas (alfabeticamente dentro de cada tipo)
|
||||
const categories = Array.from(categoriesMap.values()).sort((a, b) => {
|
||||
// Receita vem antes de despesa
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "receita" ? -1 : 1;
|
||||
}
|
||||
// Dentro do mesmo tipo, ordena alfabeticamente
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
// Ordena as categorias: receitas primeiro, depois despesas (alfabeticamente dentro de cada tipo)
|
||||
const categories = Array.from(categoriesMap.values()).sort((a, b) => {
|
||||
// Receita vem antes de despesa
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "receita" ? -1 : 1;
|
||||
}
|
||||
// Dentro do mesmo tipo, ordena alfabeticamente
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return {
|
||||
categories,
|
||||
transactionsByCategory,
|
||||
};
|
||||
return {
|
||||
categories,
|
||||
transactionsByCategory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,71 +1,74 @@
|
||||
import { lancamentos, pagadores, cartoes, contas } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { eq, and, sql, desc, or, isNull } from "drizzle-orm";
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { cartoes, contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
|
||||
export type RecentTransaction = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
purchaseDate: Date;
|
||||
cardLogo: string | null;
|
||||
accountLogo: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
purchaseDate: Date;
|
||||
cardLogo: string | null;
|
||||
accountLogo: string | null;
|
||||
};
|
||||
|
||||
export type RecentTransactionsData = {
|
||||
transactions: RecentTransaction[];
|
||||
transactions: RecentTransaction[];
|
||||
};
|
||||
|
||||
export async function fetchRecentTransactions(
|
||||
userId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<RecentTransactionsData> {
|
||||
const results = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
purchaseDate: lancamentos.purchaseDate,
|
||||
cardLogo: cartoes.logo,
|
||||
accountLogo: contas.logo,
|
||||
note: lancamentos.note,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt))
|
||||
.limit(5);
|
||||
const results = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
purchaseDate: lancamentos.purchaseDate,
|
||||
cardLogo: cartoes.logo,
|
||||
accountLogo: contas.logo,
|
||||
note: lancamentos.note,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt))
|
||||
.limit(5);
|
||||
|
||||
const transactions = results.map((row): RecentTransaction => {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
purchaseDate: row.purchaseDate,
|
||||
cardLogo: row.cardLogo,
|
||||
accountLogo: row.accountLogo,
|
||||
};
|
||||
});
|
||||
const transactions = results.map((row): RecentTransaction => {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
purchaseDate: row.purchaseDate,
|
||||
cardLogo: row.cardLogo,
|
||||
accountLogo: row.accountLogo,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
transactions,
|
||||
};
|
||||
return {
|
||||
transactions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,86 +1,86 @@
|
||||
import { and, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { cartoes, contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { and, eq, isNull, or, sql } from "drizzle-orm";
|
||||
|
||||
export type TopEstablishment = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
occurrences: number;
|
||||
logo: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
occurrences: number;
|
||||
logo: string | null;
|
||||
};
|
||||
|
||||
export type TopEstablishmentsData = {
|
||||
establishments: TopEstablishment[];
|
||||
establishments: TopEstablishment[];
|
||||
};
|
||||
|
||||
const shouldIncludeEstablishment = (name: string) => {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
const normalized = name.trim().toLowerCase();
|
||||
|
||||
if (normalized === "saldo inicial") {
|
||||
return false;
|
||||
}
|
||||
if (normalized === "saldo inicial") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.includes("fatura")) {
|
||||
return false;
|
||||
}
|
||||
if (normalized.includes("fatura")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return true;
|
||||
};
|
||||
|
||||
export async function fetchTopEstablishments(
|
||||
userId: string,
|
||||
period: string
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<TopEstablishmentsData> {
|
||||
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}))`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(lancamentos.name)
|
||||
.orderBy(sql`ABS(sum(${lancamentos.amount})) DESC`)
|
||||
.limit(10);
|
||||
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}))`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.name)
|
||||
.orderBy(sql`ABS(sum(${lancamentos.amount})) DESC`)
|
||||
.limit(10);
|
||||
|
||||
const establishments = rows
|
||||
.filter((row) => shouldIncludeEstablishment(row.name))
|
||||
.map(
|
||||
(row): TopEstablishment => ({
|
||||
id: row.name,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.totalAmount)),
|
||||
occurrences: Number(row.occurrences ?? 0),
|
||||
logo: row.logo ?? null,
|
||||
})
|
||||
);
|
||||
const establishments = rows
|
||||
.filter((row) => shouldIncludeEstablishment(row.name))
|
||||
.map(
|
||||
(row): TopEstablishment => ({
|
||||
id: row.name,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.totalAmount)),
|
||||
occurrences: Number(row.occurrences ?? 0),
|
||||
logo: row.logo ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
establishments,
|
||||
};
|
||||
return {
|
||||
establishments,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,87 +1,87 @@
|
||||
"use server";
|
||||
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { db, schema } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { db, schema } from "@/lib/db";
|
||||
|
||||
export type WidgetPreferences = {
|
||||
order: string[];
|
||||
hidden: string[];
|
||||
order: string[];
|
||||
hidden: string[];
|
||||
};
|
||||
|
||||
export async function updateWidgetPreferences(
|
||||
preferences: WidgetPreferences,
|
||||
preferences: WidgetPreferences,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
// Check if preferences exist
|
||||
const existing = await db
|
||||
.select({ id: schema.userPreferences.id })
|
||||
.from(schema.userPreferences)
|
||||
.where(eq(schema.userPreferences.userId, user.id))
|
||||
.limit(1);
|
||||
// Check if preferences exist
|
||||
const existing = await db
|
||||
.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.userPreferences)
|
||||
.set({
|
||||
dashboardWidgets: preferences,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.userPreferences.userId, user.id));
|
||||
} else {
|
||||
await db.insert(schema.userPreferences).values({
|
||||
userId: user.id,
|
||||
dashboardWidgets: preferences,
|
||||
});
|
||||
}
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(schema.userPreferences)
|
||||
.set({
|
||||
dashboardWidgets: preferences,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.userPreferences.userId, user.id));
|
||||
} else {
|
||||
await db.insert(schema.userPreferences).values({
|
||||
userId: user.id,
|
||||
dashboardWidgets: preferences,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error updating widget preferences:", error);
|
||||
return { success: false, error: "Erro ao salvar preferências" };
|
||||
}
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error updating widget preferences:", error);
|
||||
return { success: false, error: "Erro ao salvar preferências" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetWidgetPreferences(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
await db
|
||||
.update(schema.userPreferences)
|
||||
.set({
|
||||
dashboardWidgets: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.userPreferences.userId, user.id));
|
||||
await db
|
||||
.update(schema.userPreferences)
|
||||
.set({
|
||||
dashboardWidgets: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.userPreferences.userId, user.id));
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error resetting widget preferences:", error);
|
||||
return { success: false, error: "Erro ao resetar preferências" };
|
||||
}
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error resetting widget preferences:", error);
|
||||
return { success: false, error: "Erro ao resetar preferências" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWidgetPreferences(): Promise<WidgetPreferences | null> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
const result = await db
|
||||
.select({ dashboardWidgets: schema.userPreferences.dashboardWidgets })
|
||||
.from(schema.userPreferences)
|
||||
.where(eq(schema.userPreferences.userId, user.id))
|
||||
.limit(1);
|
||||
const result = await db
|
||||
.select({ dashboardWidgets: schema.userPreferences.dashboardWidgets })
|
||||
.from(schema.userPreferences)
|
||||
.where(eq(schema.userPreferences.userId, user.id))
|
||||
.limit(1);
|
||||
|
||||
return result[0]?.dashboardWidgets ?? null;
|
||||
} catch (error) {
|
||||
console.error("Error getting widget preferences:", error);
|
||||
return null;
|
||||
}
|
||||
return result[0]?.dashboardWidgets ?? null;
|
||||
} catch (error) {
|
||||
console.error("Error getting widget preferences:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
import {
|
||||
RiArrowRightLine,
|
||||
RiArrowUpDoubleLine,
|
||||
RiBarChartBoxLine,
|
||||
RiBarcodeLine,
|
||||
RiBillLine,
|
||||
RiExchangeLine,
|
||||
RiLineChartLine,
|
||||
RiMoneyDollarCircleLine,
|
||||
RiNumbersLine,
|
||||
RiPieChartLine,
|
||||
RiRefreshLine,
|
||||
RiSlideshowLine,
|
||||
RiStore2Line,
|
||||
RiStore3Line,
|
||||
RiWallet3Line,
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { BoletosWidget } from "@/components/dashboard/boletos-widget";
|
||||
import { ExpensesByCategoryWidgetWithChart } from "@/components/dashboard/expenses-by-category-widget-with-chart";
|
||||
import { IncomeByCategoryWidgetWithChart } from "@/components/dashboard/income-by-category-widget-with-chart";
|
||||
@@ -13,201 +32,182 @@ import { RecentTransactionsWidget } from "@/components/dashboard/recent-transact
|
||||
import { RecurringExpensesWidget } from "@/components/dashboard/recurring-expenses-widget";
|
||||
import { TopEstablishmentsWidget } from "@/components/dashboard/top-establishments-widget";
|
||||
import { TopExpensesWidget } from "@/components/dashboard/top-expenses-widget";
|
||||
import {
|
||||
RiArrowRightLine,
|
||||
RiArrowUpDoubleLine,
|
||||
RiBarChartBoxLine,
|
||||
RiBarcodeLine,
|
||||
RiBillLine,
|
||||
RiExchangeLine,
|
||||
RiLineChartLine,
|
||||
RiMoneyDollarCircleLine,
|
||||
RiNumbersLine,
|
||||
RiPieChartLine,
|
||||
RiRefreshLine,
|
||||
RiSlideshowLine,
|
||||
RiStore2Line,
|
||||
RiStore3Line,
|
||||
RiWallet3Line,
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import type { DashboardData } from "./fetch-dashboard-data";
|
||||
|
||||
export type WidgetConfig = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: ReactNode;
|
||||
component: (props: { data: DashboardData; period: string }) => ReactNode;
|
||||
action?: ReactNode;
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: ReactNode;
|
||||
component: (props: { data: DashboardData; period: string }) => ReactNode;
|
||||
action?: ReactNode;
|
||||
};
|
||||
|
||||
export const widgetsConfig: WidgetConfig[] = [
|
||||
{
|
||||
id: "my-accounts",
|
||||
title: "Minhas Contas",
|
||||
subtitle: "Saldo consolidado disponível",
|
||||
icon: <RiBarChartBoxLine className="size-4" />,
|
||||
component: ({ data, period }) => (
|
||||
<MyAccountsWidget
|
||||
accounts={data.accountsSnapshot.accounts}
|
||||
totalBalance={data.accountsSnapshot.totalBalance}
|
||||
period={period}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "invoices",
|
||||
title: "Faturas",
|
||||
subtitle: "Resumo das faturas do período",
|
||||
icon: <RiBillLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<InvoicesWidget invoices={data.invoicesSnapshot.invoices} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "boletos",
|
||||
title: "Boletos",
|
||||
subtitle: "Controle de boletos do período",
|
||||
icon: <RiBarcodeLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<BoletosWidget boletos={data.boletosSnapshot.boletos} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "payment-status",
|
||||
title: "Status de Pagamento",
|
||||
subtitle: "Valores Confirmados E Pendentes",
|
||||
icon: <RiWallet3Line className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<PaymentStatusWidget data={data.paymentStatusData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "income-expense-balance",
|
||||
title: "Receita, Despesa e Balanço",
|
||||
subtitle: "Últimos 6 Meses",
|
||||
icon: <RiLineChartLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<IncomeExpenseBalanceWidget data={data.incomeExpenseBalanceData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "recent-transactions",
|
||||
title: "Lançamentos Recentes",
|
||||
subtitle: "Últimas 5 despesas registradas",
|
||||
icon: <RiExchangeLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<RecentTransactionsWidget data={data.recentTransactionsData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "payment-conditions",
|
||||
title: "Condições de Pagamentos",
|
||||
subtitle: "Análise das condições",
|
||||
icon: <RiSlideshowLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<PaymentConditionsWidget data={data.paymentConditionsData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "payment-methods",
|
||||
title: "Formas de Pagamento",
|
||||
subtitle: "Distribuição das despesas",
|
||||
icon: <RiMoneyDollarCircleLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<PaymentMethodsWidget data={data.paymentMethodsData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "recurring-expenses",
|
||||
title: "Lançamentos Recorrentes",
|
||||
subtitle: "Despesas recorrentes do período",
|
||||
icon: <RiRefreshLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<RecurringExpensesWidget data={data.recurringExpensesData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "installment-expenses",
|
||||
title: "Lançamentos Parcelados",
|
||||
subtitle: "Acompanhe as parcelas abertas",
|
||||
icon: <RiNumbersLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<InstallmentExpensesWidget data={data.installmentExpensesData} />
|
||||
),
|
||||
action: (
|
||||
<Link
|
||||
href="/dashboard/analise-parcelas"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Análise
|
||||
<RiArrowRightLine className="size-4" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "top-expenses",
|
||||
title: "Maiores Gastos do Mês",
|
||||
subtitle: "Top 10 Despesas",
|
||||
icon: <RiArrowUpDoubleLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<TopExpensesWidget
|
||||
allExpenses={data.topExpensesAll}
|
||||
cardOnlyExpenses={data.topExpensesCardOnly}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "top-establishments",
|
||||
title: "Top Estabelecimentos",
|
||||
subtitle: "Frequência de gastos no período",
|
||||
icon: <RiStore2Line className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<TopEstablishmentsWidget data={data.topEstablishmentsData} />
|
||||
),
|
||||
action: (
|
||||
<Link
|
||||
href="/top-estabelecimentos"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Ver mais
|
||||
<RiArrowRightLine className="size-4" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "purchases-by-category",
|
||||
title: "Lançamentos por Categorias",
|
||||
subtitle: "Distribuição de lançamentos por categoria",
|
||||
icon: <RiStore3Line className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<PurchasesByCategoryWidget data={data.purchasesByCategoryData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "income-by-category",
|
||||
title: "Categorias por Receitas",
|
||||
subtitle: "Distribuição de receitas por categoria",
|
||||
icon: <RiPieChartLine className="size-4" />,
|
||||
component: ({ data, period }) => (
|
||||
<IncomeByCategoryWidgetWithChart
|
||||
data={data.incomeByCategoryData}
|
||||
period={period}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "expenses-by-category",
|
||||
title: "Categorias por Despesas",
|
||||
subtitle: "Distribuição de despesas por categoria",
|
||||
icon: <RiPieChartLine className="size-4" />,
|
||||
component: ({ data, period }) => (
|
||||
<ExpensesByCategoryWidgetWithChart
|
||||
data={data.expensesByCategoryData}
|
||||
period={period}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "my-accounts",
|
||||
title: "Minhas Contas",
|
||||
subtitle: "Saldo consolidado disponível",
|
||||
icon: <RiBarChartBoxLine className="size-4" />,
|
||||
component: ({ data, period }) => (
|
||||
<MyAccountsWidget
|
||||
accounts={data.accountsSnapshot.accounts}
|
||||
totalBalance={data.accountsSnapshot.totalBalance}
|
||||
period={period}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "invoices",
|
||||
title: "Faturas",
|
||||
subtitle: "Resumo das faturas do período",
|
||||
icon: <RiBillLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<InvoicesWidget invoices={data.invoicesSnapshot.invoices} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "boletos",
|
||||
title: "Boletos",
|
||||
subtitle: "Controle de boletos do período",
|
||||
icon: <RiBarcodeLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<BoletosWidget boletos={data.boletosSnapshot.boletos} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "payment-status",
|
||||
title: "Status de Pagamento",
|
||||
subtitle: "Valores Confirmados E Pendentes",
|
||||
icon: <RiWallet3Line className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<PaymentStatusWidget data={data.paymentStatusData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "income-expense-balance",
|
||||
title: "Receita, Despesa e Balanço",
|
||||
subtitle: "Últimos 6 Meses",
|
||||
icon: <RiLineChartLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<IncomeExpenseBalanceWidget data={data.incomeExpenseBalanceData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "recent-transactions",
|
||||
title: "Lançamentos Recentes",
|
||||
subtitle: "Últimas 5 despesas registradas",
|
||||
icon: <RiExchangeLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<RecentTransactionsWidget data={data.recentTransactionsData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "payment-conditions",
|
||||
title: "Condições de Pagamentos",
|
||||
subtitle: "Análise das condições",
|
||||
icon: <RiSlideshowLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<PaymentConditionsWidget data={data.paymentConditionsData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "payment-methods",
|
||||
title: "Formas de Pagamento",
|
||||
subtitle: "Distribuição das despesas",
|
||||
icon: <RiMoneyDollarCircleLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<PaymentMethodsWidget data={data.paymentMethodsData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "recurring-expenses",
|
||||
title: "Lançamentos Recorrentes",
|
||||
subtitle: "Despesas recorrentes do período",
|
||||
icon: <RiRefreshLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<RecurringExpensesWidget data={data.recurringExpensesData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "installment-expenses",
|
||||
title: "Lançamentos Parcelados",
|
||||
subtitle: "Acompanhe as parcelas abertas",
|
||||
icon: <RiNumbersLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<InstallmentExpensesWidget data={data.installmentExpensesData} />
|
||||
),
|
||||
action: (
|
||||
<Link
|
||||
href="/dashboard/analise-parcelas"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Análise
|
||||
<RiArrowRightLine className="size-4" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "top-expenses",
|
||||
title: "Maiores Gastos do Mês",
|
||||
subtitle: "Top 10 Despesas",
|
||||
icon: <RiArrowUpDoubleLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<TopExpensesWidget
|
||||
allExpenses={data.topExpensesAll}
|
||||
cardOnlyExpenses={data.topExpensesCardOnly}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "top-establishments",
|
||||
title: "Top Estabelecimentos",
|
||||
subtitle: "Frequência de gastos no período",
|
||||
icon: <RiStore2Line className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<TopEstablishmentsWidget data={data.topEstablishmentsData} />
|
||||
),
|
||||
action: (
|
||||
<Link
|
||||
href="/top-estabelecimentos"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Ver mais
|
||||
<RiArrowRightLine className="size-4" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "purchases-by-category",
|
||||
title: "Lançamentos por Categorias",
|
||||
subtitle: "Distribuição de lançamentos por categoria",
|
||||
icon: <RiStore3Line className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<PurchasesByCategoryWidget data={data.purchasesByCategoryData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "income-by-category",
|
||||
title: "Categorias por Receitas",
|
||||
subtitle: "Distribuição de receitas por categoria",
|
||||
icon: <RiPieChartLine className="size-4" />,
|
||||
component: ({ data, period }) => (
|
||||
<IncomeByCategoryWidgetWithChart
|
||||
data={data.incomeByCategoryData}
|
||||
period={period}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "expenses-by-category",
|
||||
title: "Categorias por Despesas",
|
||||
subtitle: "Distribuição de despesas por categoria",
|
||||
icon: <RiPieChartLine className="size-4" />,
|
||||
component: ({ data, period }) => (
|
||||
<ExpensesByCategoryWidgetWithChart
|
||||
data={data.expensesByCategoryData}
|
||||
period={period}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user