refactor(dashboard): reorganiza widgets e remove magnet-lines

This commit is contained in:
Felipe Coutinho
2026-03-09 17:12:44 +00:00
parent 3e06a1d056
commit 69da27276c
106 changed files with 6072 additions and 3601 deletions

View File

@@ -1,9 +1,9 @@
import { and, eq, sql } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { safeToNumber as toNumber } from "@/lib/utils/number";
type RawDashboardAccount = {
id: string;

View File

@@ -0,0 +1,53 @@
import type { DashboardBill } from "@/lib/dashboard/bills";
import type { PaymentDialogState } from "@/lib/dashboard/use-payment-dialog-controller";
import { getBusinessDateString, isDateOnlyPast } from "@/lib/utils/date";
import {
buildFinancialStatusLabel,
formatFinancialDateLabel,
} from "@/lib/utils/financial-dates";
export type BillDialogState = PaymentDialogState;
export type BillStatusDateItem = Pick<
DashboardBill,
"dueDate" | "boletoPaymentDate" | "isSettled"
>;
export const formatBillDateLabel = (value: string | null, prefix?: string) => {
return formatFinancialDateLabel(value, prefix);
};
export const buildBillStatusLabel = (bill: BillStatusDateItem) => {
return buildFinancialStatusLabel({
isSettled: bill.isSettled,
dueDate: bill.dueDate,
paidAt: bill.boletoPaymentDate,
});
};
export const getCurrentBillDateString = () => getBusinessDateString();
export const isBillOverdue = (bill: DashboardBill) => {
if (bill.isSettled || !bill.dueDate) {
return false;
}
return isDateOnlyPast(bill.dueDate);
};
export const getBillStatusBadgeVariant = (
statusLabel: string,
): "success" | "info" => {
if (statusLabel.toLowerCase() === "pendente") {
return "info";
}
return "success";
};
export const markBillAsSettled = (
bill: DashboardBill,
boletoPaymentDate: string,
): DashboardBill => ({
...bill,
isSettled: true,
boletoPaymentDate,
});

View File

@@ -2,13 +2,14 @@
import { and, asc, eq } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { toDateOnlyString } from "@/lib/utils/date";
import { safeToNumber as toNumber } from "@/lib/utils/number";
const PAYMENT_METHOD_BOLETO = "Boleto";
type RawDashboardBoleto = {
type RawDashboardBill = {
id: string;
name: string;
amount: string | number | null;
@@ -17,7 +18,7 @@ type RawDashboardBoleto = {
isSettled: boolean | null;
};
export type DashboardBoleto = {
export type DashboardBill = {
id: string;
name: string;
amount: number;
@@ -26,35 +27,19 @@ export type DashboardBoleto = {
isSettled: boolean;
};
export type DashboardBoletosSnapshot = {
boletos: DashboardBoleto[];
export type DashboardBillsSnapshot = {
bills: DashboardBill[];
totalPendingAmount: number;
pendingCount: number;
};
const toISODate = (value: Date | string | null) => {
if (!value) {
return null;
}
if (value instanceof Date) {
return value.toISOString().slice(0, 10);
}
if (typeof value === "string") {
return value;
}
return null;
};
export async function fetchDashboardBoletos(
export async function fetchDashboardBills(
userId: string,
period: string,
): Promise<DashboardBoletosSnapshot> {
): Promise<DashboardBillsSnapshot> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { boletos: [], totalPendingAmount: 0, pendingCount: 0 };
return { bills: [], totalPendingAmount: 0, pendingCount: 0 };
}
const rows = await db
@@ -81,14 +66,14 @@ export async function fetchDashboardBoletos(
asc(lancamentos.name),
);
const boletos = rows.map((row: RawDashboardBoleto): DashboardBoleto => {
const bills = rows.map((row: RawDashboardBill): DashboardBill => {
const amount = Math.abs(toNumber(row.amount));
return {
id: row.id,
name: row.name,
amount,
dueDate: toISODate(row.dueDate),
boletoPaymentDate: toISODate(row.boletoPaymentDate),
dueDate: toDateOnlyString(row.dueDate),
boletoPaymentDate: toDateOnlyString(row.boletoPaymentDate),
isSettled: Boolean(row.isSettled),
};
});
@@ -96,15 +81,15 @@ export async function fetchDashboardBoletos(
let totalPendingAmount = 0;
let pendingCount = 0;
for (const boleto of boletos) {
if (!boleto.isSettled) {
totalPendingAmount += boleto.amount;
for (const bill of bills) {
if (!bill.isSettled) {
totalPendingAmount += bill.amount;
pendingCount += 1;
}
}
return {
boletos,
bills,
totalPendingAmount,
pendingCount,
};

View File

@@ -0,0 +1,121 @@
import { calculatePercentageChange } from "@/lib/utils/math";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type DashboardCategoryBreakdownItem = {
categoryId: string;
categoryName: string;
categoryIcon: string | null;
currentAmount: number;
previousAmount: number;
percentageChange: number | null;
percentageOfTotal: number;
budgetAmount: number | null;
budgetUsedPercentage: number | null;
};
export type DashboardCategoryBreakdownData = {
categories: DashboardCategoryBreakdownItem[];
currentTotal: number;
previousTotal: number;
};
type CategoryBreakdownRow = {
categoryId: string;
categoryName: string;
categoryIcon: string | null;
period: string | null;
total: unknown;
};
type CategoryBudgetRow = {
categoriaId: string | null;
amount: unknown;
};
export function buildCategoryBreakdownData({
rows,
budgetRows,
period,
}: {
rows: CategoryBreakdownRow[];
budgetRows: CategoryBudgetRow[];
period: string;
}): DashboardCategoryBreakdownData {
const budgetMap = new Map<string, number>();
for (const row of budgetRows) {
if (row.categoriaId) {
budgetMap.set(row.categoriaId, toNumber(row.amount));
}
}
const categoryMap = new Map<
string,
{
name: string;
icon: string | null;
current: number;
previous: number;
}
>();
for (const row of rows) {
const entry = categoryMap.get(row.categoryId) ?? {
name: row.categoryName,
icon: row.categoryIcon,
current: 0,
previous: 0,
};
const amount = Math.abs(toNumber(row.total));
if (row.period === period) {
entry.current = amount;
} else {
entry.previous = amount;
}
categoryMap.set(row.categoryId, entry);
}
let currentTotal = 0;
let previousTotal = 0;
for (const entry of categoryMap.values()) {
currentTotal += entry.current;
previousTotal += entry.previous;
}
const categories: DashboardCategoryBreakdownItem[] = [];
for (const [categoryId, entry] of categoryMap) {
const percentageChange = calculatePercentageChange(
entry.current,
entry.previous,
);
const percentageOfTotal =
currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
const budgetAmount = budgetMap.get(categoryId) ?? null;
const budgetUsedPercentage =
budgetAmount && budgetAmount > 0
? (entry.current / budgetAmount) * 100
: null;
categories.push({
categoryId,
categoryName: entry.name,
categoryIcon: entry.icon,
currentAmount: entry.current,
previousAmount: entry.previous,
percentageChange,
percentageOfTotal,
budgetAmount,
budgetUsedPercentage,
});
}
categories.sort((a, b) => b.currentAmount - a.currentAmount);
return {
categories,
currentTotal,
previousTotal,
};
}

View File

@@ -5,10 +5,10 @@ import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/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 { safeToNumber as toNumber } from "@/lib/utils/number";
import { getPreviousPeriod } from "@/lib/utils/period";
type MappedLancamentos = ReturnType<typeof mapLancamentosData>;

View File

@@ -1,12 +1,15 @@
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/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { CATEGORY_COLORS } from "@/lib/utils/category-colors";
import { safeToNumber as toNumber } from "@/lib/utils/number";
import {
addMonthsToPeriod,
buildPeriodWindow,
formatPeriodMonthShort,
} from "@/lib/utils/period";
export type CategoryOption = {
id: string;
@@ -34,6 +37,19 @@ export type CategoryHistoryData = {
};
const CHART_COLORS = CATEGORY_COLORS;
type MonthlyCategoryRow = {
categoryId: string;
categoryName: string;
categoryIcon: string | null;
period: string;
totalAmount: unknown;
};
type UniqueCategory = {
id: string;
name: string;
icon: string | null;
};
export async function fetchAllCategories(
userId: string,
@@ -61,26 +77,16 @@ export async function fetchCategoryHistory(
currentPeriod: string,
): Promise<CategoryHistoryData> {
// 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);
// 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);
}
const periods = buildPeriodWindow(addMonthsToPeriod(currentPeriod, 1), 10);
const monthLabels = periods.map((period) =>
formatPeriodMonthShort(period).toUpperCase(),
);
// Fetch all categories for the selector
const allCategories = await fetchAllCategories(userId);
// Fetch monthly data for ALL categories with transactions
const monthlyDataQuery = await db
const monthlyDataQuery = (await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
@@ -112,7 +118,7 @@ export async function fetchCategoryHistory(
categorias.name,
categorias.icon,
lancamentos.period,
);
)) as MonthlyCategoryRow[];
if (monthlyDataQuery.length === 0) {
return {
@@ -124,8 +130,8 @@ export async function fetchCategoryHistory(
}
// Get unique categories from query results
const uniqueCategories = Array.from(
new Map(
const uniqueCategories: UniqueCategory[] = Array.from(
new Map<string, UniqueCategory>(
monthlyDataQuery.map((row) => [
row.categoryId,
{
@@ -178,15 +184,20 @@ export async function fetchCategoryHistory(
});
// Convert to chart data format
const chartData = monthLabels.map((month) => {
const dataPoint: Record<string, number | string> = { month };
const chartData: CategoryHistoryData["chartData"] = monthLabels.map(
(month) => {
const dataPoint: {
month: string;
[categoryName: string]: number | string;
} = { month };
categoriesMap.forEach((category) => {
dataPoint[category.name] = category.data[month];
});
categoriesMap.forEach((category) => {
dataPoint[category.name] = category.data[month];
});
return dataPoint;
});
return dataPoint;
},
);
return {
months: monthLabels,

View File

@@ -1,29 +1,20 @@
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import {
buildCategoryBreakdownData,
type DashboardCategoryBreakdownData,
type DashboardCategoryBreakdownItem,
} from "@/lib/dashboard/categories/category-breakdown";
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { calculatePercentageChange } from "@/lib/utils/math";
import { getPreviousPeriod } from "@/lib/utils/period";
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;
};
export type ExpensesByCategoryData = {
categories: CategoryExpenseItem[];
currentTotal: number;
previousTotal: number;
};
export type CategoryExpenseItem = DashboardCategoryBreakdownItem;
export type ExpensesByCategoryData = DashboardCategoryBreakdownData;
export async function fetchExpensesByCategory(
userId: string,
@@ -50,15 +41,11 @@ export async function fetchExpensesByCategory(
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
...buildDashboardAdminFilters({ userId, adminPagadorId }),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Despesa"),
eq(categorias.type, "despesa"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
excludeAutoInvoiceEntries(),
),
)
.groupBy(
@@ -76,85 +63,9 @@ export async function fetchExpensesByCategory(
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
]);
// Build budget lookup
const budgetMap = new Map<string, number>();
for (const row of budgetRows) {
if (row.categoriaId) {
budgetMap.set(row.categoriaId, toNumber(row.amount));
}
}
// Build category data from grouped results
const categoryMap = new Map<
string,
{
name: string;
icon: string | null;
current: number;
previous: number;
}
>();
for (const row of rows) {
const entry = categoryMap.get(row.categoryId) ?? {
name: row.categoryName,
icon: row.categoryIcon,
current: 0,
previous: 0,
};
const amount = Math.abs(toNumber(row.total));
if (row.period === period) {
entry.current = amount;
} else {
entry.previous = amount;
}
categoryMap.set(row.categoryId, entry);
}
// Calculate totals
let currentTotal = 0;
let previousTotal = 0;
for (const entry of categoryMap.values()) {
currentTotal += entry.current;
previousTotal += entry.previous;
}
// Build result
const categories: CategoryExpenseItem[] = [];
for (const [categoryId, entry] of categoryMap) {
const percentageChange = calculatePercentageChange(
entry.current,
entry.previous,
);
const percentageOfTotal =
currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
const budgetAmount = budgetMap.get(categoryId) ?? null;
const budgetUsedPercentage =
budgetAmount && budgetAmount > 0
? (entry.current / budgetAmount) * 100
: null;
categories.push({
categoryId,
categoryName: entry.name,
categoryIcon: entry.icon,
currentAmount: entry.current,
previousAmount: entry.previous,
percentageChange,
percentageOfTotal,
budgetAmount,
budgetUsedPercentage,
});
}
// Ordena por valor atual (maior para menor)
categories.sort((a, b) => b.currentAmount - a.currentAmount);
return {
categories,
currentTotal,
previousTotal,
};
return buildCategoryBreakdownData({
rows,
budgetRows,
period,
});
}

View File

@@ -1,32 +1,21 @@
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { categorias, contas, lancamentos, orcamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
buildCategoryBreakdownData,
type DashboardCategoryBreakdownData,
type DashboardCategoryBreakdownItem,
} from "@/lib/dashboard/categories/category-breakdown";
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { calculatePercentageChange } from "@/lib/utils/math";
import { safeToNumber } from "@/lib/utils/number";
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;
};
export type IncomeByCategoryData = {
categories: CategoryIncomeItem[];
currentTotal: number;
previousTotal: number;
};
export type CategoryIncomeItem = DashboardCategoryBreakdownItem;
export type IncomeByCategoryData = DashboardCategoryBreakdownData;
export async function fetchIncomeByCategory(
userId: string,
@@ -54,21 +43,12 @@ export async function fetchIncomeByCategory(
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
...buildDashboardAdminFilters({ userId, adminPagadorId }),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Receita"),
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),
),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
),
)
.groupBy(
@@ -86,85 +66,9 @@ export async function fetchIncomeByCategory(
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
]);
// Build budget lookup
const budgetMap = new Map<string, number>();
for (const row of budgetRows) {
if (row.categoriaId) {
budgetMap.set(row.categoriaId, safeToNumber(row.amount));
}
}
// Build category data from grouped results
const categoryMap = new Map<
string,
{
name: string;
icon: string | null;
current: number;
previous: number;
}
>();
for (const row of rows) {
const entry = categoryMap.get(row.categoryId) ?? {
name: row.categoryName,
icon: row.categoryIcon,
current: 0,
previous: 0,
};
const amount = Math.abs(safeToNumber(row.total));
if (row.period === period) {
entry.current = amount;
} else {
entry.previous = amount;
}
categoryMap.set(row.categoryId, entry);
}
// Calculate totals
let currentTotal = 0;
let previousTotal = 0;
for (const entry of categoryMap.values()) {
currentTotal += entry.current;
previousTotal += entry.previous;
}
// Build result
const categories: CategoryIncomeItem[] = [];
for (const [categoryId, entry] of categoryMap) {
const percentageChange = calculatePercentageChange(
entry.current,
entry.previous,
);
const percentageOfTotal =
currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
const budgetAmount = budgetMap.get(categoryId) ?? null;
const budgetUsedPercentage =
budgetAmount && budgetAmount > 0
? (entry.current / budgetAmount) * 100
: null;
categories.push({
categoryId,
categoryName: entry.name,
categoryIcon: entry.icon,
currentAmount: entry.current,
previousAmount: entry.previous,
percentageChange,
percentageOfTotal,
budgetAmount,
budgetUsedPercentage,
});
}
// Ordena por valor atual (maior para menor)
categories.sort((a, b) => b.currentAmount - a.currentAmount);
return {
categories,
currentTotal,
previousTotal,
};
return buildCategoryBreakdownData({
rows,
budgetRows,
period,
});
}

View File

@@ -1,9 +0,0 @@
import { calculatePercentageChange } from "@/lib/utils/math";
import { safeToNumber } from "@/lib/utils/number";
export { safeToNumber, calculatePercentageChange };
/**
* Alias for backward compatibility - dashboard uses "toNumber" naming
*/
export const toNumber = safeToNumber;

View File

@@ -1,21 +1,10 @@
import {
and,
asc,
eq,
gte,
ilike,
isNull,
lte,
ne,
not,
or,
sum,
} from "drizzle-orm";
import { and, asc, eq, gte, lte, ne, sum } from "drizzle-orm";
import { contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber } from "@/lib/utils/number";
@@ -107,21 +96,12 @@ export async function fetchDashboardCardMetrics(
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
...buildDashboardAdminFilters({ userId, adminPagadorId }),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, period),
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),
),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
),
)
.groupBy(lancamentos.period, lancamentos.transactionType)

View File

@@ -4,23 +4,28 @@ import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import {
buildDateOnlyStringFromPeriodDay,
parseLocalDateString,
} from "@/lib/utils/date";
import { safeToNumber as toNumber } from "@/lib/utils/number";
// 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;
try {
const [year, month] = period.split("-");
if (!year || !month) return null;
const dueDateString = buildDateOnlyStringFromPeriodDay(period, dueDay);
if (!dueDateString) return null;
const day = parseInt(dueDay, 10);
if (Number.isNaN(day)) return null;
const dueDate = parseLocalDateString(dueDateString);
if (Number.isNaN(dueDate.getTime())) 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);
// Meio-dia evita drift visual em serialização/locales diferentes.
dueDate.setHours(12, 0, 0, 0);
return dueDate;
} catch {
return null;
}

View File

@@ -4,9 +4,9 @@ import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type InstallmentExpense = {
id: string;

View File

@@ -4,9 +4,9 @@ import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type RecurringExpense = {
id: string;

View File

@@ -1,12 +1,12 @@
import { and, asc, eq, isNull, or, sql } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm";
import { cartoes, contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
buildDashboardAdminPeriodFilters,
excludeAutoGeneratedEntryNotes,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type TopExpense = {
id: string;
@@ -32,19 +32,13 @@ export async function fetchTopExpenses(
}
const conditions = [
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
}),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
excludeAutoGeneratedEntryNotes(),
];
// Se cardOnly for true, filtra apenas pagamentos com cartão
@@ -72,7 +66,7 @@ export async function fetchTopExpenses(
.limit(10);
const expenses = results.map(
(row): TopExpense => ({
(row: (typeof results)[number]): TopExpense => ({
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),

View File

@@ -1,15 +1,15 @@
import { unstable_cache } from "next/cache";
import { fetchDashboardAccounts } from "./accounts";
import { fetchDashboardBoletos } from "./boletos";
import { fetchDashboardBills } from "./bills";
import { fetchExpensesByCategory } from "./categories/expenses-by-category";
import { fetchIncomeByCategory } from "./categories/income-by-category";
import { fetchDashboardCardMetrics } from "./dashboard-metrics";
import { fetchInstallmentExpenses } from "./expenses/installment-expenses";
import { fetchRecurringExpenses } from "./expenses/recurring-expenses";
import { fetchTopExpenses } from "./expenses/top-expenses";
import { fetchGoalsProgressData } from "./goals-progress";
import { fetchIncomeExpenseBalance } from "./income-expense-balance";
import { fetchDashboardInvoices } from "./invoices";
import { fetchDashboardCardMetrics } from "./metrics";
import { fetchDashboardNotes } from "./notes";
import { fetchDashboardPagadores } from "./pagadores";
import { fetchPaymentConditions } from "./payments/payment-conditions";
@@ -23,7 +23,7 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
metrics,
accountsSnapshot,
invoicesSnapshot,
boletosSnapshot,
billsSnapshot,
goalsProgressData,
paymentStatusData,
incomeExpenseBalanceData,
@@ -43,7 +43,7 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
fetchDashboardCardMetrics(userId, period),
fetchDashboardAccounts(userId),
fetchDashboardInvoices(userId, period),
fetchDashboardBoletos(userId, period),
fetchDashboardBills(userId, period),
fetchGoalsProgressData(userId, period),
fetchPaymentStatus(userId, period),
fetchIncomeExpenseBalance(userId, period),
@@ -65,7 +65,7 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
metrics,
accountsSnapshot,
invoicesSnapshot,
boletosSnapshot,
billsSnapshot,
goalsProgressData,
paymentStatusData,
incomeExpenseBalanceData,
@@ -95,7 +95,7 @@ export function fetchDashboardData(userId: string, period: string) {
[`dashboard-${userId}-${period}`],
{
tags: ["dashboard", `dashboard-${userId}`],
revalidate: 120,
revalidate: 60,
},
)();
}

View File

@@ -0,0 +1,45 @@
import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
import type {
GoalProgressCategory,
GoalProgressItem,
GoalProgressStatus,
} from "@/lib/dashboard/goals-progress";
import { formatPercentage } from "@/lib/utils/percentage";
export const clampGoalProgress = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
export const formatGoalProgressPercentage = (value: number, withSign = false) =>
formatPercentage(value, {
maximumFractionDigits: 1,
signDisplay: withSign ? "always" : "auto",
});
export const getGoalProgressStatusColorClass = (status: GoalProgressStatus) =>
status === "exceeded" ? "text-destructive" : "";
export const mapGoalProgressCategoriesToBudgetCategories = (
categories: GoalProgressCategory[],
): BudgetCategory[] =>
categories.map((category) => ({
id: category.id,
name: category.name,
icon: category.icon,
}));
export const mapGoalProgressItemToBudget = (
item: GoalProgressItem,
): Budget => ({
id: item.id,
amount: item.budgetAmount,
spent: item.spentAmount,
period: item.period,
createdAt: item.createdAt,
category: item.categoryId
? {
id: item.categoryId,
name: item.categoryName,
icon: item.categoryIcon,
}
: null,
});

View File

@@ -1,8 +1,8 @@
import { and, eq, ne, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos } from "@/db/schema";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
const BUDGET_CRITICAL_THRESHOLD = 80;

View File

@@ -1,12 +1,18 @@
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
import {
buildPeriodWindow,
formatPeriodMonthShort,
getCurrentPeriod,
} from "@/lib/utils/period";
export type MonthData = {
month: string;
@@ -20,47 +26,12 @@ export type IncomeExpenseBalanceData = {
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",
};
const generateLast6Months = (currentPeriod: string): string[] => {
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;
try {
return buildPeriodWindow(currentPeriod, 6);
} catch {
return buildPeriodWindow(getCurrentPeriod(), 6);
}
const periods: string[] = [];
for (let i = 5; i >= 0; i--) {
let targetMonth = month - i;
let targetYear = year;
while (targetMonth <= 0) {
targetMonth += 12;
targetYear -= 1;
}
periods.push(`${targetYear}-${String(targetMonth).padStart(2, "0")}`);
}
return periods;
};
export async function fetchIncomeExpenseBalance(
@@ -85,17 +56,11 @@ export async function fetchIncomeExpenseBalance(
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
...buildDashboardAdminFilters({ userId, adminPagadorId }),
inArray(lancamentos.period, periods),
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
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),
),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
),
)
.groupBy(lancamentos.period, lancamentos.transactionType);
@@ -117,12 +82,10 @@ export async function fetchIncomeExpenseBalance(
// Build result array preserving period order
const months = periods.map((period) => {
const entry = dataMap.get(period) ?? { income: 0, expense: 0 };
const [, monthPart] = period.split("-");
const monthLabel = MONTH_LABELS[monthPart ?? "01"] ?? monthPart;
return {
month: period,
monthLabel: monthLabel ?? "",
monthLabel: formatPeriodMonthShort(period).toLowerCase(),
income: entry.income,
expense: entry.expense,
balance: entry.income - entry.expense,

View File

@@ -0,0 +1,116 @@
import type { InstallmentExpense } from "@/lib/dashboard/expenses/installment-expenses";
import {
calculateLastInstallmentDate,
formatLastInstallmentDate,
} from "@/lib/installments/utils";
export type InstallmentExpenseDisplay = {
compactLabel: string | null;
isLast: boolean;
remainingInstallments: number;
remainingAmount: number;
endDate: string | null;
progress: number;
};
export const buildInstallmentCompactLabel = (
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (currentInstallment && installmentCount) {
return `${currentInstallment} de ${installmentCount}`;
}
return null;
};
export const isInstallmentLast = (
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) {
return false;
}
return currentInstallment === installmentCount && installmentCount > 1;
};
export const calculateInstallmentRemainingCount = (
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) {
return 0;
}
return Math.max(0, installmentCount - currentInstallment);
};
export const calculateInstallmentRemainingAmount = (
amount: number,
currentInstallment: number | null,
installmentCount: number | null,
) =>
amount *
calculateInstallmentRemainingCount(currentInstallment, installmentCount);
export const formatInstallmentEndDate = (
period: string,
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) {
return null;
}
const lastDate = calculateLastInstallmentDate(
period,
currentInstallment,
installmentCount,
);
return formatLastInstallmentDate(lastDate);
};
export const buildInstallmentProgress = (
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount || installmentCount <= 0) {
return 0;
}
return Math.min(
100,
Math.max(0, (currentInstallment / installmentCount) * 100),
);
};
export const buildInstallmentExpenseDisplay = (
expense: InstallmentExpense,
): InstallmentExpenseDisplay => {
const { amount, currentInstallment, installmentCount, period } = expense;
return {
compactLabel: buildInstallmentCompactLabel(
currentInstallment,
installmentCount,
),
isLast: isInstallmentLast(currentInstallment, installmentCount),
remainingInstallments: calculateInstallmentRemainingCount(
currentInstallment,
installmentCount,
),
remainingAmount: calculateInstallmentRemainingAmount(
amount,
currentInstallment,
installmentCount,
),
endDate: formatInstallmentEndDate(
period,
currentInstallment,
installmentCount,
),
progress: buildInstallmentProgress(currentInstallment, installmentCount),
};
};

View File

@@ -0,0 +1,104 @@
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
import type { PaymentDialogState } from "@/lib/dashboard/use-payment-dialog-controller";
import {
INVOICE_PAYMENT_STATUS,
type InvoicePaymentStatus,
} from "@/lib/faturas";
import { getBusinessDateString } from "@/lib/utils/date";
import {
buildDueDateInfoFromPeriodDay,
formatFinancialDateLabel,
} from "@/lib/utils/financial-dates";
import { formatPercentage } from "@/lib/utils/percentage";
import { formatPeriodForUrl } from "@/lib/utils/period";
export type InvoiceDialogState = PaymentDialogState;
export type InvoiceLogoTone = "muted" | "accent";
type InvoicePaymentDateInfo = {
label: string;
};
type InvoiceDueDateInfo = {
label: string;
date: string | null;
};
export const buildInvoiceInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "CC";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CC";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CC";
};
export const parseInvoiceDueDate = (
period: string,
dueDay: string,
): InvoiceDueDateInfo => {
return buildDueDateInfoFromPeriodDay(period, dueDay);
};
export const formatInvoicePaymentDate = (
value: string | null,
): InvoicePaymentDateInfo | null => {
const label = formatFinancialDateLabel(value, "Pago em");
if (!label) {
return null;
}
return {
label,
};
};
export const getCurrentDateString = () => getBusinessDateString();
const formatInvoiceSharePercentage = (value: number) => {
if (!Number.isFinite(value) || value <= 0) {
return "0%";
}
const digits = value >= 10 ? 0 : value >= 1 ? 1 : 2;
return formatPercentage(value, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
export const getInvoiceShareLabel = (amount: number, total: number) => {
if (total <= 0) {
return "0% do total";
}
const percentage = (amount / total) * 100;
return `${formatInvoiceSharePercentage(percentage)} do total`;
};
export const getInvoiceStatusBadgeVariant = (
statusLabel: string,
): "success" | "info" => {
if (statusLabel.toLowerCase() === "em aberto") {
return "info";
}
return "success";
};
export const buildInvoiceDetailsHref = (cardId: string, period: string) =>
`/cartoes/${cardId}/fatura?periodo=${formatPeriodForUrl(period)}`;
export const markInvoiceAsPaid = (
invoice: DashboardInvoice,
paidAt: string,
): DashboardInvoice => ({
...invoice,
paymentStatus: INVOICE_PAYMENT_STATUS.PAID,
paidAt,
});
export const isInvoicePaid = (status: InvoicePaymentStatus) =>
status === INVOICE_PAYMENT_STATUS.PAID;

View File

@@ -1,13 +1,14 @@
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/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_VALUES,
type InvoicePaymentStatus,
} from "@/lib/faturas";
import { toDateOnlyString } from "@/lib/utils/date";
import { safeToNumber as toNumber } from "@/lib/utils/number";
type RawDashboardInvoice = {
invoiceId: string | null;
@@ -24,6 +25,15 @@ type RawDashboardInvoice = {
invoiceCreatedAt: Date | null;
};
type RawInvoiceBreakdownRow = {
cardId: string | null;
period: string | null;
pagadorId: string | null;
pagadorName: string | null;
pagadorAvatar: string | null;
amount: number | string | null;
};
export type InvoicePagadorBreakdown = {
pagadorId: string | null;
pagadorName: string;
@@ -51,22 +61,6 @@ export type DashboardInvoicesSnapshot = {
totalPending: number;
};
const toISODate = (value: Date | string | null | undefined) => {
if (!value) {
return null;
}
if (value instanceof Date) {
return value.toISOString().slice(0, 10);
}
if (typeof value === "string") {
return value.slice(0, 10);
}
return null;
};
const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
typeof value === "string" &&
(INVOICE_STATUS_VALUES as string[]).includes(value);
@@ -113,7 +107,7 @@ export async function fetchDashboardInvoices(
!Number.isNaN(row.purchaseDate.valueOf())
? row.purchaseDate
: row.createdAt;
const isoDate = toISODate(resolvedDate);
const isoDate = toDateOnlyString(resolvedDate);
if (!isoDate) {
continue;
}
@@ -123,7 +117,10 @@ export async function fetchDashboardInvoices(
}
}
const [rows, breakdownRows] = await Promise.all([
const [rows, breakdownRows]: [
RawDashboardInvoice[],
RawInvoiceBreakdownRow[],
] = await Promise.all([
db
.select({
invoiceId: faturas.id,
@@ -216,54 +213,57 @@ export async function fetchDashboardInvoices(
breakdownMap.set(key, current);
}
const invoices = rows
.map((row: RawDashboardInvoice | null) => {
if (!row) return null;
const invoices: DashboardInvoice[] = [];
const totalAmount = toNumber(row.totalAmount);
const transactionCount = toNumber(row.transactionCount);
const paymentStatus = isInvoiceStatus(row.paymentStatus)
? row.paymentStatus
: INVOICE_PAYMENT_STATUS.PENDING;
for (const row of rows) {
if (!row) {
continue;
}
const shouldInclude =
transactionCount > 0 ||
Math.abs(totalAmount) > 0 ||
row.invoiceId !== null;
const totalAmount = toNumber(row.totalAmount);
const transactionCount = toNumber(row.transactionCount);
const paymentStatus = isInvoiceStatus(row.paymentStatus)
? row.paymentStatus
: INVOICE_PAYMENT_STATUS.PENDING;
if (!shouldInclude) {
return null;
}
const shouldInclude =
transactionCount > 0 ||
Math.abs(totalAmount) > 0 ||
row.invoiceId !== 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;
if (!shouldInclude) {
continue;
}
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 resolvedPeriod = row.period ?? period;
const paymentKey = `${row.cardId}:${resolvedPeriod}`;
const paidAt =
paymentStatus === INVOICE_PAYMENT_STATUS.PAID
? (paymentMap.get(paymentKey) ?? toDateOnlyString(row.invoiceCreatedAt))
: null;
invoices.push({
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),
});
}
invoices.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) {

View File

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

View File

@@ -0,0 +1,15 @@
import type { Note } from "@/components/anotacoes/types";
import type { DashboardNote } from "@/lib/dashboard/notes";
export const mapDashboardNoteToNote = (note: DashboardNote): Note => ({
id: note.id,
title: note.title,
description: note.description,
type: note.type,
tasks: note.tasks,
arquivada: note.arquivada,
createdAt: note.createdAt,
});
export const mapDashboardNotesToNotes = (notes: DashboardNote[]) =>
notes.map(mapDashboardNoteToNote);

View File

@@ -11,6 +11,14 @@ import {
import { db } from "@/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import {
buildDateOnlyStringFromPeriodDay,
getBusinessDateString,
isDateOnlyPast,
isDateOnlyWithinDays,
toDateOnlyString,
} from "@/lib/utils/date";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type NotificationType = "overdue" | "due_soon";
@@ -46,100 +54,6 @@ export type DashboardNotificationsSnapshot = {
const PAYMENT_METHOD_BOLETO = "Boleto";
const BUDGET_CRITICAL_THRESHOLD = 80;
/**
* Calcula a data de vencimento de uma fatura baseado no período e dia de vencimento
* @param period Período no formato YYYY-MM
* @param dueDay Dia do vencimento (1-31)
* @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 daysInMonth = hasValidMonth
? new Date(yearNumber, monthNumber, 0).getDate()
: null;
const dueDayNumber = Number(dueDay);
const hasValidDueDay = Number.isInteger(dueDayNumber) && dueDayNumber > 0;
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 normalizedMonth =
hasValidMonth && month.length < 2 ? month.padStart(2, "0") : month;
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,
),
);
}
/**
* 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));
}
/**
* 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);
return dueNormalized < today;
}
/**
* Verifica se uma data vence nos próximos X dias (incluindo hoje)
*/
function isDueWithinDays(
dueDate: string,
today: Date,
daysThreshold: number,
): boolean {
const due = parseUTCDate(dueDate);
const dueNormalized = normalizeDate(due);
const limitDate = new Date(today);
limitDate.setUTCDate(limitDate.getUTCDate() + daysThreshold);
return dueNormalized >= today && dueNormalized <= limitDate;
}
function toNum(value: unknown): number {
if (typeof value === "number") return value;
return Number(value) || 0;
}
/**
* Busca todas as notificações do dashboard:
* - Faturas de cartão atrasadas ou com vencimento próximo
@@ -150,7 +64,7 @@ export async function fetchDashboardNotifications(
userId: string,
currentPeriod: string,
): Promise<DashboardNotificationsSnapshot> {
const today = normalizeDate(new Date());
const today = getBusinessDateString();
const DAYS_THRESHOLD = 5;
const adminPagadorId = await getAdminPagadorId(userId);
@@ -285,8 +199,12 @@ export async function fetchDashboardNotifications(
// 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 = toNum(invoice.totalAmount);
const dueDate = buildDateOnlyStringFromPeriodDay(
invoice.period,
invoice.dueDay,
);
if (!dueDate) continue;
const amount = toNumber(invoice.totalAmount);
const notificationId = invoice.invoiceId
? `invoice-${invoice.invoiceId}`
: `invoice-${invoice.cardId}-${invoice.period}`;
@@ -307,8 +225,13 @@ export async function fetchDashboardNotifications(
// Faturas do período atual
for (const invoice of currentInvoices) {
if (!invoice.period || !invoice.dueDay) continue;
const amount = toNum(invoice.totalAmount);
const transactionCount = toNum(invoice.transactionCount);
const dueDate = buildDateOnlyStringFromPeriodDay(
invoice.period,
invoice.dueDay,
);
if (!dueDate) continue;
const amount = toNumber(invoice.totalAmount);
const transactionCount = toNumber(invoice.transactionCount);
const paymentStatus =
invoice.paymentStatus ?? INVOICE_PAYMENT_STATUS.PENDING;
@@ -319,9 +242,12 @@ export async function fetchDashboardNotifications(
if (!shouldInclude) continue;
if (paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
const invoiceIsOverdue = isOverdue(dueDate, today);
const invoiceIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
const invoiceIsOverdue = isDateOnlyPast(dueDate, today);
const invoiceIsDueSoon = isDateOnlyWithinDays(
dueDate,
DAYS_THRESHOLD,
today,
);
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
const notificationId = invoice.invoiceId
@@ -343,17 +269,18 @@ export async function fetchDashboardNotifications(
// Boletos
for (const boleto of boletosRows) {
if (!boleto.dueDate) continue;
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 dueDate = toDateOnlyString(boleto.dueDate);
if (!dueDate) continue;
const boletoIsOverdue = isOverdue(dueDate, today);
const boletoIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
const boletoIsOverdue = isDateOnlyPast(dueDate, today);
const boletoIsDueSoon = isDateOnlyWithinDays(
dueDate,
DAYS_THRESHOLD,
today,
);
const isOldPeriod = boleto.period < currentPeriod;
const isCurrentPeriod = boleto.period === currentPeriod;
const amount = toNum(boleto.amount);
const amount = toNumber(boleto.amount);
if (isOldPeriod) {
notifications.push({
@@ -391,8 +318,8 @@ export async function fetchDashboardNotifications(
const budgetNotifications: BudgetNotification[] = [];
for (const row of budgetRows) {
const budgetAmount = toNum(row.budgetAmount);
const spentAmount = toNum(row.spentAmount);
const budgetAmount = toNumber(row.budgetAmount);
const spentAmount = toNumber(row.spentAmount);
if (budgetAmount <= 0) continue;
const usedPercentage = (spentAmount / budgetAmount) * 100;

View File

@@ -1,10 +1,10 @@
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { calculatePercentageChange } from "@/lib/utils/math";
import { safeToNumber as toNumber } from "@/lib/utils/number";
import { getPreviousPeriod } from "@/lib/utils/period";
export type DashboardPagador = {

View File

@@ -0,0 +1,10 @@
import { formatPercentage } from "@/lib/utils/percentage";
export const formatPaymentBreakdownPercentage = (value: number) =>
formatPercentage(value, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
export const formatPaymentBreakdownTransactionsLabel = (transactions: number) =>
`${transactions} ${transactions === 1 ? "lançamento" : "lançamentos"}`;

View File

@@ -0,0 +1,11 @@
export type PaymentOverviewTab = "conditions" | "methods";
export const DEFAULT_PAYMENT_OVERVIEW_TAB: PaymentOverviewTab = "conditions";
export const parsePaymentOverviewTab = (value: string): PaymentOverviewTab => {
if (value === "methods") {
return "methods";
}
return DEFAULT_PAYMENT_OVERVIEW_TAB;
};

View File

@@ -1,12 +1,12 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
buildDashboardAdminPeriodFilters,
excludeAutoGeneratedEntryNotes,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type PaymentConditionSummary = {
condition: string;
@@ -37,22 +37,18 @@ export async function fetchPaymentConditions(
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
}),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
excludeAutoGeneratedEntryNotes(),
),
)
.groupBy(lancamentos.condition);
const summaries = rows.map((row) => {
const summaries = rows.map((row: (typeof rows)[number]) => {
const totalAmount = Math.abs(toNumber(row.totalAmount));
const transactions = Number(row.transactions ?? 0);
@@ -63,10 +59,13 @@ export async function fetchPaymentConditions(
};
});
const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0);
const overallTotal = summaries.reduce(
(acc: number, item: (typeof summaries)[number]) => acc + item.amount,
0,
);
const conditions = summaries
.map((item) => ({
.map((item: (typeof summaries)[number]) => ({
condition: item.condition,
amount: item.amount,
transactions: item.transactions,
@@ -75,7 +74,10 @@ export async function fetchPaymentConditions(
? Number(((item.amount / overallTotal) * 100).toFixed(2))
: 0,
}))
.sort((a, b) => b.amount - a.amount);
.sort(
(a: (typeof summaries)[number], b: (typeof summaries)[number]) =>
b.amount - a.amount,
);
return {
conditions,

View File

@@ -1,12 +1,12 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
buildDashboardAdminPeriodFilters,
excludeAutoGeneratedEntryNotes,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type PaymentMethodSummary = {
paymentMethod: string;
@@ -37,22 +37,18 @@ export async function fetchPaymentMethods(
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
}),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
excludeAutoGeneratedEntryNotes(),
),
)
.groupBy(lancamentos.paymentMethod);
const summaries = rows.map((row) => {
const summaries = rows.map((row: (typeof rows)[number]) => {
const amount = Math.abs(toNumber(row.totalAmount));
const transactions = Number(row.transactions ?? 0);
@@ -63,10 +59,13 @@ export async function fetchPaymentMethods(
};
});
const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0);
const overallTotal = summaries.reduce(
(acc: number, item: (typeof summaries)[number]) => acc + item.amount,
0,
);
const methods = summaries
.map((item) => ({
.map((item: (typeof summaries)[number]) => ({
paymentMethod: item.paymentMethod,
amount: item.amount,
transactions: item.transactions,
@@ -75,7 +74,10 @@ export async function fetchPaymentMethods(
? Number(((item.amount / overallTotal) * 100).toFixed(2))
: 0,
}))
.sort((a, b) => b.amount - a.amount);
.sort(
(a: (typeof summaries)[number], b: (typeof summaries)[number]) =>
b.amount - a.amount,
);
return {
methods,

View File

@@ -1,9 +1,12 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { and, inArray, sql } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import {
buildDashboardAdminPeriodFilters,
excludeAutoInvoiceEntries,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type PaymentStatusCategory = {
total: number;
@@ -51,11 +54,13 @@ export async function fetchPaymentStatus(
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.pagadorId, adminPagadorId),
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
}),
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
excludeAutoInvoiceEntries(),
),
)
.groupBy(lancamentos.transactionType);

View File

@@ -1,12 +1,12 @@
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, desc, eq, inArray } from "drizzle-orm";
import { cartoes, categorias, contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
buildDashboardAdminPeriodFilters,
excludeAutoGeneratedEntryNotes,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type CategoryOption = {
id: string;
@@ -68,19 +68,13 @@ export async function fetchPurchasesByCategory(
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.pagadorId, adminPagadorId),
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
}),
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}%`}`,
),
),
excludeAutoGeneratedEntryNotes(),
),
)
.orderBy(desc(lancamentos.purchaseDate));

View File

@@ -1,12 +1,12 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { cartoes, contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
buildDashboardAdminPeriodFilters,
excludeAutoGeneratedEntryNotes,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type TopEstablishment = {
id: string;
@@ -55,17 +55,13 @@ export async function fetchTopEstablishments(
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
}),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
excludeAutoGeneratedEntryNotes(),
),
)
.groupBy(lancamentos.name)
@@ -76,9 +72,11 @@ export async function fetchTopEstablishments(
.limit(10);
const establishments = rows
.filter((row) => shouldIncludeEstablishment(row.name))
.filter((row: (typeof rows)[number]) =>
shouldIncludeEstablishment(row.name),
)
.map(
(row): TopEstablishment => ({
(row: (typeof rows)[number]): TopEstablishment => ({
id: row.name,
name: row.name,
amount: Math.abs(toNumber(row.totalAmount)),

View File

@@ -0,0 +1,46 @@
"use client";
import { toggleLancamentoSettlementAction } from "@/app/(dashboard)/lancamentos/actions";
import type { DashboardBill } from "@/lib/dashboard/bills";
import {
type BillDialogState,
getCurrentBillDateString,
markBillAsSettled,
} from "@/lib/dashboard/bills-helpers";
import {
type PaymentDialogController,
usePaymentDialogController,
} from "@/lib/dashboard/use-payment-dialog-controller";
const EMPTY_BILLS: DashboardBill[] = [];
export type BillWidgetController = Omit<
PaymentDialogController<DashboardBill>,
"selectedItem"
> & {
selectedBill: DashboardBill | null;
modalState: BillDialogState;
};
export function useBillWidgetController(
bills?: DashboardBill[],
): BillWidgetController {
const safeBills = bills ?? EMPTY_BILLS;
const controller = usePaymentDialogController({
items: safeBills,
getItemId: (bill) => bill.id,
isItemConfirmed: (bill) => bill.isSettled,
executeConfirm: (bill) =>
toggleLancamentoSettlementAction({
id: bill.id,
value: true,
}),
applyConfirmedState: (bill) =>
markBillAsSettled(bill, getCurrentBillDateString()),
});
return {
...controller,
selectedBill: controller.selectedItem,
};
}

View File

@@ -0,0 +1,56 @@
"use client";
import { useMemo, useState } from "react";
import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
import type {
GoalProgressItem,
GoalsProgressData,
} from "@/lib/dashboard/goals-progress";
import {
mapGoalProgressCategoriesToBudgetCategories,
mapGoalProgressItemToBudget,
} from "@/lib/dashboard/goals-progress-helpers";
export type GoalsProgressWidgetController = {
selectedBudget: Budget | null;
editOpen: boolean;
categories: BudgetCategory[];
defaultPeriod: string;
handleEdit: (item: GoalProgressItem) => void;
handleEditOpenChange: (open: boolean) => void;
};
export function useGoalsProgressWidgetController(
data: GoalsProgressData,
): GoalsProgressWidgetController {
const [editOpen, setEditOpen] = useState(false);
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
const categories = useMemo<BudgetCategory[]>(
() => mapGoalProgressCategoriesToBudgetCategories(data.categories),
[data.categories],
);
const defaultPeriod = data.items[0]?.period ?? "";
const handleEdit = (item: GoalProgressItem) => {
setSelectedBudget(mapGoalProgressItemToBudget(item));
setEditOpen(true);
};
const handleEditOpenChange = (open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedBudget(null);
}
};
return {
selectedBudget,
editOpen,
categories,
defaultPeriod,
handleEdit,
handleEditOpenChange,
};
}

View File

@@ -0,0 +1,46 @@
"use client";
import { updateInvoicePaymentStatusAction } from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions";
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
import {
getCurrentDateString,
type InvoiceDialogState,
isInvoicePaid,
markInvoiceAsPaid,
} from "@/lib/dashboard/invoices-helpers";
import {
type PaymentDialogController,
usePaymentDialogController,
} from "@/lib/dashboard/use-payment-dialog-controller";
import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
export type InvoicesWidgetController = Omit<
PaymentDialogController<DashboardInvoice>,
"selectedItem"
> & {
selectedInvoice: DashboardInvoice | null;
modalState: InvoiceDialogState;
};
export function useInvoicesWidgetController(
invoices: DashboardInvoice[],
): InvoicesWidgetController {
const controller = usePaymentDialogController({
items: invoices,
getItemId: (invoice) => invoice.id,
isItemConfirmed: (invoice) => isInvoicePaid(invoice.paymentStatus),
executeConfirm: (invoice) =>
updateInvoicePaymentStatusAction({
cartaoId: invoice.cardId,
period: invoice.period,
status: INVOICE_PAYMENT_STATUS.PAID,
}),
applyConfirmedState: (invoice) =>
markInvoiceAsPaid(invoice, getCurrentDateString()),
});
return {
...controller,
selectedInvoice: controller.selectedItem,
};
}

View File

@@ -0,0 +1,65 @@
"use client";
import { useMemo, useState } from "react";
import type { Note } from "@/components/anotacoes/types";
import type { DashboardNote } from "@/lib/dashboard/notes";
import { mapDashboardNotesToNotes } from "@/lib/dashboard/notes-mappers";
export type NotesWidgetController = {
mappedNotes: Note[];
noteToEdit: Note | null;
isEditOpen: boolean;
noteDetails: Note | null;
isDetailsOpen: boolean;
openEdit: (note: Note) => void;
openDetails: (note: Note) => void;
handleEditOpenChange: (open: boolean) => void;
handleDetailsOpenChange: (open: boolean) => void;
};
export function useNotesWidgetController(
notes: DashboardNote[],
): NotesWidgetController {
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
const [isEditOpen, setIsEditOpen] = useState(false);
const [noteDetails, setNoteDetails] = useState<Note | null>(null);
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const mappedNotes = useMemo(() => mapDashboardNotesToNotes(notes), [notes]);
const openEdit = (note: Note) => {
setNoteToEdit(note);
setIsEditOpen(true);
};
const openDetails = (note: Note) => {
setNoteDetails(note);
setIsDetailsOpen(true);
};
const handleEditOpenChange = (open: boolean) => {
setIsEditOpen(open);
if (!open) {
setNoteToEdit(null);
}
};
const handleDetailsOpenChange = (open: boolean) => {
setIsDetailsOpen(open);
if (!open) {
setNoteDetails(null);
}
};
return {
mappedNotes,
noteToEdit,
isEditOpen,
noteDetails,
isDetailsOpen,
openEdit,
openDetails,
handleEditOpenChange,
handleDetailsOpenChange,
};
}

View File

@@ -0,0 +1,110 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import type { ActionResult } from "@/lib/types/actions";
export type PaymentDialogState = "idle" | "processing" | "success";
type UsePaymentDialogControllerOptions<TItem> = {
items: TItem[];
getItemId: (item: TItem) => string;
isItemConfirmed: (item: TItem) => boolean;
executeConfirm: (item: TItem) => Promise<ActionResult>;
applyConfirmedState: (item: TItem) => TItem;
};
export type PaymentDialogController<TItem> = {
items: TItem[];
selectedItem: TItem | null;
isModalOpen: boolean;
modalState: PaymentDialogState;
isPending: boolean;
openPaymentDialog: (itemId: string) => void;
closePaymentDialog: () => void;
confirmPayment: () => void;
};
export function usePaymentDialogController<TItem>({
items,
getItemId,
isItemConfirmed,
executeConfirm,
applyConfirmedState,
}: UsePaymentDialogControllerOptions<TItem>): PaymentDialogController<TItem> {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [localItems, setLocalItems] = useState(items);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalState, setModalState] = useState<PaymentDialogState>("idle");
useEffect(() => {
setLocalItems(items);
}, [items]);
const selectedItem = useMemo(
() => localItems.find((item) => getItemId(item) === selectedId) ?? null,
[localItems, selectedId, getItemId],
);
const openPaymentDialog = (itemId: string) => {
setSelectedId(itemId);
setModalState("idle");
setIsModalOpen(true);
};
const closePaymentDialog = () => {
setIsModalOpen(false);
setSelectedId(null);
setModalState("idle");
};
const confirmPayment = () => {
const itemToUpdate = selectedItem;
if (
!itemToUpdate ||
isItemConfirmed(itemToUpdate) ||
modalState === "processing" ||
isPending
) {
return;
}
const itemId = getItemId(itemToUpdate);
setModalState("processing");
startTransition(() => {
void (async () => {
const result = await executeConfirm(itemToUpdate);
if (!result.success) {
toast.error(result.error);
setModalState("idle");
return;
}
setLocalItems((previous) =>
previous.map((item) =>
getItemId(item) === itemId ? applyConfirmedState(item) : item,
),
);
toast.success(result.message);
router.refresh();
setModalState("success");
})();
});
};
return {
items: localItems,
selectedItem,
isModalOpen,
modalState,
isPending,
openPaymentDialog,
closePaymentDialog,
confirmPayment,
};
}

View File

@@ -0,0 +1,28 @@
"use client";
import { useState } from "react";
import {
DEFAULT_PAYMENT_OVERVIEW_TAB,
type PaymentOverviewTab,
parsePaymentOverviewTab,
} from "@/lib/dashboard/payment-overview-tabs";
export type PaymentOverviewWidgetController = {
activeTab: PaymentOverviewTab;
handleTabChange: (value: string) => void;
};
export function usePaymentOverviewWidgetController(): PaymentOverviewWidgetController {
const [activeTab, setActiveTab] = useState<PaymentOverviewTab>(
DEFAULT_PAYMENT_OVERVIEW_TAB,
);
const handleTabChange = (value: string) => {
setActiveTab(parsePaymentOverviewTab(value));
};
return {
activeTab,
handleTabChange,
};
}

View File

@@ -16,16 +16,16 @@ import {
} from "@remixicon/react";
import Link from "next/link";
import type { ReactNode } from "react";
import { BoletosWidget } from "@/components/dashboard/boletos-widget";
import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget";
import { BillWidget } from "@/components/dashboard/bill-widget";
import { ExpensesByCategoryWidgetWithChart } from "@/components/dashboard/expenses-by-category-widget-with-chart";
import { GoalsProgressWidget } from "@/components/dashboard/goals-progress-widget";
import { IncomeByCategoryWidgetWithChart } from "@/components/dashboard/income-by-category-widget-with-chart";
import { IncomeExpenseBalanceWidget } from "@/components/dashboard/income-expense-balance-widget";
import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget";
import { InvoicesWidget } from "@/components/dashboard/invoices-widget";
import { MyAccountsWidget } from "@/components/dashboard/my-accounts-widget";
import { NotesWidget } from "@/components/dashboard/notes-widget";
import { PagadoresWidget } from "@/components/dashboard/pagadores-widget";
import { PayersWidget } from "@/components/dashboard/payers-widget";
import { PaymentOverviewWidget } from "@/components/dashboard/payment-overview-widget";
import { PaymentStatusWidget } from "@/components/dashboard/payment-status-widget";
import { PurchasesByCategoryWidget } from "@/components/dashboard/purchases-by-category-widget";
@@ -70,9 +70,7 @@ export const widgetsConfig: WidgetConfig[] = [
title: "Boletos",
subtitle: "Controle de boletos do período",
icon: <RiBarcodeLine className="size-4" />,
component: ({ data }) => (
<BoletosWidget boletos={data.boletosSnapshot.boletos} />
),
component: ({ data }) => <BillWidget bills={data.billsSnapshot.bills} />,
},
{
id: "payment-status",
@@ -98,7 +96,7 @@ export const widgetsConfig: WidgetConfig[] = [
subtitle: "Despesas por pagador no período",
icon: <RiGroupLine className="size-4" />,
component: ({ data }) => (
<PagadoresWidget pagadores={data.pagadoresSnapshot.pagadores} />
<PayersWidget pagadores={data.pagadoresSnapshot.pagadores} />
),
action: (
<Link