mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
refactor(dashboard): reorganiza widgets e remove magnet-lines
This commit is contained in:
@@ -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;
|
||||
|
||||
53
lib/dashboard/bills-helpers.ts
Normal file
53
lib/dashboard/bills-helpers.ts
Normal 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,
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
121
lib/dashboard/categories/category-breakdown.ts
Normal file
121
lib/dashboard/categories/category-breakdown.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)();
|
||||
}
|
||||
|
||||
45
lib/dashboard/goals-progress-helpers.ts
Normal file
45
lib/dashboard/goals-progress-helpers.ts
Normal 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,
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
116
lib/dashboard/installment-expenses-helpers.ts
Normal file
116
lib/dashboard/installment-expenses-helpers.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
104
lib/dashboard/invoices-helpers.ts
Normal file
104
lib/dashboard/invoices-helpers.ts
Normal 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;
|
||||
@@ -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) {
|
||||
|
||||
56
lib/dashboard/lancamento-filters.ts
Normal file
56
lib/dashboard/lancamento-filters.ts
Normal 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),
|
||||
);
|
||||
15
lib/dashboard/notes-mappers.ts
Normal file
15
lib/dashboard/notes-mappers.ts
Normal 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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
10
lib/dashboard/payment-breakdown-formatters.ts
Normal file
10
lib/dashboard/payment-breakdown-formatters.ts
Normal 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"}`;
|
||||
11
lib/dashboard/payment-overview-tabs.ts
Normal file
11
lib/dashboard/payment-overview-tabs.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)),
|
||||
|
||||
46
lib/dashboard/use-bill-widget-controller.ts
Normal file
46
lib/dashboard/use-bill-widget-controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
56
lib/dashboard/use-goals-progress-widget-controller.ts
Normal file
56
lib/dashboard/use-goals-progress-widget-controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
46
lib/dashboard/use-invoices-widget-controller.ts
Normal file
46
lib/dashboard/use-invoices-widget-controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
65
lib/dashboard/use-notes-widget-controller.ts
Normal file
65
lib/dashboard/use-notes-widget-controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
110
lib/dashboard/use-payment-dialog-controller.ts
Normal file
110
lib/dashboard/use-payment-dialog-controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
28
lib/dashboard/use-payment-overview-widget-controller.ts
Normal file
28
lib/dashboard/use-payment-overview-widget-controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user