mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
refactor(core): move app para src e padroniza estrutura
This commit is contained in:
114
src/features/dashboard/accounts-queries.ts
Normal file
114
src/features/dashboard/accounts-queries.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
type RawDashboardAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
logo: string | null;
|
||||
initialBalance: string | number | null;
|
||||
balanceMovements: unknown;
|
||||
};
|
||||
|
||||
export type DashboardAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
logo: string | null;
|
||||
initialBalance: number;
|
||||
balance: number;
|
||||
excludeFromBalance: boolean;
|
||||
};
|
||||
|
||||
export type DashboardAccountsSnapshot = {
|
||||
totalBalance: number;
|
||||
accounts: DashboardAccount[];
|
||||
};
|
||||
|
||||
export async function fetchDashboardAccounts(
|
||||
userId: string,
|
||||
): Promise<DashboardAccountsSnapshot> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: contas.id,
|
||||
name: contas.name,
|
||||
accountType: contas.accountType,
|
||||
status: contas.status,
|
||||
logo: contas.logo,
|
||||
initialBalance: contas.initialBalance,
|
||||
excludeFromBalance: contas.excludeFromBalance,
|
||||
balanceMovements: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||
else ${lancamentos.amount}
|
||||
end
|
||||
),
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(contas)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.contaId, contas.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.isSettled, true),
|
||||
),
|
||||
)
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(contas.userId, userId),
|
||||
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
contas.id,
|
||||
contas.name,
|
||||
contas.accountType,
|
||||
contas.status,
|
||||
contas.logo,
|
||||
contas.initialBalance,
|
||||
contas.excludeFromBalance,
|
||||
);
|
||||
|
||||
const accounts = rows
|
||||
.map(
|
||||
(
|
||||
row: RawDashboardAccount & { excludeFromBalance: boolean },
|
||||
): DashboardAccount => {
|
||||
const initialBalance = toNumber(row.initialBalance);
|
||||
const balanceMovements = toNumber(row.balanceMovements);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
accountType: row.accountType,
|
||||
status: row.status,
|
||||
logo: row.logo,
|
||||
initialBalance,
|
||||
balance: initialBalance + balanceMovements,
|
||||
excludeFromBalance: row.excludeFromBalance,
|
||||
};
|
||||
},
|
||||
)
|
||||
.sort((a, b) => b.balance - a.balance);
|
||||
|
||||
const totalBalance = accounts
|
||||
.filter((account) => !account.excludeFromBalance)
|
||||
.reduce((total, account) => total + account.balance, 0);
|
||||
|
||||
return {
|
||||
totalBalance,
|
||||
accounts,
|
||||
};
|
||||
}
|
||||
53
src/features/dashboard/bills-helpers.ts
Normal file
53
src/features/dashboard/bills-helpers.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import type { PaymentDialogState } from "@/features/dashboard/use-payment-dialog-controller";
|
||||
import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date";
|
||||
import {
|
||||
buildFinancialStatusLabel,
|
||||
formatFinancialDateLabel,
|
||||
} from "@/shared/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,
|
||||
});
|
||||
96
src/features/dashboard/bills-queries.ts
Normal file
96
src/features/dashboard/bills-queries.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
"use server";
|
||||
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { lancamentos } from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { toDateOnlyString } from "@/shared/utils/date";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||
|
||||
type RawDashboardBill = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: string | number | null;
|
||||
dueDate: string | Date | null;
|
||||
boletoPaymentDate: string | Date | null;
|
||||
isSettled: boolean | null;
|
||||
};
|
||||
|
||||
export type DashboardBill = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
dueDate: string | null;
|
||||
boletoPaymentDate: string | null;
|
||||
isSettled: boolean;
|
||||
};
|
||||
|
||||
export type DashboardBillsSnapshot = {
|
||||
bills: DashboardBill[];
|
||||
totalPendingAmount: number;
|
||||
pendingCount: number;
|
||||
};
|
||||
|
||||
export async function fetchDashboardBills(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<DashboardBillsSnapshot> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { bills: [], totalPendingAmount: 0, pendingCount: 0 };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
dueDate: lancamentos.dueDate,
|
||||
boletoPaymentDate: lancamentos.boletoPaymentDate,
|
||||
isSettled: lancamentos.isSettled,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
eq(lancamentos.pagadorId, adminPagadorId),
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
asc(lancamentos.isSettled),
|
||||
asc(lancamentos.dueDate),
|
||||
asc(lancamentos.name),
|
||||
);
|
||||
|
||||
const bills = rows.map((row: RawDashboardBill): DashboardBill => {
|
||||
const amount = Math.abs(toNumber(row.amount));
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount,
|
||||
dueDate: toDateOnlyString(row.dueDate),
|
||||
boletoPaymentDate: toDateOnlyString(row.boletoPaymentDate),
|
||||
isSettled: Boolean(row.isSettled),
|
||||
};
|
||||
});
|
||||
|
||||
let totalPendingAmount = 0;
|
||||
let pendingCount = 0;
|
||||
|
||||
for (const bill of bills) {
|
||||
if (!bill.isSettled) {
|
||||
totalPendingAmount += bill.amount;
|
||||
pendingCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bills,
|
||||
totalPendingAmount,
|
||||
pendingCount,
|
||||
};
|
||||
}
|
||||
121
src/features/dashboard/categories/category-breakdown.ts
Normal file
121
src/features/dashboard/categories/category-breakdown.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { calculatePercentageChange } from "@/shared/utils/math";
|
||||
import { safeToNumber as toNumber } from "@/shared/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,
|
||||
};
|
||||
}
|
||||
136
src/features/dashboard/categories/category-details-queries.ts
Normal file
136
src/features/dashboard/categories/category-details-queries.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { and, desc, eq, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import { categorias, contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import { mapLancamentosData } from "@/features/transactions/page-helpers";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/shared/lib/accounts/constants";
|
||||
import type { CategoryType } from "@/shared/lib/categories/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||
import { calculatePercentageChange } from "@/shared/utils/math";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
import { getPreviousPeriod } from "@/shared/utils/period";
|
||||
|
||||
type MappedLancamentos = ReturnType<typeof mapLancamentosData>;
|
||||
|
||||
export type CategoryDetailData = {
|
||||
category: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: CategoryType;
|
||||
};
|
||||
period: string;
|
||||
previousPeriod: string;
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
percentageChange: number | null;
|
||||
transactions: MappedLancamentos;
|
||||
};
|
||||
|
||||
export async function fetchCategoryDetails(
|
||||
userId: string,
|
||||
categoryId: string,
|
||||
period: string,
|
||||
): Promise<CategoryDetailData | null> {
|
||||
const category = await db.query.categorias.findFirst({
|
||||
where: and(eq(categorias.userId, userId), eq(categorias.id, categoryId)),
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
const transactionType = category.type === "receita" ? "Receita" : "Despesa";
|
||||
|
||||
const sanitizedNote = or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
);
|
||||
|
||||
const currentRows = await db.query.lancamentos.findMany({
|
||||
where: and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.categoriaId, categoryId),
|
||||
eq(lancamentos.transactionType, transactionType),
|
||||
eq(lancamentos.period, period),
|
||||
sanitizedNote,
|
||||
),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
orderBy: [desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)],
|
||||
});
|
||||
|
||||
const filteredRows = currentRows.filter((row) => {
|
||||
// Filtrar apenas pagadores admin
|
||||
if (row.pagador?.role !== PAGADOR_ROLE_ADMIN) return false;
|
||||
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
if (
|
||||
row.note === INITIAL_BALANCE_NOTE &&
|
||||
row.conta?.excludeInitialBalanceFromIncome
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const transactions = mapLancamentosData(filteredRows);
|
||||
|
||||
const currentTotal = transactions.reduce(
|
||||
(total, transaction) => total + Math.abs(toNumber(transaction.amount)),
|
||||
0,
|
||||
);
|
||||
|
||||
const [previousTotalRow] = await db
|
||||
.select({
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.categoriaId, categoryId),
|
||||
eq(lancamentos.transactionType, transactionType),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
sanitizedNote,
|
||||
eq(lancamentos.period, previousPeriod),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const previousTotal = Math.abs(toNumber(previousTotalRow?.total ?? 0));
|
||||
const percentageChange = calculatePercentageChange(
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
);
|
||||
|
||||
return {
|
||||
category: {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
icon: category.icon,
|
||||
type: category.type as CategoryType,
|
||||
},
|
||||
period,
|
||||
previousPeriod,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
percentageChange,
|
||||
transactions,
|
||||
};
|
||||
}
|
||||
208
src/features/dashboard/categories/category-history-queries.ts
Normal file
208
src/features/dashboard/categories/category-history-queries.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { categorias, lancamentos, pagadores } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||
import { CATEGORY_COLORS } from "@/shared/utils/category-colors";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
import {
|
||||
addMonthsToPeriod,
|
||||
buildPeriodWindow,
|
||||
formatPeriodMonthShort,
|
||||
} from "@/shared/utils/period";
|
||||
|
||||
export type CategoryOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: "receita" | "despesa";
|
||||
};
|
||||
|
||||
export type CategoryHistoryItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
color: string;
|
||||
data: Record<string, number>;
|
||||
};
|
||||
|
||||
export type CategoryHistoryData = {
|
||||
months: string[]; // ["NOV", "DEZ", "JAN", ...]
|
||||
categories: CategoryHistoryItem[];
|
||||
chartData: Array<{
|
||||
month: string;
|
||||
[categoryName: string]: number | string;
|
||||
}>;
|
||||
allCategories: CategoryOption[];
|
||||
};
|
||||
|
||||
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,
|
||||
): Promise<CategoryOption[]> {
|
||||
const result = await db
|
||||
.select({
|
||||
id: categorias.id,
|
||||
name: categorias.name,
|
||||
icon: categorias.icon,
|
||||
type: categorias.type,
|
||||
})
|
||||
.from(categorias)
|
||||
.where(eq(categorias.userId, userId))
|
||||
.orderBy(categorias.type, categorias.name);
|
||||
|
||||
return result as CategoryOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches category expense/income history for all categories with transactions
|
||||
* Widget will allow user to select up to 5 to display
|
||||
*/
|
||||
export async function fetchCategoryHistory(
|
||||
userId: string,
|
||||
currentPeriod: string,
|
||||
): Promise<CategoryHistoryData> {
|
||||
// Generate last 8 months, current month, and next month (10 total)
|
||||
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
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
period: lancamentos.period,
|
||||
totalAmount: sql<string>`SUM(ABS(${lancamentos.amount}))`.as(
|
||||
"total_amount",
|
||||
),
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(categorias.userId, userId),
|
||||
inArray(lancamentos.period, periods),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
lancamentos.period,
|
||||
)) as MonthlyCategoryRow[];
|
||||
|
||||
if (monthlyDataQuery.length === 0) {
|
||||
return {
|
||||
months: monthLabels,
|
||||
categories: [],
|
||||
chartData: monthLabels.map((month) => ({ month })),
|
||||
allCategories,
|
||||
};
|
||||
}
|
||||
|
||||
// Get unique categories from query results
|
||||
const uniqueCategories: UniqueCategory[] = Array.from(
|
||||
new Map<string, UniqueCategory>(
|
||||
monthlyDataQuery.map((row) => [
|
||||
row.categoryId,
|
||||
{
|
||||
id: row.categoryId,
|
||||
name: row.categoryName,
|
||||
icon: row.categoryIcon,
|
||||
},
|
||||
]),
|
||||
).values(),
|
||||
);
|
||||
|
||||
// Transform data into chart-ready format
|
||||
const categoriesMap = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
color: string;
|
||||
data: Record<string, number>;
|
||||
}
|
||||
>();
|
||||
|
||||
// Initialize ALL categories with transactions with all months set to 0
|
||||
uniqueCategories.forEach((cat, index) => {
|
||||
const monthData: Record<string, number> = {};
|
||||
periods.forEach((_period, periodIndex) => {
|
||||
monthData[monthLabels[periodIndex]] = 0;
|
||||
});
|
||||
|
||||
categoriesMap.set(cat.id, {
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
icon: cat.icon,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
data: monthData,
|
||||
});
|
||||
});
|
||||
|
||||
// Fill in actual values from monthly data
|
||||
monthlyDataQuery.forEach((row) => {
|
||||
const category = categoriesMap.get(row.categoryId);
|
||||
if (category) {
|
||||
const periodIndex = periods.indexOf(row.period);
|
||||
if (periodIndex !== -1) {
|
||||
const monthLabel = monthLabels[periodIndex];
|
||||
category.data[monthLabel] = toNumber(row.totalAmount);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to chart data format
|
||||
const chartData: CategoryHistoryData["chartData"] = monthLabels.map(
|
||||
(month) => {
|
||||
const dataPoint: {
|
||||
month: string;
|
||||
[categoryName: string]: number | string;
|
||||
} = { month };
|
||||
|
||||
categoriesMap.forEach((category) => {
|
||||
dataPoint[category.name] = category.data[month];
|
||||
});
|
||||
|
||||
return dataPoint;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
months: monthLabels,
|
||||
categories: Array.from(categoriesMap.values()),
|
||||
chartData,
|
||||
allCategories,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { categorias, lancamentos, orcamentos } from "@/db/schema";
|
||||
import {
|
||||
buildCategoryBreakdownData,
|
||||
type DashboardCategoryBreakdownData,
|
||||
type DashboardCategoryBreakdownItem,
|
||||
} from "@/features/dashboard/categories/category-breakdown";
|
||||
import {
|
||||
buildDashboardAdminFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
} from "@/features/dashboard/lancamento-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { getPreviousPeriod } from "@/shared/utils/period";
|
||||
|
||||
export type CategoryExpenseItem = DashboardCategoryBreakdownItem;
|
||||
export type ExpensesByCategoryData = DashboardCategoryBreakdownData;
|
||||
|
||||
export async function fetchExpensesByCategory(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<ExpensesByCategoryData> {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { categories: [], currentTotal: 0, previousTotal: 0 };
|
||||
}
|
||||
|
||||
// Single query: GROUP BY categoriaId + period for both current and previous periods
|
||||
const [rows, budgetRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
period: lancamentos.period,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminFilters({ userId, adminPagadorId }),
|
||||
inArray(lancamentos.period, [period, previousPeriod]),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(categorias.type, "despesa"),
|
||||
excludeAutoInvoiceEntries(),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
lancamentos.period,
|
||||
),
|
||||
db
|
||||
.select({
|
||||
categoriaId: orcamentos.categoriaId,
|
||||
amount: orcamentos.amount,
|
||||
})
|
||||
.from(orcamentos)
|
||||
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
|
||||
]);
|
||||
|
||||
return buildCategoryBreakdownData({
|
||||
rows,
|
||||
budgetRows,
|
||||
period,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { categorias, contas, lancamentos, orcamentos } from "@/db/schema";
|
||||
import {
|
||||
buildCategoryBreakdownData,
|
||||
type DashboardCategoryBreakdownData,
|
||||
type DashboardCategoryBreakdownItem,
|
||||
} from "@/features/dashboard/categories/category-breakdown";
|
||||
import {
|
||||
buildDashboardAdminFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
excludeInitialBalanceWhenConfigured,
|
||||
} from "@/features/dashboard/lancamento-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { getPreviousPeriod } from "@/shared/utils/period";
|
||||
|
||||
export type CategoryIncomeItem = DashboardCategoryBreakdownItem;
|
||||
export type IncomeByCategoryData = DashboardCategoryBreakdownData;
|
||||
|
||||
export async function fetchIncomeByCategory(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<IncomeByCategoryData> {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { categories: [], currentTotal: 0, previousTotal: 0 };
|
||||
}
|
||||
|
||||
// Single query: GROUP BY categoriaId + period for both current and previous periods
|
||||
const [rows, budgetRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
period: lancamentos.period,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminFilters({ userId, adminPagadorId }),
|
||||
inArray(lancamentos.period, [period, previousPeriod]),
|
||||
eq(lancamentos.transactionType, "Receita"),
|
||||
eq(categorias.type, "receita"),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeInitialBalanceWhenConfigured(),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
lancamentos.period,
|
||||
),
|
||||
db
|
||||
.select({
|
||||
categoriaId: orcamentos.categoriaId,
|
||||
amount: orcamentos.amount,
|
||||
})
|
||||
.from(orcamentos)
|
||||
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
|
||||
]);
|
||||
|
||||
return buildCategoryBreakdownData({
|
||||
rows,
|
||||
budgetRows,
|
||||
period,
|
||||
});
|
||||
}
|
||||
35
src/features/dashboard/components/bill-widget.tsx
Normal file
35
src/features/dashboard/components/bill-widget.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import { useBillWidgetController } from "@/features/dashboard/use-bill-widget-controller";
|
||||
import { BillsWidgetView } from "./bills/bills-widget-view";
|
||||
|
||||
type BillWidgetProps = {
|
||||
bills?: DashboardBill[];
|
||||
};
|
||||
|
||||
export function BillWidget({ bills }: BillWidgetProps) {
|
||||
const {
|
||||
items,
|
||||
selectedBill,
|
||||
isModalOpen,
|
||||
modalState,
|
||||
isPending,
|
||||
openPaymentDialog,
|
||||
closePaymentDialog,
|
||||
confirmPayment,
|
||||
} = useBillWidgetController(bills);
|
||||
|
||||
return (
|
||||
<BillsWidgetView
|
||||
bills={items}
|
||||
selectedBill={selectedBill}
|
||||
isModalOpen={isModalOpen}
|
||||
modalState={modalState}
|
||||
isPending={isPending}
|
||||
onOpenPaymentDialog={openPaymentDialog}
|
||||
onClosePaymentDialog={closePaymentDialog}
|
||||
onConfirmPayment={confirmPayment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
73
src/features/dashboard/components/bills/bill-list-item.tsx
Normal file
73
src/features/dashboard/components/bills/bill-list-item.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { RiCheckboxCircleFill } from "@remixicon/react";
|
||||
import {
|
||||
buildBillStatusLabel,
|
||||
isBillOverdue,
|
||||
} from "@/features/dashboard/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import { EstabelecimentoLogo } from "@/features/transactions/components/shared/establishment-logo";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
type BillListItemProps = {
|
||||
bill: DashboardBill;
|
||||
onPay: (billId: string) => void;
|
||||
};
|
||||
|
||||
export function BillListItem({ bill, onPay }: BillListItemProps) {
|
||||
const statusLabel = buildBillStatusLabel(bill);
|
||||
const overdue = isBillOverdue(bill);
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
|
||||
<EstabelecimentoLogo name={bill.name} size={37} />
|
||||
|
||||
<div className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
{bill.name}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{statusLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full py-0.5",
|
||||
bill.isSettled && "text-success",
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={bill.amount} />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="h-auto p-0 disabled:opacity-100"
|
||||
disabled={bill.isSettled}
|
||||
onClick={() => onPay(bill.id)}
|
||||
>
|
||||
{bill.isSettled ? (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<RiCheckboxCircleFill className="size-3" /> Pago
|
||||
</span>
|
||||
) : overdue ? (
|
||||
<span className="overdue-blink">
|
||||
<span className="overdue-blink-primary text-destructive">
|
||||
Atrasado
|
||||
</span>
|
||||
<span className="overdue-blink-secondary">Pagar</span>
|
||||
</span>
|
||||
) : (
|
||||
"Pagar"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
189
src/features/dashboard/components/bills/bill-payment-dialog.tsx
Normal file
189
src/features/dashboard/components/bills/bill-payment-dialog.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
RiBarcodeFill,
|
||||
RiCheckboxCircleLine,
|
||||
RiLoader4Line,
|
||||
RiMoneyDollarCircleLine,
|
||||
} from "@remixicon/react";
|
||||
import {
|
||||
type BillDialogState,
|
||||
formatBillDateLabel,
|
||||
getBillStatusBadgeVariant,
|
||||
} from "@/features/dashboard/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
|
||||
type BillPaymentDialogProps = {
|
||||
bill: DashboardBill | null;
|
||||
open: boolean;
|
||||
modalState: BillDialogState;
|
||||
isPending: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function BillPaymentDialog({
|
||||
bill,
|
||||
open,
|
||||
modalState,
|
||||
isPending,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: BillPaymentDialogProps) {
|
||||
const isProcessing = modalState === "processing" || isPending;
|
||||
const dueLabel = bill
|
||||
? formatBillDateLabel(bill.dueDate, "Vencimento:")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen || isProcessing) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[calc(100%-2rem)] sm:max-w-md"
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (isProcessing) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (isProcessing) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{modalState === "success" ? (
|
||||
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
|
||||
<RiCheckboxCircleLine className="size-8" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<DialogTitle className="text-base">
|
||||
Pagamento registrado!
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
Atualizamos o status do boleto para pago. Em instantes ele
|
||||
aparecerá como baixado no histórico.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogFooter className="sm:justify-center">
|
||||
<Button type="button" onClick={onClose} className="sm:w-auto">
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar pagamento do boleto</DialogTitle>
|
||||
<DialogDescription>
|
||||
Confirme os dados para registrar o pagamento. Você poderá editar
|
||||
o lançamento depois, se necessário.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{bill ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<RiBarcodeFill className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Boleto
|
||||
</p>
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
{bill.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{dueLabel ? (
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{dueLabel}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RiMoneyDollarCircleLine className="size-4" />
|
||||
<span className="text-xs font-semibold uppercase">
|
||||
Valor do Boleto
|
||||
</span>
|
||||
</div>
|
||||
<MoneyValues
|
||||
amount={bill.amount}
|
||||
className="text-lg font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RiCheckboxCircleLine className="size-4" />
|
||||
<span className="text-xs font-semibold uppercase">
|
||||
Status
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={getBillStatusBadgeVariant(
|
||||
bill.isSettled ? "Pago" : "Pendente",
|
||||
)}
|
||||
>
|
||||
{bill.isSettled ? "Pago" : "Pendente"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isProcessing || !bill || bill.isSettled}
|
||||
className="relative"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
|
||||
Processando...
|
||||
</>
|
||||
) : (
|
||||
"Confirmar pagamento"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
29
src/features/dashboard/components/bills/bills-list.tsx
Normal file
29
src/features/dashboard/components/bills/bills-list.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { RiBarcodeFill } from "@remixicon/react";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { BillListItem } from "./bill-list-item";
|
||||
|
||||
type BillsListProps = {
|
||||
bills: DashboardBill[];
|
||||
onPay: (billId: string) => void;
|
||||
};
|
||||
|
||||
export function BillsList({ bills, onPay }: BillsListProps) {
|
||||
if (bills.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiBarcodeFill className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum boleto cadastrado para o período selecionado"
|
||||
description="Cadastre boletos para monitorar os pagamentos aqui."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col">
|
||||
{bills.map((bill) => (
|
||||
<BillListItem key={bill.id} bill={bill} onPay={onPay} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { BillDialogState } from "@/features/dashboard/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import { BillPaymentDialog } from "./bill-payment-dialog";
|
||||
import { BillsList } from "./bills-list";
|
||||
|
||||
type BillsWidgetViewProps = {
|
||||
bills: DashboardBill[];
|
||||
selectedBill: DashboardBill | null;
|
||||
isModalOpen: boolean;
|
||||
modalState: BillDialogState;
|
||||
isPending: boolean;
|
||||
onOpenPaymentDialog: (billId: string) => void;
|
||||
onClosePaymentDialog: () => void;
|
||||
onConfirmPayment: () => void;
|
||||
};
|
||||
|
||||
export function BillsWidgetView({
|
||||
bills,
|
||||
selectedBill,
|
||||
isModalOpen,
|
||||
modalState,
|
||||
isPending,
|
||||
onOpenPaymentDialog,
|
||||
onClosePaymentDialog,
|
||||
onConfirmPayment,
|
||||
}: BillsWidgetViewProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<BillsList bills={bills} onPay={onOpenPaymentDialog} />
|
||||
</div>
|
||||
|
||||
<BillPaymentDialog
|
||||
bill={selectedBill}
|
||||
open={isModalOpen}
|
||||
modalState={modalState}
|
||||
isPending={isPending}
|
||||
onClose={onClosePaymentDialog}
|
||||
onConfirm={onConfirmPayment}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiArrowDownSFill,
|
||||
RiArrowUpSFill,
|
||||
RiExternalLinkLine,
|
||||
RiListUnordered,
|
||||
RiPieChart2Line,
|
||||
RiPieChartLine,
|
||||
RiWallet3Line,
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Pie, PieChart, Tooltip } from "recharts";
|
||||
import { CategoryIconBadge } from "@/features/categories/components/category-icon-badge";
|
||||
import type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { type ChartConfig, ChartContainer } from "@/shared/components/ui/chart";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/shared/components/ui/tabs";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
import { formatPercentage as formatPercentageValue } from "@/shared/utils/percentage";
|
||||
import { formatPeriodForUrl } from "@/shared/utils/period";
|
||||
|
||||
type CategoryBreakdownVariant = "income" | "expense";
|
||||
|
||||
type CategoryBreakdownWidgetViewProps = {
|
||||
data: DashboardCategoryBreakdownData;
|
||||
period: string;
|
||||
variant: CategoryBreakdownVariant;
|
||||
};
|
||||
|
||||
const CATEGORY_BREAKDOWN_COLORS = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
"var(--chart-5)",
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
];
|
||||
|
||||
const VARIANT_CONFIG = {
|
||||
income: {
|
||||
emptyTitle: "Nenhuma receita encontrada",
|
||||
emptyDescription:
|
||||
"Quando houver receitas registradas, elas aparecerão aqui.",
|
||||
shareLabel: "receita total",
|
||||
percentageDigits: 1,
|
||||
changeClassName: {
|
||||
increase: "text-success",
|
||||
decrease: "text-destructive",
|
||||
},
|
||||
listItemClassName:
|
||||
"flex flex-col gap-1.5 py-2 border-b border-dashed last:border-0",
|
||||
includeBudgetAmount: true,
|
||||
},
|
||||
expense: {
|
||||
emptyTitle: "Nenhuma despesa encontrada",
|
||||
emptyDescription:
|
||||
"Quando houver despesas registradas, elas aparecerão aqui.",
|
||||
shareLabel: "despesa total",
|
||||
percentageDigits: 0,
|
||||
changeClassName: {
|
||||
increase: "text-destructive",
|
||||
decrease: "text-success",
|
||||
},
|
||||
listItemClassName:
|
||||
"flex flex-col py-2 border-b border-dashed last:border-0",
|
||||
includeBudgetAmount: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const formatPercentage = (value: number, digits: number) =>
|
||||
formatPercentageValue(value, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
export function CategoryBreakdownWidgetView({
|
||||
data,
|
||||
period,
|
||||
variant,
|
||||
}: CategoryBreakdownWidgetViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
|
||||
const periodParam = formatPeriodForUrl(period);
|
||||
const config = VARIANT_CONFIG[variant];
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
const nextConfig: ChartConfig = {};
|
||||
|
||||
if (data.categories.length <= 7) {
|
||||
data.categories.forEach((category, index) => {
|
||||
nextConfig[category.categoryId] = {
|
||||
label: category.categoryName,
|
||||
color:
|
||||
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const topCategories = data.categories.slice(0, 7);
|
||||
topCategories.forEach((category, index) => {
|
||||
nextConfig[category.categoryId] = {
|
||||
label: category.categoryName,
|
||||
color:
|
||||
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
|
||||
};
|
||||
});
|
||||
nextConfig.outros = {
|
||||
label: "Outros",
|
||||
color: "var(--chart-6)",
|
||||
};
|
||||
}
|
||||
|
||||
return nextConfig;
|
||||
}, [data.categories]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (data.categories.length <= 7) {
|
||||
return data.categories.map((category) => ({
|
||||
category: category.categoryId,
|
||||
name: category.categoryName,
|
||||
value: category.currentAmount,
|
||||
percentage: category.percentageOfTotal,
|
||||
fill: chartConfig[category.categoryId]?.color,
|
||||
}));
|
||||
}
|
||||
|
||||
const topCategories = data.categories.slice(0, 7);
|
||||
const otherCategories = data.categories.slice(7);
|
||||
const otherTotal = otherCategories.reduce(
|
||||
(sum, category) => sum + category.currentAmount,
|
||||
0,
|
||||
);
|
||||
const otherPercentage = otherCategories.reduce(
|
||||
(sum, category) => sum + category.percentageOfTotal,
|
||||
0,
|
||||
);
|
||||
|
||||
const groupedData = topCategories.map((category) => ({
|
||||
category: category.categoryId,
|
||||
name: category.categoryName,
|
||||
value: category.currentAmount,
|
||||
percentage: category.percentageOfTotal,
|
||||
fill: chartConfig[category.categoryId]?.color,
|
||||
}));
|
||||
|
||||
if (otherCategories.length > 0) {
|
||||
groupedData.push({
|
||||
category: "outros",
|
||||
name: "Outros",
|
||||
value: otherTotal,
|
||||
percentage: otherPercentage,
|
||||
fill: chartConfig.outros?.color,
|
||||
});
|
||||
}
|
||||
|
||||
return groupedData;
|
||||
}, [data.categories, chartConfig]);
|
||||
|
||||
if (data.categories.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
|
||||
title={config.emptyTitle}
|
||||
description={config.emptyDescription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value: string) => setActiveTab(value as "list" | "chart")}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="list" className="text-xs">
|
||||
<RiListUnordered className="mr-1 size-3.5" />
|
||||
Lista
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chart" className="text-xs">
|
||||
<RiPieChart2Line className="mr-1 size-3.5" />
|
||||
Gráfico
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="list" className="mt-0">
|
||||
<div className="flex flex-col px-0">
|
||||
{data.categories.map((category, index) => {
|
||||
const hasIncrease =
|
||||
category.percentageChange !== null &&
|
||||
category.percentageChange > 0;
|
||||
const hasDecrease =
|
||||
category.percentageChange !== null &&
|
||||
category.percentageChange < 0;
|
||||
const hasBudget = category.budgetAmount !== null;
|
||||
const budgetExceeded =
|
||||
hasBudget &&
|
||||
category.budgetUsedPercentage !== null &&
|
||||
category.budgetUsedPercentage > 100;
|
||||
const exceededAmount =
|
||||
budgetExceeded && category.budgetAmount
|
||||
? category.currentAmount - category.budgetAmount
|
||||
: 0;
|
||||
const changeClassName = hasIncrease
|
||||
? config.changeClassName.increase
|
||||
: hasDecrease
|
||||
? config.changeClassName.decrease
|
||||
: "text-muted-foreground";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category.categoryId}
|
||||
className={config.listItemClassName}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<CategoryIconBadge
|
||||
icon={category.categoryIcon}
|
||||
name={category.categoryName}
|
||||
colorIndex={index}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/categories/${category.categoryId}?periodo=${periodParam}`}
|
||||
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
<span className="truncate">
|
||||
{category.categoryName}
|
||||
</span>
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatPercentage(
|
||||
category.percentageOfTotal,
|
||||
config.percentageDigits,
|
||||
)}{" "}
|
||||
da {config.shareLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||
<MoneyValues
|
||||
className="text-foreground"
|
||||
amount={category.currentAmount}
|
||||
/>
|
||||
{category.percentageChange !== null ? (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs ${changeClassName}`}
|
||||
>
|
||||
{hasIncrease ? (
|
||||
<RiArrowUpSFill className="size-3" />
|
||||
) : null}
|
||||
{hasDecrease ? (
|
||||
<RiArrowDownSFill className="size-3" />
|
||||
) : null}
|
||||
{formatPercentage(
|
||||
category.percentageChange,
|
||||
config.percentageDigits,
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasBudget && category.budgetUsedPercentage !== null ? (
|
||||
<div className="ml-11 flex items-center gap-1.5 text-xs">
|
||||
<RiWallet3Line
|
||||
className={`size-3 ${
|
||||
budgetExceeded ? "text-destructive" : "text-info"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
budgetExceeded ? "text-destructive" : "text-info"
|
||||
}
|
||||
>
|
||||
{budgetExceeded ? (
|
||||
<>
|
||||
{formatPercentage(
|
||||
category.budgetUsedPercentage,
|
||||
config.percentageDigits,
|
||||
)}{" "}
|
||||
do limite
|
||||
{config.includeBudgetAmount &&
|
||||
category.budgetAmount !== null
|
||||
? ` ${formatCurrency(category.budgetAmount)}`
|
||||
: ""}{" "}
|
||||
- excedeu em {formatCurrency(exceededAmount)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{formatPercentage(
|
||||
category.budgetUsedPercentage,
|
||||
config.percentageDigits,
|
||||
)}{" "}
|
||||
do limite
|
||||
{config.includeBudgetAmount &&
|
||||
category.budgetAmount !== null
|
||||
? ` ${formatCurrency(category.budgetAmount)}`
|
||||
: ""}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="chart" className="mt-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ payload }) =>
|
||||
formatPercentage(
|
||||
(payload as { percentage?: number } | undefined)
|
||||
?.percentage ?? 0,
|
||||
config.percentageDigits,
|
||||
)
|
||||
}
|
||||
outerRadius={75}
|
||||
dataKey="value"
|
||||
nameKey="category"
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = payload[0]?.payload;
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="font-bold text-foreground">
|
||||
{formatCurrency(entry.value)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatPercentage(
|
||||
entry.percentage,
|
||||
config.percentageDigits,
|
||||
)}{" "}
|
||||
do total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="min-w-[140px] flex flex-col gap-2">
|
||||
{chartData.map((entry, index) => (
|
||||
<div key={`legend-${index}`} className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: entry.fill }}
|
||||
/>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
443
src/features/dashboard/components/category-history-widget.tsx
Normal file
443
src/features/dashboard/components/category-history-widget.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
"use client";
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiBarChartBoxLine,
|
||||
RiCloseLine,
|
||||
} from "@remixicon/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import type { CategoryHistoryData } from "@/features/dashboard/categories/category-history-queries";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
} from "@/shared/components/ui/chart";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/shared/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/components/ui/popover";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { CATEGORY_COLORS } from "@/shared/utils/category-colors";
|
||||
import { formatCurrency, formatCurrencyCompact } from "@/shared/utils/currency";
|
||||
import { getIconComponent } from "@/shared/utils/icons";
|
||||
|
||||
type CategoryHistoryWidgetProps = {
|
||||
data: CategoryHistoryData;
|
||||
};
|
||||
|
||||
const STORAGE_KEY_SELECTED = "dashboard-category-history-selected";
|
||||
|
||||
const CHART_COLORS = CATEGORY_COLORS;
|
||||
|
||||
export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
// Load from sessionStorage on mount and save on changes
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
|
||||
// Only load from storage on first render
|
||||
if (isFirstRender.current) {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed)) {
|
||||
const validCategories = parsed.filter((id) =>
|
||||
data.allCategories.some((cat) => cat.id === id),
|
||||
);
|
||||
setSelectedCategories(validCategories.slice(0, 5));
|
||||
}
|
||||
} catch (_e) {
|
||||
// Invalid JSON, ignore
|
||||
}
|
||||
}
|
||||
isFirstRender.current = false;
|
||||
} else {
|
||||
// Save to storage on subsequent changes
|
||||
sessionStorage.setItem(
|
||||
STORAGE_KEY_SELECTED,
|
||||
JSON.stringify(selectedCategories),
|
||||
);
|
||||
}
|
||||
}, [selectedCategories, data.allCategories]);
|
||||
|
||||
// Filter data to show only selected categories with vibrant colors
|
||||
const filteredCategories = useMemo(() => {
|
||||
return selectedCategories
|
||||
.map((id, index) => {
|
||||
const cat = data.categories.find((c) => c.id === id);
|
||||
if (!cat) return null;
|
||||
return {
|
||||
...cat,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
color: string;
|
||||
data: Record<string, number>;
|
||||
}>;
|
||||
}, [data.categories, selectedCategories]);
|
||||
|
||||
// Filter chart data to include only selected categories
|
||||
const filteredChartData = useMemo(() => {
|
||||
if (filteredCategories.length === 0) {
|
||||
return data.chartData.map((item) => ({ month: item.month }));
|
||||
}
|
||||
|
||||
return data.chartData.map((item) => {
|
||||
const filtered: Record<string, number | string> = { month: item.month };
|
||||
filteredCategories.forEach((category) => {
|
||||
filtered[category.name] = item[category.name] || 0;
|
||||
});
|
||||
return filtered;
|
||||
});
|
||||
}, [data.chartData, filteredCategories]);
|
||||
|
||||
// Build chart config dynamically from filtered categories
|
||||
const chartConfig = useMemo(() => {
|
||||
const config: ChartConfig = {};
|
||||
|
||||
filteredCategories.forEach((category) => {
|
||||
config[category.name] = {
|
||||
label: category.name,
|
||||
color: category.color,
|
||||
};
|
||||
});
|
||||
|
||||
return config;
|
||||
}, [filteredCategories]);
|
||||
|
||||
const handleAddCategory = (categoryId: string) => {
|
||||
if (
|
||||
categoryId &&
|
||||
!selectedCategories.includes(categoryId) &&
|
||||
selectedCategories.length < 5
|
||||
) {
|
||||
setSelectedCategories([...selectedCategories, categoryId]);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCategory = (categoryId: string) => {
|
||||
setSelectedCategories(selectedCategories.filter((id) => id !== categoryId));
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
setSelectedCategories([]);
|
||||
};
|
||||
|
||||
const availableCategories = useMemo(() => {
|
||||
return data.allCategories.filter(
|
||||
(cat) => !selectedCategories.includes(cat.id),
|
||||
);
|
||||
}, [data.allCategories, selectedCategories]);
|
||||
|
||||
const selectedCategoryDetails = useMemo(() => {
|
||||
return selectedCategories
|
||||
.map((id) => data.allCategories.find((cat) => cat.id === id))
|
||||
.filter(Boolean);
|
||||
}, [selectedCategories, data.allCategories]);
|
||||
|
||||
const isEmpty = filteredCategories.length === 0;
|
||||
|
||||
// Group available categories by type
|
||||
const { despesaCategories, receitaCategories } = useMemo(() => {
|
||||
const despesa = availableCategories.filter((cat) => cat.type === "despesa");
|
||||
const receita = availableCategories.filter((cat) => cat.type === "receita");
|
||||
return { despesaCategories: despesa, receitaCategories: receita };
|
||||
}, [availableCategories]);
|
||||
|
||||
if (!isClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-auto">
|
||||
<CardContent className="space-y-2.5">
|
||||
<div className="space-y-2">
|
||||
{selectedCategoryDetails.length > 0 && (
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedCategoryDetails.map((category) => {
|
||||
if (!category) return null;
|
||||
const IconComponent = category.icon
|
||||
? getIconComponent(category.icon)
|
||||
: null;
|
||||
const colorIndex = selectedCategories.indexOf(category.id);
|
||||
const color = CHART_COLORS[colorIndex % CHART_COLORS.length];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||
style={{ borderColor: color }}
|
||||
>
|
||||
{IconComponent ? (
|
||||
<span style={{ color }}>
|
||||
<IconComponent className="size-4" />
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
className="size-3 rounded-sm"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-foreground">{category.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||
onClick={() => handleRemoveCategory(category.id)}
|
||||
>
|
||||
<RiCloseLine className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 pt-1.5">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{selectedCategories.length}/5 selecionadas
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCategories.length < 5 && availableCategories.length > 0 && (
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between hover:scale-none"
|
||||
>
|
||||
Selecionar categorias
|
||||
<RiArrowDownSLine className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-(--radix-popover-trigger-width) p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Pesquisar categoria..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
|
||||
|
||||
{despesaCategories.length > 0 && (
|
||||
<CommandGroup heading="Despesas">
|
||||
{despesaCategories.map((category) => {
|
||||
const IconComponent = category.icon
|
||||
? getIconComponent(category.icon)
|
||||
: null;
|
||||
return (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
value={category.name}
|
||||
onSelect={() => handleAddCategory(category.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
{IconComponent ? (
|
||||
<IconComponent className="size-4 text-destructive" />
|
||||
) : (
|
||||
<div className="size-3 rounded-sm bg-destructive" />
|
||||
)}
|
||||
<span>{category.name}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{receitaCategories.length > 0 && (
|
||||
<CommandGroup heading="Receitas">
|
||||
{receitaCategories.map((category) => {
|
||||
const IconComponent = category.icon
|
||||
? getIconComponent(category.icon)
|
||||
: null;
|
||||
return (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
value={category.name}
|
||||
onSelect={() => handleAddCategory(category.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
{IconComponent ? (
|
||||
<IconComponent className="size-4 text-success" />
|
||||
) : (
|
||||
<div className="size-3 rounded-sm bg-success" />
|
||||
)}
|
||||
<span>{category.name}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="h-[450px] flex items-center justify-center">
|
||||
<WidgetEmptyState
|
||||
icon={
|
||||
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
|
||||
}
|
||||
title="Selecione categorias para visualizar"
|
||||
description="Escolha até 5 categorias para acompanhar o histórico dos últimos 8 meses, mês atual e próximo mês."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="h-[450px] w-full">
|
||||
<AreaChart
|
||||
data={filteredChartData}
|
||||
margin={{ top: 10, right: 20, left: 10, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
{filteredCategories.map((category) => (
|
||||
<linearGradient
|
||||
key={`gradient-${category.id}`}
|
||||
id={`gradient-${category.id}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={category.color}
|
||||
stopOpacity={0.4}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={category.color}
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
className="text-xs"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
className="text-xs"
|
||||
tickFormatter={(value) => formatCurrencyCompact(Number(value))}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload || payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort payload by value (descending)
|
||||
const sortedPayload = [...payload].sort(
|
||||
(a, b) => (b.value as number) - (a.value as number),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
{payload[0].payload.month}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
{sortedPayload
|
||||
.filter((entry) => (entry.value as number) > 0)
|
||||
.map((entry) => {
|
||||
const config =
|
||||
chartConfig[
|
||||
entry.dataKey as keyof typeof chartConfig
|
||||
];
|
||||
const value = entry.value as number;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.dataKey}
|
||||
className="flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: config?.color }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
|
||||
{config?.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium tabular-nums">
|
||||
{formatCurrency(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
cursor={{
|
||||
stroke: "hsl(var(--muted-foreground))",
|
||||
strokeWidth: 1,
|
||||
}}
|
||||
/>
|
||||
{filteredCategories.map((category) => (
|
||||
<Area
|
||||
key={category.id}
|
||||
type="monotone"
|
||||
dataKey={category.name}
|
||||
stroke={category.color}
|
||||
strokeWidth={1}
|
||||
fill={`url(#gradient-${category.id})`}
|
||||
fillOpacity={1}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 5,
|
||||
fill: category.color,
|
||||
stroke: "hsl(var(--background))",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
390
src/features/dashboard/components/dashboard-grid-editable.tsx
Normal file
390
src/features/dashboard/components/dashboard-grid-editable.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
closestCorners,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
rectSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
RiAddCircleLine,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiDragMove2Line,
|
||||
RiEyeOffLine,
|
||||
RiTodoLine,
|
||||
} from "@remixicon/react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { SortableWidget } from "@/features/dashboard/components/sortable-widget";
|
||||
import { WidgetSettingsDialog } from "@/features/dashboard/components/widget-settings-dialog";
|
||||
import type { DashboardData } from "@/features/dashboard/fetch-dashboard-data";
|
||||
import {
|
||||
resetWidgetPreferences,
|
||||
updateWidgetPreferences,
|
||||
type WidgetPreferences,
|
||||
} from "@/features/dashboard/widgets/actions";
|
||||
import {
|
||||
type WidgetConfig,
|
||||
widgetsConfig,
|
||||
} from "@/features/dashboard/widgets/widgets-config";
|
||||
import { NoteDialog } from "@/features/notes/components/note-dialog";
|
||||
import { LancamentoDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
||||
import type { SelectOption } from "@/features/transactions/components/types";
|
||||
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
type DashboardGridEditableProps = {
|
||||
data: DashboardData;
|
||||
period: string;
|
||||
initialPreferences: WidgetPreferences | null;
|
||||
quickActionOptions: {
|
||||
pagadorOptions: SelectOption[];
|
||||
splitPagadorOptions: SelectOption[];
|
||||
defaultPagadorId: string | null;
|
||||
contaOptions: SelectOption[];
|
||||
cartaoOptions: SelectOption[];
|
||||
categoriaOptions: SelectOption[];
|
||||
estabelecimentos: string[];
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_WIDGET_ORDER = widgetsConfig.map((widget) => widget.id);
|
||||
|
||||
export function DashboardGridEditable({
|
||||
data,
|
||||
period,
|
||||
initialPreferences,
|
||||
quickActionOptions,
|
||||
}: DashboardGridEditableProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Initialize widget order and hidden state
|
||||
const [widgetOrder, setWidgetOrder] = useState<string[]>(
|
||||
initialPreferences?.order ?? DEFAULT_WIDGET_ORDER,
|
||||
);
|
||||
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
|
||||
initialPreferences?.hidden ?? [],
|
||||
);
|
||||
|
||||
// Keep track of original state for cancel
|
||||
const [originalOrder, setOriginalOrder] = useState(widgetOrder);
|
||||
const [originalHidden, setOriginalHidden] = useState(hiddenWidgets);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
// Get ordered and visible widgets
|
||||
const orderedWidgets = useMemo(() => {
|
||||
// Create a map for quick lookup
|
||||
const widgetMap = new Map(widgetsConfig.map((w) => [w.id, w]));
|
||||
|
||||
// Get widgets in order, filtering out hidden ones
|
||||
const ordered: WidgetConfig[] = [];
|
||||
for (const id of widgetOrder) {
|
||||
const widget = widgetMap.get(id);
|
||||
if (widget && !hiddenWidgets.includes(id)) {
|
||||
ordered.push(widget);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any new widgets that might not be in the order yet
|
||||
for (const widget of widgetsConfig) {
|
||||
if (
|
||||
!widgetOrder.includes(widget.id) &&
|
||||
!hiddenWidgets.includes(widget.id)
|
||||
) {
|
||||
ordered.push(widget);
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}, [widgetOrder, hiddenWidgets]);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
setWidgetOrder((items) => {
|
||||
const oldIndex = items.indexOf(active.id as string);
|
||||
const newIndex = items.indexOf(over.id as string);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleWidget = (widgetId: string) => {
|
||||
const newHidden = hiddenWidgets.includes(widgetId)
|
||||
? hiddenWidgets.filter((id) => id !== widgetId)
|
||||
: [...hiddenWidgets, widgetId];
|
||||
|
||||
setHiddenWidgets(newHidden);
|
||||
|
||||
// Salvar automaticamente ao toggle
|
||||
startTransition(async () => {
|
||||
await updateWidgetPreferences({
|
||||
order: widgetOrder,
|
||||
hidden: newHidden,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleHideWidget = (widgetId: string) => {
|
||||
setHiddenWidgets((prev) => [...prev, widgetId]);
|
||||
};
|
||||
|
||||
const handleStartEditing = () => {
|
||||
setOriginalOrder(widgetOrder);
|
||||
setOriginalHidden(hiddenWidgets);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleCancelEditing = () => {
|
||||
setWidgetOrder(originalOrder);
|
||||
setHiddenWidgets(originalHidden);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
startTransition(async () => {
|
||||
const result = await updateWidgetPreferences({
|
||||
order: widgetOrder,
|
||||
hidden: hiddenWidgets,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Preferências salvas!");
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
toast.error(result.error ?? "Erro ao salvar");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
startTransition(async () => {
|
||||
const result = await resetWidgetPreferences();
|
||||
|
||||
if (result.success) {
|
||||
setWidgetOrder(DEFAULT_WIDGET_ORDER);
|
||||
setHiddenWidgets([]);
|
||||
toast.success("Preferências restauradas!");
|
||||
} else {
|
||||
toast.error(result.error ?? "Erro ao restaurar");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{!isEditing ? (
|
||||
<div className="flex w-full min-w-0 flex-col gap-1 px-1 sm:w-auto sm:flex-row sm:items-center sm:gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Ações rápidas
|
||||
</span>
|
||||
<div className="-mb-1 grid w-full grid-cols-3 gap-1 pb-1 sm:mb-0 sm:flex sm:w-auto sm:items-center sm:gap-2 sm:overflow-visible sm:pb-0">
|
||||
<LancamentoDialog
|
||||
mode="create"
|
||||
pagadorOptions={quickActionOptions.pagadorOptions}
|
||||
splitPagadorOptions={quickActionOptions.splitPagadorOptions}
|
||||
defaultPagadorId={quickActionOptions.defaultPagadorId}
|
||||
contaOptions={quickActionOptions.contaOptions}
|
||||
cartaoOptions={quickActionOptions.cartaoOptions}
|
||||
categoriaOptions={quickActionOptions.categoriaOptions}
|
||||
estabelecimentos={quickActionOptions.estabelecimentos}
|
||||
defaultPeriod={period}
|
||||
defaultTransactionType="Receita"
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-12 w-full min-w-0 flex-col justify-center gap-0.5 px-1.5 text-sm whitespace-normal sm:h-8 sm:w-auto sm:flex-row sm:gap-2 sm:px-3 sm:whitespace-nowrap"
|
||||
>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<RiAddCircleLine className="size-3.5 shrink-0 text-success/80" />
|
||||
</span>
|
||||
<span className="sm:hidden">Receita</span>
|
||||
<span className="hidden sm:inline">Nova receita</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<LancamentoDialog
|
||||
mode="create"
|
||||
pagadorOptions={quickActionOptions.pagadorOptions}
|
||||
splitPagadorOptions={quickActionOptions.splitPagadorOptions}
|
||||
defaultPagadorId={quickActionOptions.defaultPagadorId}
|
||||
contaOptions={quickActionOptions.contaOptions}
|
||||
cartaoOptions={quickActionOptions.cartaoOptions}
|
||||
categoriaOptions={quickActionOptions.categoriaOptions}
|
||||
estabelecimentos={quickActionOptions.estabelecimentos}
|
||||
defaultPeriod={period}
|
||||
defaultTransactionType="Despesa"
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-12 w-full min-w-0 flex-col justify-center gap-0.5 px-1.5 text-sm whitespace-normal sm:h-8 sm:w-auto sm:flex-row sm:gap-2 sm:px-3 sm:whitespace-nowrap"
|
||||
>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<RiAddCircleLine className="size-3.5 shrink-0 text-destructive/80" />
|
||||
</span>
|
||||
<span className="sm:hidden">Despesa</span>
|
||||
<span className="hidden sm:inline">Nova despesa</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<NoteDialog
|
||||
mode="create"
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-12 w-full min-w-0 flex-col justify-center gap-0.5 px-1.5 text-sm whitespace-normal sm:h-8 sm:w-auto sm:flex-row sm:gap-2 sm:px-3 sm:whitespace-nowrap"
|
||||
>
|
||||
<RiTodoLine className="size-3.5 shrink-0 text-info/80" />
|
||||
<span className="sm:hidden">Anotação</span>
|
||||
<span className="hidden sm:inline">Nova anotação</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
<div className="flex w-full items-center justify-end gap-2 sm:w-auto">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelEditing}
|
||||
disabled={isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiCheckLine className="size-4" />
|
||||
Salvar
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto">
|
||||
<WidgetSettingsDialog
|
||||
hiddenWidgets={hiddenWidgets}
|
||||
onToggleWidget={handleToggleWidget}
|
||||
onReset={handleReset}
|
||||
triggerClassName="w-full sm:w-auto"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStartEditing}
|
||||
className="w-full gap-2 sm:w-auto"
|
||||
>
|
||||
<RiDragMove2Line className="size-4" />
|
||||
Reordenar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={orderedWidgets.map((w) => w.id)}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<section className="grid grid-cols-1 gap-3 @4xl/main:grid-cols-2 @6xl/main:grid-cols-3">
|
||||
{orderedWidgets.map((widget) => (
|
||||
<SortableWidget
|
||||
key={widget.id}
|
||||
id={widget.id}
|
||||
isEditing={isEditing}
|
||||
>
|
||||
<div className="relative">
|
||||
{isEditing && (
|
||||
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[1px] rounded-lg border-2 border-dashed border-primary/50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RiDragMove2Line className="size-8 text-primary" />
|
||||
<span className="text-xs font-bold">
|
||||
Arraste para mover
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHideWidget(widget.id);
|
||||
}}
|
||||
className="gap-1 mt-2"
|
||||
>
|
||||
<RiEyeOffLine className="size-4" />
|
||||
Ocultar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ExpandableWidgetCard
|
||||
title={widget.title}
|
||||
subtitle={widget.subtitle}
|
||||
icon={widget.icon}
|
||||
action={widget.action}
|
||||
>
|
||||
{widget.component({ data, period })}
|
||||
</ExpandableWidgetCard>
|
||||
</div>
|
||||
</SortableWidget>
|
||||
))}
|
||||
</section>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Hidden widgets indicator */}
|
||||
{hiddenWidgets.length > 0 && !isEditing && (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{hiddenWidgets.length} widget(s) oculto(s) •{" "}
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Restaurar todos
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
src/features/dashboard/components/dashboard-metrics-cards.tsx
Normal file
127
src/features/dashboard/components/dashboard-metrics-cards.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
RiArrowDownLine,
|
||||
RiArrowDownSFill,
|
||||
RiArrowUpLine,
|
||||
RiArrowUpSFill,
|
||||
RiCashLine,
|
||||
RiIncreaseDecreaseLine,
|
||||
RiSubtractLine,
|
||||
} from "@remixicon/react";
|
||||
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
|
||||
type DashboardMetricsCardsProps = {
|
||||
metrics: DashboardCardMetrics;
|
||||
};
|
||||
|
||||
type Trend = "up" | "down" | "flat";
|
||||
|
||||
const TREND_THRESHOLD = 0.005;
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
label: "Receitas",
|
||||
key: "receitas",
|
||||
icon: RiArrowUpLine,
|
||||
invertTrend: false,
|
||||
},
|
||||
{
|
||||
label: "Despesas",
|
||||
key: "despesas",
|
||||
icon: RiArrowDownLine,
|
||||
invertTrend: true,
|
||||
},
|
||||
{
|
||||
label: "Balanço",
|
||||
key: "balanco",
|
||||
icon: RiIncreaseDecreaseLine,
|
||||
invertTrend: false,
|
||||
},
|
||||
{ label: "Previsto", key: "previsto", icon: RiCashLine, invertTrend: false },
|
||||
] as const;
|
||||
|
||||
const TREND_ICONS = {
|
||||
up: RiArrowUpSFill,
|
||||
down: RiArrowDownSFill,
|
||||
flat: RiSubtractLine,
|
||||
} as const;
|
||||
|
||||
const getTrend = (current: number, previous: number): Trend => {
|
||||
const diff = current - previous;
|
||||
if (diff > TREND_THRESHOLD) return "up";
|
||||
if (diff < -TREND_THRESHOLD) return "down";
|
||||
return "flat";
|
||||
};
|
||||
|
||||
const getPercentChange = (current: number, previous: number): string => {
|
||||
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
|
||||
|
||||
if (Math.abs(previous) < EPSILON) {
|
||||
if (Math.abs(current) < EPSILON) return "0%";
|
||||
return "—";
|
||||
}
|
||||
|
||||
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||
return Number.isFinite(change) && Math.abs(change) < 1000000
|
||||
? formatPercentage(change, {
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: 1,
|
||||
signDisplay: "always",
|
||||
})
|
||||
: "—";
|
||||
};
|
||||
|
||||
const getTrendColor = (trend: Trend, invertTrend: boolean): string => {
|
||||
if (trend === "flat") return "";
|
||||
const isPositive = invertTrend ? trend === "down" : trend === "up";
|
||||
return isPositive
|
||||
? "text-success border-success"
|
||||
: "text-destructive border-destructive";
|
||||
};
|
||||
|
||||
export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||
return (
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||
{CARDS.map(({ label, key, icon: Icon, invertTrend }) => {
|
||||
const metric = metrics[key];
|
||||
const trend = getTrend(metric.current, metric.previous);
|
||||
const TrendIcon = TREND_ICONS[trend];
|
||||
const trendColor = getTrendColor(trend, invertTrend);
|
||||
|
||||
return (
|
||||
<Card key={label} className="@container/card gap-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-1 tracking-tighter lowercase">
|
||||
<Icon className="size-4" />
|
||||
{label}
|
||||
</CardTitle>
|
||||
<MoneyValues className="text-2xl" amount={metric.current} />
|
||||
<CardAction>
|
||||
<div className={`flex items-center text-xs ${trendColor}`}>
|
||||
<TrendIcon size={16} />
|
||||
{getPercentChange(metric.current, metric.previous)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-2 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 text-xs">
|
||||
mês anterior
|
||||
</div>
|
||||
<div className="text-foreground">
|
||||
<MoneyValues amount={metric.previous} />
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/features/dashboard/components/dashboard-welcome.tsx
Normal file
18
src/features/dashboard/components/dashboard-welcome.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { formatCurrentDate, getGreeting } from "./welcome-widget";
|
||||
|
||||
export function DashboardWelcome({ name }: { name?: string | null }) {
|
||||
const displayName = name && name.trim().length > 0 ? name : "Administrador";
|
||||
const formattedDate = formatCurrentDate();
|
||||
const greeting = getGreeting();
|
||||
|
||||
return (
|
||||
<section className="p-2">
|
||||
<div className="tracking-tight">
|
||||
<h1 className="text-xl">
|
||||
{greeting}, {displayName}
|
||||
</h1>
|
||||
<p className="text-sm mt-1">{formattedDate}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import type { ExpensesByCategoryData } from "@/features/dashboard/categories/expenses-by-category-queries";
|
||||
import { CategoryBreakdownWidgetView } from "./category-breakdown/category-breakdown-widget-view";
|
||||
|
||||
type ExpensesByCategoryWidgetWithChartProps = {
|
||||
data: ExpensesByCategoryData;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export function ExpensesByCategoryWidgetWithChart({
|
||||
data,
|
||||
period,
|
||||
}: ExpensesByCategoryWidgetWithChartProps) {
|
||||
return (
|
||||
<CategoryBreakdownWidgetView
|
||||
data={data}
|
||||
period={period}
|
||||
variant="expense"
|
||||
/>
|
||||
);
|
||||
}
|
||||
32
src/features/dashboard/components/goals-progress-widget.tsx
Normal file
32
src/features/dashboard/components/goals-progress-widget.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import type { GoalsProgressData } from "@/features/dashboard/goals-progress-queries";
|
||||
import { useGoalsProgressWidgetController } from "@/features/dashboard/use-goals-progress-widget-controller";
|
||||
import { GoalsProgressWidgetView } from "./goals-progress/goals-progress-widget-view";
|
||||
|
||||
type GoalsProgressWidgetProps = {
|
||||
data: GoalsProgressData;
|
||||
};
|
||||
|
||||
export function GoalsProgressWidget({ data }: GoalsProgressWidgetProps) {
|
||||
const {
|
||||
selectedBudget,
|
||||
editOpen,
|
||||
categories,
|
||||
defaultPeriod,
|
||||
handleEdit,
|
||||
handleEditOpenChange,
|
||||
} = useGoalsProgressWidgetController(data);
|
||||
|
||||
return (
|
||||
<GoalsProgressWidgetView
|
||||
data={data}
|
||||
selectedBudget={selectedBudget}
|
||||
editOpen={editOpen}
|
||||
categories={categories}
|
||||
defaultPeriod={defaultPeriod}
|
||||
onEdit={handleEdit}
|
||||
onEditOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { RiPencilLine } from "@remixicon/react";
|
||||
import { CategoryIconBadge } from "@/features/categories/components/category-icon-badge";
|
||||
import {
|
||||
clampGoalProgress,
|
||||
formatGoalProgressPercentage,
|
||||
getGoalProgressStatusColorClass,
|
||||
} from "@/features/dashboard/goals-progress-helpers";
|
||||
import type { GoalProgressItem as GoalProgressItemData } from "@/features/dashboard/goals-progress-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
|
||||
type GoalProgressItemProps = {
|
||||
item: GoalProgressItemData;
|
||||
index: number;
|
||||
onEdit: (item: GoalProgressItemData) => void;
|
||||
};
|
||||
|
||||
export function GoalProgressItem({
|
||||
item,
|
||||
index,
|
||||
onEdit,
|
||||
}: GoalProgressItemProps) {
|
||||
const statusColor = getGoalProgressStatusColorClass(item.status);
|
||||
const progressValue = clampGoalProgress(item.usedPercentage, 0, 100);
|
||||
const percentageDelta = item.usedPercentage - 100;
|
||||
|
||||
return (
|
||||
<li className="border-b border-dashed py-2 last:border-b-0 last:pb-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
<CategoryIconBadge
|
||||
icon={item.categoryIcon}
|
||||
name={item.categoryName}
|
||||
colorIndex={index}
|
||||
size="md"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{item.categoryName}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<MoneyValues amount={item.spentAmount} /> de{" "}
|
||||
<MoneyValues amount={item.budgetAmount} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className={`text-xs font-medium ${statusColor}`}>
|
||||
{formatGoalProgressPercentage(percentageDelta, true)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onEdit(item)}
|
||||
aria-label={`Editar orçamento de ${item.categoryName}`}
|
||||
>
|
||||
<RiPencilLine className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-11 mt-1.5">
|
||||
<Progress value={progressValue} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { RiFundsLine } from "@remixicon/react";
|
||||
import type { GoalProgressItem } from "@/features/dashboard/goals-progress-queries";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { GoalProgressItem as GoalProgressListItem } from "./goal-progress-item";
|
||||
|
||||
type GoalsProgressListProps = {
|
||||
items: GoalProgressItem[];
|
||||
onEdit: (item: GoalProgressItem) => void;
|
||||
};
|
||||
|
||||
export function GoalsProgressList({ items, onEdit }: GoalsProgressListProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiFundsLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum orçamento para o período"
|
||||
description="Cadastre orçamentos para acompanhar o progresso das metas."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col">
|
||||
{items.map((item, index) => (
|
||||
<GoalProgressListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BudgetDialog } from "@/features/budgets/components/budget-dialog";
|
||||
import type {
|
||||
Budget,
|
||||
BudgetCategory,
|
||||
} from "@/features/budgets/components/types";
|
||||
|
||||
type GoalsProgressWidgetDialogsProps = {
|
||||
selectedBudget: Budget | null;
|
||||
editOpen: boolean;
|
||||
categories: BudgetCategory[];
|
||||
defaultPeriod: string;
|
||||
onEditOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function GoalsProgressWidgetDialogs({
|
||||
selectedBudget,
|
||||
editOpen,
|
||||
categories,
|
||||
defaultPeriod,
|
||||
onEditOpenChange,
|
||||
}: GoalsProgressWidgetDialogsProps) {
|
||||
return (
|
||||
<BudgetDialog
|
||||
mode="update"
|
||||
budget={selectedBudget ?? undefined}
|
||||
categories={categories}
|
||||
defaultPeriod={defaultPeriod}
|
||||
open={editOpen && !!selectedBudget}
|
||||
onOpenChange={onEditOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
Budget,
|
||||
BudgetCategory,
|
||||
} from "@/features/budgets/components/types";
|
||||
import type {
|
||||
GoalProgressItem,
|
||||
GoalsProgressData,
|
||||
} from "@/features/dashboard/goals-progress-queries";
|
||||
import { GoalsProgressList } from "./goals-progress-list";
|
||||
import { GoalsProgressWidgetDialogs } from "./goals-progress-widget-dialogs";
|
||||
|
||||
type GoalsProgressWidgetViewProps = {
|
||||
data: GoalsProgressData;
|
||||
selectedBudget: Budget | null;
|
||||
editOpen: boolean;
|
||||
categories: BudgetCategory[];
|
||||
defaultPeriod: string;
|
||||
onEdit: (item: GoalProgressItem) => void;
|
||||
onEditOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function GoalsProgressWidgetView({
|
||||
data,
|
||||
selectedBudget,
|
||||
editOpen,
|
||||
categories,
|
||||
defaultPeriod,
|
||||
onEdit,
|
||||
onEditOpenChange,
|
||||
}: GoalsProgressWidgetViewProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<GoalsProgressList items={data.items} onEdit={onEdit} />
|
||||
|
||||
<GoalsProgressWidgetDialogs
|
||||
selectedBudget={selectedBudget}
|
||||
editOpen={editOpen}
|
||||
categories={categories}
|
||||
defaultPeriod={defaultPeriod}
|
||||
onEditOpenChange={onEditOpenChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import type { IncomeByCategoryData } from "@/features/dashboard/categories/income-by-category-queries";
|
||||
import { CategoryBreakdownWidgetView } from "./category-breakdown/category-breakdown-widget-view";
|
||||
|
||||
type IncomeByCategoryWidgetWithChartProps = {
|
||||
data: IncomeByCategoryData;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export function IncomeByCategoryWidgetWithChart({
|
||||
data,
|
||||
period,
|
||||
}: IncomeByCategoryWidgetWithChartProps) {
|
||||
return (
|
||||
<CategoryBreakdownWidgetView data={data} period={period} variant="income" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { RiLineChartLine } from "@remixicon/react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
||||
import type { IncomeExpenseBalanceData } from "@/features/dashboard/income-expense-balance-queries";
|
||||
import { CardContent } from "@/shared/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
} from "@/shared/components/ui/chart";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
|
||||
type IncomeExpenseBalanceWidgetProps = {
|
||||
data: IncomeExpenseBalanceData;
|
||||
};
|
||||
|
||||
const chartConfig = {
|
||||
receita: {
|
||||
label: "Receita",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
despesa: {
|
||||
label: "Despesa",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
balanco: {
|
||||
label: "Balanço",
|
||||
color: "var(--chart-3)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function IncomeExpenseBalanceWidget({
|
||||
data,
|
||||
}: IncomeExpenseBalanceWidgetProps) {
|
||||
const chartData = data.months.map((month) => ({
|
||||
month: month.monthLabel,
|
||||
receita: month.income,
|
||||
despesa: month.expense,
|
||||
balanco: month.balance,
|
||||
}));
|
||||
|
||||
// Verifica se todos os valores são zero
|
||||
const isEmpty = chartData.every(
|
||||
(item) => item.receita === 0 && item.despesa === 0 && item.balanco === 0,
|
||||
);
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<CardContent className="px-0">
|
||||
<WidgetEmptyState
|
||||
icon={<RiLineChartLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma movimentação financeira no período"
|
||||
description="Registre receitas e despesas para visualizar o balanço mensal."
|
||||
/>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardContent className="space-y-4 px-0">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[270px] w-full aspect-auto"
|
||||
>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 10, left: 10, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload || payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid gap-2">
|
||||
{payload.map((entry) => {
|
||||
const config =
|
||||
chartConfig[entry.dataKey as keyof typeof chartConfig];
|
||||
const value = entry.value as number;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.dataKey}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: config?.color }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{config?.label}:
|
||||
</span>
|
||||
<span className="text-xs font-medium">
|
||||
{formatCurrency(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
cursor={{ fill: "hsl(var(--muted))", opacity: 0.3 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="receita"
|
||||
fill={chartConfig.receita.color}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="despesa"
|
||||
fill={chartConfig.despesa.color}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="balanco"
|
||||
fill={chartConfig.balanco.color}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: chartConfig.receita.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{chartConfig.receita.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: chartConfig.despesa.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{chartConfig.despesa.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: chartConfig.balanco.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{chartConfig.balanco.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiCalculatorLine,
|
||||
RiCheckboxBlankLine,
|
||||
RiCheckboxLine,
|
||||
} from "@remixicon/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { InstallmentGroupCard } from "./installment-group-card";
|
||||
import type { InstallmentAnalysisData } from "./types";
|
||||
|
||||
type InstallmentAnalysisPageProps = {
|
||||
data: InstallmentAnalysisData;
|
||||
};
|
||||
|
||||
export function InstallmentAnalysisPage({
|
||||
data,
|
||||
}: InstallmentAnalysisPageProps) {
|
||||
// Estado para parcelas selecionadas: Map<seriesId, Set<installmentId>>
|
||||
const [selectedInstallments, setSelectedInstallments] = useState<
|
||||
Map<string, Set<string>>
|
||||
>(new Map());
|
||||
|
||||
// Calcular se está tudo selecionado (apenas parcelas não pagas)
|
||||
const isAllSelected = useMemo(() => {
|
||||
const allInstallmentsSelected = data.installmentGroups.every((group) => {
|
||||
const groupSelection = selectedInstallments.get(group.seriesId);
|
||||
const unpaidInstallments = group.pendingInstallments.filter(
|
||||
(i) => !i.isSettled,
|
||||
);
|
||||
if (!groupSelection || unpaidInstallments.length === 0) return false;
|
||||
return groupSelection.size === unpaidInstallments.length;
|
||||
});
|
||||
|
||||
return allInstallmentsSelected && data.installmentGroups.length > 0;
|
||||
}, [selectedInstallments, data]);
|
||||
|
||||
// Função para selecionar/desselecionar tudo
|
||||
const toggleSelectAll = () => {
|
||||
if (isAllSelected) {
|
||||
// Desmarcar tudo
|
||||
setSelectedInstallments(new Map());
|
||||
} else {
|
||||
// Marcar tudo (exceto parcelas já pagas)
|
||||
const newInstallments = new Map<string, Set<string>>();
|
||||
data.installmentGroups.forEach((group) => {
|
||||
const unpaidIds = group.pendingInstallments
|
||||
.filter((i) => !i.isSettled)
|
||||
.map((i) => i.id);
|
||||
if (unpaidIds.length > 0) {
|
||||
newInstallments.set(group.seriesId, new Set(unpaidIds));
|
||||
}
|
||||
});
|
||||
|
||||
setSelectedInstallments(newInstallments);
|
||||
}
|
||||
};
|
||||
|
||||
// Função para selecionar/desselecionar um grupo de parcelas
|
||||
const toggleGroupSelection = (seriesId: string, installmentIds: string[]) => {
|
||||
const newMap = new Map(selectedInstallments);
|
||||
const current = newMap.get(seriesId) || new Set<string>();
|
||||
|
||||
if (current.size === installmentIds.length) {
|
||||
// Já está tudo selecionado, desmarcar
|
||||
newMap.delete(seriesId);
|
||||
} else {
|
||||
// Marcar tudo
|
||||
newMap.set(seriesId, new Set(installmentIds));
|
||||
}
|
||||
|
||||
setSelectedInstallments(newMap);
|
||||
};
|
||||
|
||||
// Função para selecionar/desselecionar parcela individual
|
||||
const toggleInstallmentSelection = (
|
||||
seriesId: string,
|
||||
installmentId: string,
|
||||
) => {
|
||||
const newMap = new Map(selectedInstallments);
|
||||
// Criar uma NOVA instância do Set para React detectar a mudança
|
||||
const current = new Set(newMap.get(seriesId) || []);
|
||||
|
||||
if (current.has(installmentId)) {
|
||||
current.delete(installmentId);
|
||||
if (current.size === 0) {
|
||||
newMap.delete(seriesId);
|
||||
} else {
|
||||
newMap.set(seriesId, current);
|
||||
}
|
||||
} else {
|
||||
current.add(installmentId);
|
||||
newMap.set(seriesId, current);
|
||||
}
|
||||
|
||||
setSelectedInstallments(newMap);
|
||||
};
|
||||
|
||||
// Calcular totais
|
||||
const { grandTotal, selectedCount } = useMemo(() => {
|
||||
let installmentsSum = 0;
|
||||
let installmentsCount = 0;
|
||||
|
||||
selectedInstallments.forEach((installmentIds, seriesId) => {
|
||||
const group = data.installmentGroups.find((g) => g.seriesId === seriesId);
|
||||
if (group) {
|
||||
installmentIds.forEach((id) => {
|
||||
const installment = group.pendingInstallments.find(
|
||||
(i) => i.id === id,
|
||||
);
|
||||
if (installment && !installment.isSettled) {
|
||||
installmentsSum += installment.amount;
|
||||
installmentsCount++;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
grandTotal: installmentsSum,
|
||||
selectedCount: installmentsCount,
|
||||
};
|
||||
}, [selectedInstallments, data]);
|
||||
|
||||
const hasNoData = data.installmentGroups.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Card de resumo principal */}
|
||||
<Card className="border-none bg-primary/15">
|
||||
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Se você pagar tudo que está selecionado:
|
||||
</p>
|
||||
<MoneyValues
|
||||
amount={grandTotal}
|
||||
className="text-3xl font-bold text-primary"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}
|
||||
selecionadas
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Botões de ação */}
|
||||
{!hasNoData && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleSelectAll}
|
||||
className="gap-2"
|
||||
>
|
||||
{isAllSelected ? (
|
||||
<RiCheckboxLine className="size-4" />
|
||||
) : (
|
||||
<RiCheckboxBlankLine className="size-4" />
|
||||
)}
|
||||
{isAllSelected ? "Desmarcar Tudo" : "Selecionar Tudo"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Seção de Lançamentos Parcelados */}
|
||||
{data.installmentGroups.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{data.installmentGroups.map((group) => (
|
||||
<InstallmentGroupCard
|
||||
key={group.seriesId}
|
||||
group={group}
|
||||
selectedInstallments={
|
||||
selectedInstallments.get(group.seriesId) || new Set()
|
||||
}
|
||||
onToggleGroup={() =>
|
||||
toggleGroupSelection(
|
||||
group.seriesId,
|
||||
group.pendingInstallments
|
||||
.filter((i) => !i.isSettled)
|
||||
.map((i) => i.id),
|
||||
)
|
||||
}
|
||||
onToggleInstallment={(installmentId) =>
|
||||
toggleInstallmentSelection(group.seriesId, installmentId)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Estado vazio */}
|
||||
{hasNoData && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center gap-3 py-12">
|
||||
<RiCalculatorLine className="size-12 text-muted-foreground/50" />
|
||||
<div className="text-center">
|
||||
<p className="font-medium">Nenhuma parcela pendente</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Você está em dia com seus pagamentos!
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
RiCheckboxCircleFill,
|
||||
} from "@remixicon/react";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { useState } from "react";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import type { InstallmentGroup } from "./types";
|
||||
|
||||
type InstallmentGroupCardProps = {
|
||||
group: InstallmentGroup;
|
||||
selectedInstallments: Set<string>;
|
||||
onToggleGroup: () => void;
|
||||
onToggleInstallment: (installmentId: string) => void;
|
||||
};
|
||||
|
||||
export function InstallmentGroupCard({
|
||||
group,
|
||||
selectedInstallments,
|
||||
onToggleGroup,
|
||||
onToggleInstallment,
|
||||
}: InstallmentGroupCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const unpaidInstallments = group.pendingInstallments.filter(
|
||||
(i) => !i.isSettled,
|
||||
);
|
||||
|
||||
const unpaidCount = unpaidInstallments.length;
|
||||
|
||||
const isFullySelected =
|
||||
selectedInstallments.size === unpaidInstallments.length &&
|
||||
unpaidInstallments.length > 0;
|
||||
|
||||
const progress =
|
||||
group.totalInstallments > 0
|
||||
? (group.paidInstallments / group.totalInstallments) * 100
|
||||
: 0;
|
||||
|
||||
const selectedAmount = group.pendingInstallments
|
||||
.filter((i) => selectedInstallments.has(i.id) && !i.isSettled)
|
||||
.reduce((sum, i) => sum + Number(i.amount), 0);
|
||||
|
||||
// Calcular valor total de todas as parcelas (pagas + pendentes)
|
||||
const totalAmount = group.pendingInstallments.reduce(
|
||||
(sum, i) => sum + i.amount,
|
||||
0,
|
||||
);
|
||||
|
||||
// Calcular valor pendente (apenas não pagas)
|
||||
const pendingAmount = unpaidInstallments.reduce(
|
||||
(sum, i) => sum + i.amount,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className={cn(isFullySelected && "border-primary/50")}>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{/* Header do card */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isFullySelected}
|
||||
onCheckedChange={onToggleGroup}
|
||||
className="mt-1"
|
||||
aria-label={`Selecionar todas as parcelas de ${group.name}`}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{group.cartaoLogo && (
|
||||
<img
|
||||
src={`/logos/${group.cartaoLogo}`}
|
||||
alt={group.cartaoName ?? "Cartão"}
|
||||
className="h-6 w-auto object-contain rounded"
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium truncate">{group.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
| {group.cartaoName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">Total:</span>
|
||||
<MoneyValues
|
||||
amount={totalAmount}
|
||||
className="text-base font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Pendente:
|
||||
</span>
|
||||
<MoneyValues
|
||||
amount={pendingAmount}
|
||||
className="text-sm font-medium text-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 flex flex-wrap items-center px-1 justify-between gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{group.paidInstallments} de {group.totalInstallments} pagas
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span>
|
||||
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
|
||||
</span>
|
||||
{selectedInstallments.size > 0 && (
|
||||
<span className="text-primary font-medium">
|
||||
• Selecionado:{" "}
|
||||
<MoneyValues
|
||||
amount={selectedAmount}
|
||||
className="text-xs font-medium text-primary inline"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* Botão de expandir */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="mt-2 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<RiArrowDownSLine className="size-4" />
|
||||
Ocultar parcelas ({group.pendingInstallments.length})
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiArrowRightSLine className="size-4" />
|
||||
Ver parcelas ({group.pendingInstallments.length})
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de parcelas expandida */}
|
||||
{isExpanded && (
|
||||
<div className="px-2 sm:px-8 mt-2 flex flex-col gap-2">
|
||||
{group.pendingInstallments.map((installment) => {
|
||||
const isSelected = selectedInstallments.has(installment.id);
|
||||
const isPaid = installment.isSettled;
|
||||
const dueDate = installment.dueDate
|
||||
? format(installment.dueDate, "dd/MM/yyyy", { locale: ptBR })
|
||||
: format(installment.purchaseDate, "dd/MM/yyyy", {
|
||||
locale: ptBR,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={installment.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md border p-2 transition-colors",
|
||||
isSelected && !isPaid && "border-primary/50 bg-primary/5",
|
||||
isPaid &&
|
||||
"border-success/40 bg-success/5 dark:border-success/20 dark:bg-success/5",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isPaid ? false : isSelected}
|
||||
disabled={isPaid}
|
||||
onCheckedChange={() =>
|
||||
!isPaid && onToggleInstallment(installment.id)
|
||||
}
|
||||
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
isPaid &&
|
||||
"text-success line-through decoration-success/50",
|
||||
)}
|
||||
>
|
||||
Parcela {installment.currentInstallment}/
|
||||
{group.totalInstallments}
|
||||
{isPaid && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 text-xs border-none text-success"
|
||||
>
|
||||
<RiCheckboxCircleFill /> Pago
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs mt-1",
|
||||
isPaid ? "text-success" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
Vencimento: {dueDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MoneyValues
|
||||
amount={installment.amount}
|
||||
className={cn(
|
||||
"shrink-0 text-sm",
|
||||
isPaid && "text-success",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type {
|
||||
InstallmentAnalysisData,
|
||||
InstallmentGroup,
|
||||
} from "@/features/dashboard/expenses/installment-analysis-queries";
|
||||
|
||||
export type { InstallmentAnalysisData, InstallmentGroup };
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import { InstallmentExpensesWidgetView } from "./installment-expenses/installment-expenses-widget-view";
|
||||
|
||||
type InstallmentExpensesWidgetProps = {
|
||||
data: InstallmentExpensesData;
|
||||
};
|
||||
|
||||
export function InstallmentExpensesWidget({
|
||||
data,
|
||||
}: InstallmentExpensesWidgetProps) {
|
||||
return <InstallmentExpensesWidgetView data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Image from "next/image";
|
||||
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import { buildInstallmentExpenseDisplay } from "@/features/dashboard/installment-expenses-helpers";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
|
||||
type InstallmentExpenseListItemProps = {
|
||||
expense: InstallmentExpense;
|
||||
};
|
||||
|
||||
export function InstallmentExpenseListItem({
|
||||
expense,
|
||||
}: InstallmentExpenseListItemProps) {
|
||||
const {
|
||||
compactLabel,
|
||||
isLast,
|
||||
remainingInstallments,
|
||||
remainingAmount,
|
||||
endDate,
|
||||
progress,
|
||||
} = buildInstallmentExpenseDisplay(expense);
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{expense.name}
|
||||
</p>
|
||||
{compactLabel ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
{compactLabel}
|
||||
{isLast ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Image
|
||||
src="/icons/party.svg"
|
||||
alt="Última parcela"
|
||||
width={14}
|
||||
height={14}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="sr-only">Última parcela</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Última parcela!</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<MoneyValues amount={expense.amount} className="shrink-0" />
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{endDate ? `Termina em ${endDate}` : null}
|
||||
{" | Restante "}
|
||||
<MoneyValues
|
||||
amount={remainingAmount}
|
||||
className="inline-block font-medium"
|
||||
/>{" "}
|
||||
({remainingInstallments})
|
||||
</p>
|
||||
|
||||
<Progress value={progress} className="mt-1 h-2" />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { RiNumbersLine } from "@remixicon/react";
|
||||
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { InstallmentExpenseListItem } from "./installment-expense-list-item";
|
||||
|
||||
type InstallmentExpensesListProps = {
|
||||
expenses: InstallmentExpense[];
|
||||
};
|
||||
|
||||
export function InstallmentExpensesList({
|
||||
expenses,
|
||||
}: InstallmentExpensesListProps) {
|
||||
if (expenses.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiNumbersLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma despesa parcelada"
|
||||
description="Lançamentos parcelados aparecerão aqui conforme forem registrados."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{expenses.map((expense) => (
|
||||
<InstallmentExpenseListItem key={expense.id} expense={expense} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import { InstallmentExpensesList } from "./installment-expenses-list";
|
||||
|
||||
type InstallmentExpensesWidgetViewProps = {
|
||||
data: InstallmentExpensesData;
|
||||
};
|
||||
|
||||
export function InstallmentExpensesWidgetView({
|
||||
data,
|
||||
}: InstallmentExpensesWidgetViewProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<InstallmentExpensesList expenses={data.expenses} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/features/dashboard/components/invoices-widget.tsx
Normal file
35
src/features/dashboard/components/invoices-widget.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import { useInvoicesWidgetController } from "@/features/dashboard/use-invoices-widget-controller";
|
||||
import { InvoicesWidgetView } from "./invoices/invoices-widget-view";
|
||||
|
||||
type InvoicesWidgetProps = {
|
||||
invoices: DashboardInvoice[];
|
||||
};
|
||||
|
||||
export function InvoicesWidget({ invoices }: InvoicesWidgetProps) {
|
||||
const {
|
||||
items,
|
||||
selectedInvoice,
|
||||
isModalOpen,
|
||||
modalState,
|
||||
isPending,
|
||||
openPaymentDialog,
|
||||
closePaymentDialog,
|
||||
confirmPayment,
|
||||
} = useInvoicesWidgetController(invoices);
|
||||
|
||||
return (
|
||||
<InvoicesWidgetView
|
||||
invoices={items}
|
||||
selectedInvoice={selectedInvoice}
|
||||
isModalOpen={isModalOpen}
|
||||
modalState={modalState}
|
||||
isPending={isPending}
|
||||
onOpenPaymentDialog={openPaymentDialog}
|
||||
onClosePaymentDialog={closePaymentDialog}
|
||||
onConfirmPayment={confirmPayment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
152
src/features/dashboard/components/invoices/invoice-list-item.tsx
Normal file
152
src/features/dashboard/components/invoices/invoice-list-item.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
buildInvoiceDetailsHref,
|
||||
buildInvoiceInitials,
|
||||
formatInvoicePaymentDate,
|
||||
getInvoiceShareLabel,
|
||||
parseInvoiceDueDate,
|
||||
} from "@/features/dashboard/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/shared/components/ui/avatar";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/shared/components/ui/hover-card";
|
||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||
import { isDateOnlyPast } from "@/shared/utils/date";
|
||||
import { InvoiceLogo } from "./invoice-logo";
|
||||
|
||||
type InvoiceListItemProps = {
|
||||
invoice: DashboardInvoice;
|
||||
onPay: (invoiceId: string) => void;
|
||||
};
|
||||
|
||||
export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
||||
const dueInfo = parseInvoiceDueDate(invoice.period, invoice.dueDay);
|
||||
const isPaid = invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
|
||||
const isOverdue =
|
||||
!isPaid && dueInfo.date !== null && isDateOnlyPast(dueInfo.date);
|
||||
const paymentInfo = formatInvoicePaymentDate(invoice.paidAt);
|
||||
const breakdown = invoice.pagadorBreakdown ?? [];
|
||||
const hasBreakdown = breakdown.length > 0;
|
||||
const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period);
|
||||
|
||||
const linkNode = (
|
||||
<Link
|
||||
prefetch
|
||||
href={detailHref}
|
||||
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
|
||||
>
|
||||
<span className="truncate">{invoice.cardName}</span>
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
|
||||
<InvoiceLogo
|
||||
cardName={invoice.cardName}
|
||||
logo={invoice.logo}
|
||||
size={36}
|
||||
containerClassName="size-9.5"
|
||||
/>
|
||||
|
||||
<div className="min-w-0">
|
||||
{hasBreakdown ? (
|
||||
<HoverCard openDelay={150}>
|
||||
<HoverCardTrigger asChild>{linkNode}</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-72 space-y-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Distribuição por pagador
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{breakdown.map((share, index) => (
|
||||
<li
|
||||
key={`${invoice.id}-${
|
||||
share.pagadorId ?? share.pagadorName ?? index
|
||||
}`}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<Avatar className="size-9">
|
||||
<AvatarImage
|
||||
src={getAvatarSrc(share.pagadorAvatar)}
|
||||
alt={`Avatar de ${share.pagadorName}`}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{buildInvoiceInitials(share.pagadorName)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{share.pagadorName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getInvoiceShareLabel(
|
||||
share.amount,
|
||||
Math.abs(invoice.totalAmount),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
<MoneyValues amount={share.amount} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
linkNode
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{!isPaid ? <span>{dueInfo.label}</span> : null}
|
||||
{isPaid && paymentInfo ? (
|
||||
<span className="text-success">{paymentInfo.label}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={Math.abs(invoice.totalAmount)} />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="h-auto p-0 disabled:opacity-100"
|
||||
disabled={isPaid}
|
||||
onClick={() => onPay(invoice.id)}
|
||||
>
|
||||
{isPaid ? (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<RiCheckboxCircleFill className="size-3" /> Pago
|
||||
</span>
|
||||
) : isOverdue ? (
|
||||
<span className="overdue-blink">
|
||||
<span className="overdue-blink-primary text-destructive">
|
||||
Atrasado
|
||||
</span>
|
||||
<span className="overdue-blink-secondary">Pagar</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>Pagar</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
59
src/features/dashboard/components/invoices/invoice-logo.tsx
Normal file
59
src/features/dashboard/components/invoices/invoice-logo.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import Image from "next/image";
|
||||
import {
|
||||
buildInvoiceInitials,
|
||||
type InvoiceLogoTone,
|
||||
} from "@/features/dashboard/invoices-helpers";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
type InvoiceLogoProps = {
|
||||
cardName: string;
|
||||
logo: string | null;
|
||||
size: number;
|
||||
containerClassName?: string;
|
||||
imageClassName?: string;
|
||||
fallbackClassName?: string;
|
||||
tone?: InvoiceLogoTone;
|
||||
};
|
||||
|
||||
export function InvoiceLogo({
|
||||
cardName,
|
||||
logo,
|
||||
size,
|
||||
containerClassName,
|
||||
imageClassName,
|
||||
fallbackClassName,
|
||||
tone = "muted",
|
||||
}: InvoiceLogoProps) {
|
||||
const resolvedLogo = resolveLogoSrc(logo);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center overflow-hidden rounded-full",
|
||||
tone === "accent" && "bg-primary/10",
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
{resolvedLogo ? (
|
||||
<Image
|
||||
src={resolvedLogo}
|
||||
alt={`Logo do cartão ${cardName}`}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn("h-full w-full object-contain", imageClassName)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold uppercase text-muted-foreground",
|
||||
tone === "accent" && "text-primary",
|
||||
fallbackClassName,
|
||||
)}
|
||||
>
|
||||
{buildInvoiceInitials(cardName)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
RiCheckboxCircleLine,
|
||||
RiLoader4Line,
|
||||
RiMoneyDollarCircleLine,
|
||||
} from "@remixicon/react";
|
||||
import {
|
||||
formatInvoicePaymentDate,
|
||||
getInvoiceStatusBadgeVariant,
|
||||
type InvoiceDialogState,
|
||||
parseInvoiceDueDate,
|
||||
} from "@/features/dashboard/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import {
|
||||
INVOICE_PAYMENT_STATUS,
|
||||
INVOICE_STATUS_LABEL,
|
||||
} from "@/shared/lib/invoices";
|
||||
import { InvoiceLogo } from "./invoice-logo";
|
||||
|
||||
type InvoicePaymentDialogProps = {
|
||||
invoice: DashboardInvoice | null;
|
||||
open: boolean;
|
||||
modalState: InvoiceDialogState;
|
||||
isPending: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function InvoicePaymentDialog({
|
||||
invoice,
|
||||
open,
|
||||
modalState,
|
||||
isPending,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: InvoicePaymentDialogProps) {
|
||||
const isProcessing = modalState === "processing" || isPending;
|
||||
const paymentInfo = invoice ? formatInvoicePaymentDate(invoice.paidAt) : null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen || isProcessing) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[calc(100%-2rem)] sm:max-w-md"
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (isProcessing) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (isProcessing) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{modalState === "success" ? (
|
||||
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
|
||||
<RiCheckboxCircleLine className="size-8" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<DialogTitle className="text-base">
|
||||
Pagamento confirmado!
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
Atualizamos o status da fatura. O lançamento do pagamento
|
||||
aparecerá no extrato em instantes.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogFooter className="sm:justify-center">
|
||||
<Button type="button" onClick={onClose} className="sm:w-auto">
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar pagamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Revise os dados antes de confirmar. Vamos registrar a fatura
|
||||
como paga.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{invoice ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<InvoiceLogo
|
||||
cardName={invoice.cardName}
|
||||
logo={invoice.logo}
|
||||
size={40}
|
||||
tone="accent"
|
||||
containerClassName="size-10"
|
||||
fallbackClassName="text-xs"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Cartão
|
||||
</p>
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
{invoice.cardName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PAID ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{
|
||||
parseInvoiceDueDate(invoice.period, invoice.dueDay)
|
||||
.label
|
||||
}
|
||||
</p>
|
||||
) : null}
|
||||
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID &&
|
||||
paymentInfo ? (
|
||||
<p className="text-sm text-success">
|
||||
{paymentInfo.label}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RiMoneyDollarCircleLine className="size-4" />
|
||||
<span className="text-xs font-semibold uppercase">
|
||||
Valor da Fatura
|
||||
</span>
|
||||
</div>
|
||||
<MoneyValues
|
||||
amount={Math.abs(invoice.totalAmount)}
|
||||
className="text-lg font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RiCheckboxCircleLine className="size-4" />
|
||||
<span className="text-xs font-semibold uppercase">
|
||||
Status
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={getInvoiceStatusBadgeVariant(
|
||||
INVOICE_STATUS_LABEL[invoice.paymentStatus],
|
||||
)}
|
||||
>
|
||||
{INVOICE_STATUS_LABEL[invoice.paymentStatus]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isProcessing || !invoice}
|
||||
className="relative"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
|
||||
Processando...
|
||||
</>
|
||||
) : (
|
||||
"Confirmar pagamento"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
29
src/features/dashboard/components/invoices/invoices-list.tsx
Normal file
29
src/features/dashboard/components/invoices/invoices-list.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { RiBillLine } from "@remixicon/react";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { InvoiceListItem } from "./invoice-list-item";
|
||||
|
||||
type InvoicesListProps = {
|
||||
invoices: DashboardInvoice[];
|
||||
onPay: (invoiceId: string) => void;
|
||||
};
|
||||
|
||||
export function InvoicesList({ invoices, onPay }: InvoicesListProps) {
|
||||
if (invoices.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiBillLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma fatura para o período selecionado"
|
||||
description="Quando houver cartões com compras registradas, eles aparecerão aqui."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col">
|
||||
{invoices.map((invoice) => (
|
||||
<InvoiceListItem key={invoice.id} invoice={invoice} onPay={onPay} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { InvoiceDialogState } from "@/features/dashboard/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import { InvoicePaymentDialog } from "./invoice-payment-dialog";
|
||||
import { InvoicesList } from "./invoices-list";
|
||||
|
||||
type InvoicesWidgetViewProps = {
|
||||
invoices: DashboardInvoice[];
|
||||
selectedInvoice: DashboardInvoice | null;
|
||||
isModalOpen: boolean;
|
||||
modalState: InvoiceDialogState;
|
||||
isPending: boolean;
|
||||
onOpenPaymentDialog: (invoiceId: string) => void;
|
||||
onClosePaymentDialog: () => void;
|
||||
onConfirmPayment: () => void;
|
||||
};
|
||||
|
||||
export function InvoicesWidgetView({
|
||||
invoices,
|
||||
selectedInvoice,
|
||||
isModalOpen,
|
||||
modalState,
|
||||
isPending,
|
||||
onOpenPaymentDialog,
|
||||
onClosePaymentDialog,
|
||||
onConfirmPayment,
|
||||
}: InvoicesWidgetViewProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<InvoicesList invoices={invoices} onPay={onOpenPaymentDialog} />
|
||||
</div>
|
||||
|
||||
<InvoicePaymentDialog
|
||||
invoice={selectedInvoice}
|
||||
open={isModalOpen}
|
||||
modalState={modalState}
|
||||
isPending={isPending}
|
||||
onClose={onClosePaymentDialog}
|
||||
onConfirm={onConfirmPayment}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
src/features/dashboard/components/my-accounts-widget.tsx
Normal file
103
src/features/dashboard/components/my-accounts-widget.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { RiBarChartBoxLine, RiExternalLinkLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { DashboardAccount } from "@/features/dashboard/accounts-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { CardFooter } from "@/shared/components/ui/card";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { formatPeriodForUrl } from "@/shared/utils/period";
|
||||
|
||||
type MyAccountsWidgetProps = {
|
||||
accounts: DashboardAccount[];
|
||||
totalBalance: number;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export function MyAccountsWidget({
|
||||
accounts,
|
||||
totalBalance,
|
||||
period,
|
||||
}: MyAccountsWidgetProps) {
|
||||
const visibleAccounts = accounts.filter(
|
||||
(account) => !account.excludeFromBalance,
|
||||
);
|
||||
const displayedAccounts = visibleAccounts.slice(0, 5);
|
||||
const remainingCount = visibleAccounts.length - displayedAccounts.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between py-2">
|
||||
Saldo Total
|
||||
<MoneyValues className="text-2xl" amount={totalBalance} />
|
||||
</div>
|
||||
|
||||
<div className="py-2 px-0">
|
||||
{displayedAccounts.length === 0 ? (
|
||||
<div className="-mt-10">
|
||||
<WidgetEmptyState
|
||||
icon={
|
||||
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
|
||||
}
|
||||
title="Você ainda não adicionou nenhuma conta"
|
||||
description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{displayedAccounts.map((account) => {
|
||||
const logoSrc = resolveLogoSrc(account.logo);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={account.id}
|
||||
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="relative size-10 overflow-hidden">
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={`Logo da conta ${account.name}`}
|
||||
fill
|
||||
className="object-contain rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
prefetch
|
||||
href={`/accounts/${
|
||||
account.id
|
||||
}/statement?periodo=${formatPeriodForUrl(period)}`}
|
||||
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
|
||||
>
|
||||
<span className="truncate">{account.name}</span>
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="truncate">{account.accountType}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-0.5 text-right">
|
||||
<MoneyValues amount={account.balance} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visibleAccounts.length > displayedAccounts.length ? (
|
||||
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground">
|
||||
+{remainingCount} contas não exibidas
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
37
src/features/dashboard/components/notes-widget.tsx
Normal file
37
src/features/dashboard/components/notes-widget.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import type { DashboardNote } from "@/features/dashboard/notes-queries";
|
||||
import { useNotesWidgetController } from "@/features/dashboard/use-notes-widget-controller";
|
||||
import { NotesWidgetView } from "./notes/notes-widget-view";
|
||||
|
||||
type NotesWidgetProps = {
|
||||
notes: DashboardNote[];
|
||||
};
|
||||
|
||||
export function NotesWidget({ notes }: NotesWidgetProps) {
|
||||
const {
|
||||
mappedNotes,
|
||||
noteToEdit,
|
||||
isEditOpen,
|
||||
noteDetails,
|
||||
isDetailsOpen,
|
||||
openEdit,
|
||||
openDetails,
|
||||
handleEditOpenChange,
|
||||
handleDetailsOpenChange,
|
||||
} = useNotesWidgetController(notes);
|
||||
|
||||
return (
|
||||
<NotesWidgetView
|
||||
notes={mappedNotes}
|
||||
noteToEdit={noteToEdit}
|
||||
isEditOpen={isEditOpen}
|
||||
noteDetails={noteDetails}
|
||||
isDetailsOpen={isDetailsOpen}
|
||||
onOpenEdit={openEdit}
|
||||
onOpenDetails={openDetails}
|
||||
onEditOpenChange={handleEditOpenChange}
|
||||
onDetailsOpenChange={handleDetailsOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
65
src/features/dashboard/components/notes/note-list-item.tsx
Normal file
65
src/features/dashboard/components/notes/note-list-item.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { RiFileList2Line, RiPencilLine } from "@remixicon/react";
|
||||
import type { Note } from "@/features/notes/components/types";
|
||||
import {
|
||||
buildNoteDisplayTitle,
|
||||
formatNoteCreatedAt,
|
||||
getNoteTasksSummary,
|
||||
} from "@/features/notes/lib/formatters";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
type NoteListItemProps = {
|
||||
note: Note;
|
||||
onOpenEdit: (note: Note) => void;
|
||||
onOpenDetails: (note: Note) => void;
|
||||
};
|
||||
|
||||
export function NoteListItem({
|
||||
note,
|
||||
onOpenEdit,
|
||||
onOpenDetails,
|
||||
}: NoteListItemProps) {
|
||||
const displayTitle = buildNoteDisplayTitle(note.title);
|
||||
const createdAtLabel = formatNoteCreatedAt(note.createdAt);
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-b-0 last:pb-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{displayTitle}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||
{getNoteTasksSummary(note)}
|
||||
</Badge>
|
||||
{createdAtLabel ? (
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{createdAtLabel}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onOpenEdit(note)}
|
||||
aria-label={`Editar anotação ${displayTitle}`}
|
||||
>
|
||||
<RiPencilLine className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onOpenDetails(note)}
|
||||
aria-label={`Ver detalhes da anotação ${displayTitle}`}
|
||||
>
|
||||
<RiFileList2Line className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
39
src/features/dashboard/components/notes/notes-list.tsx
Normal file
39
src/features/dashboard/components/notes/notes-list.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { RiTodoLine } from "@remixicon/react";
|
||||
import type { Note } from "@/features/notes/components/types";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { NoteListItem } from "./note-list-item";
|
||||
|
||||
type NotesListProps = {
|
||||
notes: Note[];
|
||||
onOpenEdit: (note: Note) => void;
|
||||
onOpenDetails: (note: Note) => void;
|
||||
};
|
||||
|
||||
export function NotesList({
|
||||
notes,
|
||||
onOpenEdit,
|
||||
onOpenDetails,
|
||||
}: NotesListProps) {
|
||||
if (notes.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiTodoLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma anotação ativa"
|
||||
description="Crie anotações para acompanhar lembretes e tarefas financeiras."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col">
|
||||
{notes.map((note) => (
|
||||
<NoteListItem
|
||||
key={note.id}
|
||||
note={note}
|
||||
onOpenEdit={onOpenEdit}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NoteDetailsDialog } from "@/features/notes/components/note-details-dialog";
|
||||
import { NoteDialog } from "@/features/notes/components/note-dialog";
|
||||
import type { Note } from "@/features/notes/components/types";
|
||||
|
||||
type NotesWidgetDialogsProps = {
|
||||
noteToEdit: Note | null;
|
||||
isEditOpen: boolean;
|
||||
noteDetails: Note | null;
|
||||
isDetailsOpen: boolean;
|
||||
onEditOpenChange: (open: boolean) => void;
|
||||
onDetailsOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function NotesWidgetDialogs({
|
||||
noteToEdit,
|
||||
isEditOpen,
|
||||
noteDetails,
|
||||
isDetailsOpen,
|
||||
onEditOpenChange,
|
||||
onDetailsOpenChange,
|
||||
}: NotesWidgetDialogsProps) {
|
||||
return (
|
||||
<>
|
||||
<NoteDialog
|
||||
mode="update"
|
||||
note={noteToEdit ?? undefined}
|
||||
open={isEditOpen}
|
||||
onOpenChange={onEditOpenChange}
|
||||
/>
|
||||
|
||||
<NoteDetailsDialog
|
||||
note={noteDetails}
|
||||
open={isDetailsOpen}
|
||||
onOpenChange={onDetailsOpenChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Note } from "@/features/notes/components/types";
|
||||
import { NotesList } from "./notes-list";
|
||||
import { NotesWidgetDialogs } from "./notes-widget-dialogs";
|
||||
|
||||
type NotesWidgetViewProps = {
|
||||
notes: Note[];
|
||||
noteToEdit: Note | null;
|
||||
isEditOpen: boolean;
|
||||
noteDetails: Note | null;
|
||||
isDetailsOpen: boolean;
|
||||
onOpenEdit: (note: Note) => void;
|
||||
onOpenDetails: (note: Note) => void;
|
||||
onEditOpenChange: (open: boolean) => void;
|
||||
onDetailsOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function NotesWidgetView({
|
||||
notes,
|
||||
noteToEdit,
|
||||
isEditOpen,
|
||||
noteDetails,
|
||||
isDetailsOpen,
|
||||
onOpenEdit,
|
||||
onOpenDetails,
|
||||
onEditOpenChange,
|
||||
onDetailsOpenChange,
|
||||
}: NotesWidgetViewProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<NotesList
|
||||
notes={notes}
|
||||
onOpenEdit={onOpenEdit}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NotesWidgetDialogs
|
||||
noteToEdit={noteToEdit}
|
||||
isEditOpen={isEditOpen}
|
||||
noteDetails={noteDetails}
|
||||
isDetailsOpen={isDetailsOpen}
|
||||
onEditOpenChange={onEditOpenChange}
|
||||
onDetailsOpenChange={onDetailsOpenChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
src/features/dashboard/components/payers-widget.tsx
Normal file
130
src/features/dashboard/components/payers-widget.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiArrowDownSFill,
|
||||
RiArrowUpSFill,
|
||||
RiExternalLinkLine,
|
||||
RiGroupLine,
|
||||
RiVerifiedBadgeFill,
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import type { DashboardPagador } from "@/features/dashboard/payers-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/shared/components/ui/avatar";
|
||||
import { CardContent } from "@/shared/components/ui/card";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
|
||||
type PayersWidgetProps = {
|
||||
pagadores: DashboardPagador[];
|
||||
};
|
||||
|
||||
const buildInitials = (value: string) => {
|
||||
const parts = value.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return "??";
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
const firstPart = parts[0];
|
||||
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "??";
|
||||
}
|
||||
const firstChar = parts[0]?.[0] ?? "";
|
||||
const secondChar = parts[1]?.[0] ?? "";
|
||||
return `${firstChar}${secondChar}`.toUpperCase() || "??";
|
||||
};
|
||||
|
||||
export function PayersWidget({ pagadores }: PayersWidgetProps) {
|
||||
return (
|
||||
<CardContent className="flex flex-col gap-4 px-0">
|
||||
{pagadores.length === 0 ? (
|
||||
<WidgetEmptyState
|
||||
icon={<RiGroupLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum pagador para o período"
|
||||
description="Quando houver despesas associadas a pagadores, eles aparecerão aqui."
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{pagadores.map((pagador) => {
|
||||
const initials = buildInitials(pagador.name);
|
||||
const hasValidPercentageChange =
|
||||
typeof pagador.percentageChange === "number" &&
|
||||
Number.isFinite(pagador.percentageChange);
|
||||
const percentageChange = hasValidPercentageChange
|
||||
? pagador.percentageChange
|
||||
: null;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={pagador.id}
|
||||
className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage
|
||||
src={getAvatarSrc(pagador.avatarUrl)}
|
||||
alt={`Avatar de ${pagador.name}`}
|
||||
/>
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
prefetch
|
||||
href={`/payers/${pagador.id}`}
|
||||
className="inline-flex max-w-full items-center gap-1 text-sm text-foreground underline-offset-2 hover:text-primary hover:underline"
|
||||
>
|
||||
<span className="truncate font-medium">
|
||||
{pagador.name}
|
||||
</span>
|
||||
{pagador.isAdmin && (
|
||||
<RiVerifiedBadgeFill
|
||||
className="size-4 shrink-0 text-blue-500"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{pagador.email ?? "Sem email cadastrado"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={pagador.totalExpenses} />
|
||||
{percentageChange !== null && (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs ${
|
||||
percentageChange > 0
|
||||
? "text-destructive"
|
||||
: percentageChange < 0
|
||||
? "text-success"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{percentageChange > 0 && (
|
||||
<RiArrowUpSFill className="size-3" />
|
||||
)}
|
||||
{percentageChange < 0 && (
|
||||
<RiArrowDownSFill className="size-3" />
|
||||
)}
|
||||
{formatPercentage(percentageChange)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||
import { usePaymentOverviewWidgetController } from "@/features/dashboard/use-payment-overview-widget-controller";
|
||||
import { PaymentOverviewWidgetView } from "./payment-overview/payment-overview-widget-view";
|
||||
|
||||
type PaymentOverviewWidgetProps = {
|
||||
paymentConditionsData: PaymentConditionsData;
|
||||
paymentMethodsData: PaymentMethodsData;
|
||||
};
|
||||
|
||||
export function PaymentOverviewWidget({
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
}: PaymentOverviewWidgetProps) {
|
||||
const { activeTab, handleTabChange } = usePaymentOverviewWidgetController();
|
||||
|
||||
return (
|
||||
<PaymentOverviewWidgetView
|
||||
activeTab={activeTab}
|
||||
paymentConditionsData={paymentConditionsData}
|
||||
paymentMethodsData={paymentMethodsData}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
formatPaymentBreakdownPercentage,
|
||||
formatPaymentBreakdownTransactionsLabel,
|
||||
} from "@/features/dashboard/payment-breakdown-formatters";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
|
||||
const ICON_WRAPPER_CLASS =
|
||||
"flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground";
|
||||
|
||||
export type PaymentBreakdownListItemData = {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
amount: number;
|
||||
transactions: number;
|
||||
percentage: number;
|
||||
};
|
||||
|
||||
type PaymentBreakdownListItemProps = {
|
||||
item: PaymentBreakdownListItemData;
|
||||
};
|
||||
|
||||
export function PaymentBreakdownListItem({
|
||||
item,
|
||||
}: PaymentBreakdownListItemProps) {
|
||||
return (
|
||||
<li className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0">
|
||||
<div className={ICON_WRAPPER_CLASS}>{item.icon}</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
||||
<MoneyValues amount={item.amount} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatPaymentBreakdownTransactionsLabel(item.transactions)}
|
||||
</span>
|
||||
<span>{formatPaymentBreakdownPercentage(item.percentage)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<Progress value={item.percentage} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import {
|
||||
PaymentBreakdownListItem,
|
||||
type PaymentBreakdownListItemData,
|
||||
} from "./payment-breakdown-list-item";
|
||||
|
||||
export type { PaymentBreakdownListItemData } from "./payment-breakdown-list-item";
|
||||
|
||||
type PaymentBreakdownListProps = {
|
||||
items: PaymentBreakdownListItemData[];
|
||||
emptyIcon: ReactNode;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
};
|
||||
|
||||
export function PaymentBreakdownList({
|
||||
items,
|
||||
emptyIcon,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
}: PaymentBreakdownListProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={emptyIcon}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
<PaymentBreakdownListItem key={item.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { RiCheckLine, RiSlideshowLine } from "@remixicon/react";
|
||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||
import { getConditionIcon } from "@/shared/utils/icons";
|
||||
import {
|
||||
PaymentBreakdownList,
|
||||
type PaymentBreakdownListItemData,
|
||||
} from "./payment-breakdown-list";
|
||||
|
||||
type PaymentConditionsWidgetProps = {
|
||||
data: PaymentConditionsData;
|
||||
};
|
||||
|
||||
const resolveConditionIcon = (condition: string) =>
|
||||
getConditionIcon(condition) ?? <RiCheckLine className="size-5" aria-hidden />;
|
||||
|
||||
export function PaymentConditionsWidget({
|
||||
data,
|
||||
}: PaymentConditionsWidgetProps) {
|
||||
const items: PaymentBreakdownListItemData[] = data.conditions.map(
|
||||
(condition) => ({
|
||||
id: condition.condition,
|
||||
title: condition.condition,
|
||||
icon: resolveConditionIcon(condition.condition),
|
||||
amount: condition.amount,
|
||||
transactions: condition.transactions,
|
||||
percentage: condition.percentage,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<PaymentBreakdownList
|
||||
items={items}
|
||||
emptyIcon={<RiSlideshowLine className="size-6 text-muted-foreground" />}
|
||||
emptyTitle="Nenhuma despesa encontrada"
|
||||
emptyDescription="As distribuições por condição aparecerão conforme novos lançamentos."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
|
||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||
import { getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||
import {
|
||||
PaymentBreakdownList,
|
||||
type PaymentBreakdownListItemData,
|
||||
} from "./payment-breakdown-list";
|
||||
|
||||
type PaymentMethodsWidgetProps = {
|
||||
data: PaymentMethodsData;
|
||||
};
|
||||
|
||||
const resolvePaymentMethodIcon = (paymentMethod: string) =>
|
||||
getPaymentMethodIcon(paymentMethod) ?? (
|
||||
<RiBankCard2Line className="size-5" aria-hidden />
|
||||
);
|
||||
|
||||
export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) {
|
||||
const items: PaymentBreakdownListItemData[] = data.methods.map((method) => ({
|
||||
id: method.paymentMethod,
|
||||
title: method.paymentMethod,
|
||||
icon: resolvePaymentMethodIcon(method.paymentMethod),
|
||||
amount: method.amount,
|
||||
transactions: method.transactions,
|
||||
percentage: method.percentage,
|
||||
}));
|
||||
|
||||
return (
|
||||
<PaymentBreakdownList
|
||||
items={items}
|
||||
emptyIcon={
|
||||
<RiMoneyDollarCircleLine className="size-6 text-muted-foreground" />
|
||||
}
|
||||
emptyTitle="Nenhuma despesa encontrada"
|
||||
emptyDescription="Cadastre despesas para visualizar a distribuição por forma de pagamento."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
|
||||
import type { PaymentOverviewTab } from "@/features/dashboard/payment-overview-tabs";
|
||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/shared/components/ui/tabs";
|
||||
import { PaymentConditionsWidget } from "./payment-conditions-widget";
|
||||
import { PaymentMethodsWidget } from "./payment-methods-widget";
|
||||
|
||||
type PaymentOverviewWidgetViewProps = {
|
||||
activeTab: PaymentOverviewTab;
|
||||
paymentConditionsData: PaymentConditionsData;
|
||||
paymentMethodsData: PaymentMethodsData;
|
||||
onTabChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function PaymentOverviewWidgetView({
|
||||
activeTab,
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
onTabChange,
|
||||
}: PaymentOverviewWidgetViewProps) {
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="conditions" className="text-xs">
|
||||
<RiSlideshowLine className="mr-1 size-3.5" />
|
||||
Condições
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="methods" className="text-xs">
|
||||
<RiMoneyDollarCircleLine className="mr-1 size-3.5" />
|
||||
Formas
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="conditions" className="mt-2">
|
||||
<PaymentConditionsWidget data={paymentConditionsData} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="methods" className="mt-2">
|
||||
<PaymentMethodsWidget data={paymentMethodsData} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
12
src/features/dashboard/components/payment-status-widget.tsx
Normal file
12
src/features/dashboard/components/payment-status-widget.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
|
||||
import { PaymentStatusWidgetView } from "./payment-status/payment-status-widget-view";
|
||||
|
||||
type PaymentStatusWidgetProps = {
|
||||
data: PaymentStatusData;
|
||||
};
|
||||
|
||||
export function PaymentStatusWidget({ data }: PaymentStatusWidgetProps) {
|
||||
return <PaymentStatusWidgetView data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import StatusDot from "@/shared/components/status-dot";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
|
||||
type PaymentStatusCategorySectionProps = {
|
||||
title: string;
|
||||
total: number;
|
||||
confirmed: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
export function PaymentStatusCategorySection({
|
||||
title,
|
||||
total,
|
||||
confirmed,
|
||||
pending,
|
||||
}: PaymentStatusCategorySectionProps) {
|
||||
const absTotal = Math.abs(total);
|
||||
const absConfirmed = Math.abs(confirmed);
|
||||
const confirmedPercentage =
|
||||
absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
<MoneyValues
|
||||
amount={total}
|
||||
className="text-sm font-medium tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Progress value={confirmedPercentage} className="h-2" />
|
||||
|
||||
<div className="flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusDot color="bg-primary" />
|
||||
<MoneyValues amount={confirmed} className="tabular-nums" />
|
||||
<span className="text-xs text-muted-foreground">confirmados</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusDot color="bg-warning/40" />
|
||||
<MoneyValues amount={pending} className="tabular-nums" />
|
||||
<span className="text-xs text-muted-foreground">pendentes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { RiWallet3Line } from "@remixicon/react";
|
||||
import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
|
||||
import { CardContent } from "@/shared/components/ui/card";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { PaymentStatusCategorySection } from "./payment-status-category-section";
|
||||
|
||||
type PaymentStatusWidgetViewProps = {
|
||||
data: PaymentStatusData;
|
||||
};
|
||||
|
||||
export function PaymentStatusWidgetView({
|
||||
data,
|
||||
}: PaymentStatusWidgetViewProps) {
|
||||
const isEmpty = data.income.total === 0 && data.expenses.total === 0;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<CardContent className="px-0">
|
||||
<WidgetEmptyState
|
||||
icon={<RiWallet3Line className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum valor a receber ou pagar no período"
|
||||
description="Registre lançamentos para visualizar os valores confirmados e pendentes."
|
||||
/>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardContent className="space-y-6 px-0">
|
||||
<PaymentStatusCategorySection
|
||||
title="A Receber"
|
||||
total={data.income.total}
|
||||
confirmed={data.income.confirmed}
|
||||
pending={data.income.pending}
|
||||
/>
|
||||
|
||||
<div className="border-t border-dashed" />
|
||||
|
||||
<PaymentStatusCategorySection
|
||||
title="A Pagar"
|
||||
total={data.expenses.total}
|
||||
confirmed={data.expenses.confirmed}
|
||||
pending={data.expenses.pending}
|
||||
/>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { RiArrowDownSFill, RiStore3Line } from "@remixicon/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PurchasesByCategoryData } from "@/features/dashboard/purchases-by-category-queries";
|
||||
import { EstabelecimentoLogo } from "@/features/transactions/components/shared/establishment-logo";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { CATEGORY_TYPE_LABEL } from "@/shared/lib/categories/constants";
|
||||
|
||||
type PurchasesByCategoryWidgetProps = {
|
||||
data: PurchasesByCategoryData;
|
||||
};
|
||||
|
||||
const formatTransactionDate = (date: Date | string) => {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const formatter = new Intl.DateTimeFormat("pt-BR", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
const formatted = formatter.format(d);
|
||||
// Capitaliza a primeira letra do dia da semana
|
||||
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "purchases-by-category-selected";
|
||||
|
||||
export function PurchasesByCategoryWidget({
|
||||
data,
|
||||
}: PurchasesByCategoryWidgetProps) {
|
||||
const firstCategoryId = data.categories[0]?.id ?? "";
|
||||
const hasRestoredSelectionRef = useRef(false);
|
||||
const hasPersistedSelectionRef = useRef(false);
|
||||
const [selectedCategoryId, setSelectedCategoryId] =
|
||||
useState<string>(firstCategoryId);
|
||||
|
||||
// Agrupa categorias por tipo
|
||||
const categoriesByType = useMemo(() => {
|
||||
const grouped: Record<string, typeof data.categories> = {};
|
||||
|
||||
for (const category of data.categories) {
|
||||
if (!grouped[category.type]) {
|
||||
grouped[category.type] = [];
|
||||
}
|
||||
const typeGroup = grouped[category.type];
|
||||
if (typeGroup) {
|
||||
typeGroup.push(category);
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data.categories]);
|
||||
|
||||
// Restaura a categoria salva apenas depois da montagem para manter SSR e cliente consistentes.
|
||||
useEffect(() => {
|
||||
if (hasRestoredSelectionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasRestoredSelectionRef.current = true;
|
||||
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved && data.categories.some((cat) => cat.id === saved)) {
|
||||
setSelectedCategoryId(saved);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCategoryId(firstCategoryId);
|
||||
}, [data.categories, firstCategoryId]);
|
||||
|
||||
// Salva a categoria selecionada quando mudar, sem sobrescrever o valor salvo na primeira montagem.
|
||||
useEffect(() => {
|
||||
if (!hasPersistedSelectionRef.current) {
|
||||
hasPersistedSelectionRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCategoryId) {
|
||||
sessionStorage.setItem(STORAGE_KEY, selectedCategoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}, [selectedCategoryId]);
|
||||
|
||||
// Atualiza a categoria selecionada se ela não existir mais na lista
|
||||
useEffect(() => {
|
||||
if (!selectedCategoryId && firstCategoryId) {
|
||||
setSelectedCategoryId(firstCategoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedCategoryId &&
|
||||
!data.categories.some((cat) => cat.id === selectedCategoryId)
|
||||
) {
|
||||
setSelectedCategoryId(firstCategoryId);
|
||||
}
|
||||
}, [data.categories, firstCategoryId, selectedCategoryId]);
|
||||
|
||||
const currentTransactions = useMemo(() => {
|
||||
if (!selectedCategoryId) {
|
||||
return [];
|
||||
}
|
||||
return data.transactionsByCategory[selectedCategoryId] ?? [];
|
||||
}, [selectedCategoryId, data.transactionsByCategory]);
|
||||
|
||||
const selectedCategory = useMemo(() => {
|
||||
return data.categories.find((cat) => cat.id === selectedCategoryId);
|
||||
}, [data.categories, selectedCategoryId]);
|
||||
|
||||
if (data.categories.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiStore3Line className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma categoria encontrada"
|
||||
description="Crie categorias de despesas ou receitas para visualizar as compras."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
value={selectedCategoryId}
|
||||
onValueChange={setSelectedCategoryId}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Selecione uma categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(categoriesByType).map(([type, categories]) => (
|
||||
<div key={type}>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
{CATEGORY_TYPE_LABEL[
|
||||
type as keyof typeof CATEGORY_TYPE_LABEL
|
||||
] ?? type}
|
||||
</div>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{currentTransactions.length === 0 ? (
|
||||
<WidgetEmptyState
|
||||
icon={<RiArrowDownSFill className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma compra encontrada"
|
||||
description={
|
||||
selectedCategory
|
||||
? `Não há lançamentos na categoria "${selectedCategory.name}".`
|
||||
: "Selecione uma categoria para visualizar as compras."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{currentTransactions.map((transaction) => {
|
||||
return (
|
||||
<li
|
||||
key={transaction.id}
|
||||
className="flex items-center justify-between gap-3 border-b border-dashed py-2 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<EstabelecimentoLogo name={transaction.name} size={37} />
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{transaction.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatTransactionDate(transaction.purchaseDate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-foreground">
|
||||
<MoneyValues amount={transaction.amount} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { RiRefreshLine } from "@remixicon/react";
|
||||
import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries";
|
||||
import { EstabelecimentoLogo } from "@/features/transactions/components/shared/establishment-logo";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
|
||||
type RecurringExpensesWidgetProps = {
|
||||
data: RecurringExpensesData;
|
||||
};
|
||||
|
||||
const formatOccurrences = (value: number | null) => {
|
||||
if (!value) {
|
||||
return "Recorrência contínua";
|
||||
}
|
||||
|
||||
return `${value} recorrências`;
|
||||
};
|
||||
|
||||
export function RecurringExpensesWidget({
|
||||
data,
|
||||
}: RecurringExpensesWidgetProps) {
|
||||
if (data.expenses.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiRefreshLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma despesa recorrente"
|
||||
description="Lançamentos recorrentes aparecerão aqui conforme forem registrados."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{data.expenses.map((expense) => {
|
||||
return (
|
||||
<li
|
||||
key={expense.id}
|
||||
className="flex items-center gap-3 border-b border-dashed pb-2 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<EstabelecimentoLogo name={expense.name} size={37} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="truncate text-foreground text-sm font-medium">
|
||||
{expense.name}
|
||||
</p>
|
||||
|
||||
<MoneyValues amount={expense.amount} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{expense.paymentMethod}
|
||||
</span>
|
||||
<span>{formatOccurrences(expense.recurrenceCount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
src/features/dashboard/components/recurring-series-widget.tsx
Normal file
153
src/features/dashboard/components/recurring-series-widget.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiPauseCircleLine,
|
||||
RiPlayCircleLine,
|
||||
RiRefreshLine,
|
||||
RiStopCircleLine,
|
||||
} from "@remixicon/react";
|
||||
import { useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { RecurringSeriesData } from "@/features/dashboard/recurring/recurring-series-queries";
|
||||
import {
|
||||
cancelRecurringSeriesAction,
|
||||
pauseRecurringSeriesAction,
|
||||
resumeRecurringSeriesAction,
|
||||
} from "@/features/recurring/actions";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { formatMonthYearLabel } from "@/shared/utils/period";
|
||||
|
||||
type RecurringSeriesWidgetProps = {
|
||||
data: RecurringSeriesData;
|
||||
};
|
||||
|
||||
export function RecurringSeriesWidget({ data }: RecurringSeriesWidgetProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
if (data.series.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiRefreshLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma série recorrente"
|
||||
description="Séries recorrentes aparecerão aqui quando forem criadas."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePause = (seriesId: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await pauseRecurringSeriesAction({ seriesId });
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleResume = (seriesId: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await resumeRecurringSeriesAction({ seriesId });
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = (seriesId: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
"Tem certeza que deseja cancelar esta série recorrente? Lançamentos passados serão mantidos.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const result = await cancelRecurringSeriesAction({ seriesId });
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{data.series.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 border-b border-dashed pb-2 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<p className="truncate text-foreground text-sm font-medium">
|
||||
{item.name}
|
||||
</p>
|
||||
<Badge
|
||||
variant={item.status === "active" ? "default" : "secondary"}
|
||||
className="shrink-0 text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{item.status === "active" ? "Ativo" : "Pausado"}
|
||||
</Badge>
|
||||
</div>
|
||||
<MoneyValues amount={item.amount} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mt-0.5">
|
||||
<span>
|
||||
Dia {item.dayOfMonth} · {item.paymentMethod}
|
||||
{item.categoryName ? ` · ${item.categoryName}` : ""}
|
||||
</span>
|
||||
<span>Próx: {formatMonthYearLabel(item.nextPeriod)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 mt-1.5">
|
||||
{item.status === "active" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={isPending}
|
||||
onClick={() => handlePause(item.id)}
|
||||
>
|
||||
<RiPauseCircleLine className="size-3.5 mr-1" />
|
||||
Pausar
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={isPending}
|
||||
onClick={() => handleResume(item.id)}
|
||||
>
|
||||
<RiPlayCircleLine className="size-3.5 mr-1" />
|
||||
Continuar
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-destructive hover:text-destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => handleCancel(item.id)}
|
||||
>
|
||||
<RiStopCircleLine className="size-3.5 mr-1" />
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/features/dashboard/components/sortable-widget.tsx
Normal file
48
src/features/dashboard/components/sortable-widget.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
type SortableWidgetProps = {
|
||||
id: string;
|
||||
children: ReactNode;
|
||||
isEditing: boolean;
|
||||
};
|
||||
|
||||
export function SortableWidget({
|
||||
id,
|
||||
children,
|
||||
isEditing,
|
||||
}: SortableWidgetProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id, disabled: !isEditing });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"relative",
|
||||
isDragging && "z-50 opacity-90",
|
||||
isEditing &&
|
||||
"cursor-grab active:cursor-grabbing touch-none select-none",
|
||||
)}
|
||||
{...(isEditing ? { ...attributes, ...listeners } : {})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { RiArrowUpDoubleLine, RiStore2Line } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import type { TopExpensesData } from "@/features/dashboard/expenses/top-expenses-queries";
|
||||
import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/shared/components/ui/tabs";
|
||||
import { TopEstablishmentsWidget } from "./top-establishments-widget";
|
||||
import { TopExpensesWidget } from "./top-expenses-widget";
|
||||
|
||||
type SpendingOverviewWidgetProps = {
|
||||
topExpensesAll: TopExpensesData;
|
||||
topExpensesCardOnly: TopExpensesData;
|
||||
topEstablishmentsData: TopEstablishmentsData;
|
||||
};
|
||||
|
||||
export function SpendingOverviewWidget({
|
||||
topExpensesAll,
|
||||
topExpensesCardOnly,
|
||||
topEstablishmentsData,
|
||||
}: SpendingOverviewWidgetProps) {
|
||||
const [activeTab, setActiveTab] = useState<"expenses" | "establishments">(
|
||||
"expenses",
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) =>
|
||||
setActiveTab(value as "expenses" | "establishments")
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="expenses" className="text-xs">
|
||||
<RiArrowUpDoubleLine className="mr-1 size-3.5" />
|
||||
Top gastos
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="establishments" className="text-xs">
|
||||
<RiStore2Line className="mr-1 size-3.5" />
|
||||
Estabelecimentos
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="expenses" className="mt-2">
|
||||
<TopExpensesWidget
|
||||
allExpenses={topExpensesAll}
|
||||
cardOnlyExpenses={topExpensesCardOnly}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="establishments" className="mt-2">
|
||||
<TopEstablishmentsWidget data={topEstablishmentsData} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { RiStore2Line } from "@remixicon/react";
|
||||
import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
|
||||
import { EstabelecimentoLogo } from "@/features/transactions/components/shared/establishment-logo";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
|
||||
type TopEstablishmentsWidgetProps = {
|
||||
data: TopEstablishmentsData;
|
||||
};
|
||||
|
||||
const formatOccurrencesLabel = (occurrences: number) => {
|
||||
if (occurrences === 1) {
|
||||
return "1 lançamento";
|
||||
}
|
||||
return `${occurrences} lançamentos`;
|
||||
};
|
||||
|
||||
export function TopEstablishmentsWidget({
|
||||
data,
|
||||
}: TopEstablishmentsWidgetProps) {
|
||||
return (
|
||||
<div className="flex flex-col px-0">
|
||||
{data.establishments.length === 0 ? (
|
||||
<WidgetEmptyState
|
||||
icon={<RiStore2Line className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum estabelecimento encontrado"
|
||||
description="Quando houver despesas registradas, elas aparecerão aqui."
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{data.establishments.map((establishment) => {
|
||||
return (
|
||||
<li
|
||||
key={establishment.id}
|
||||
className="flex items-center justify-between gap-3 border-b border-dashed py-2 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<EstabelecimentoLogo name={establishment.name} size={37} />
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{establishment.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatOccurrencesLabel(establishment.occurrences)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-foreground">
|
||||
<MoneyValues amount={establishment.amount} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
src/features/dashboard/components/top-expenses-widget.tsx
Normal file
140
src/features/dashboard/components/top-expenses-widget.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { RiArrowUpDoubleLine } from "@remixicon/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type {
|
||||
TopExpense,
|
||||
TopExpensesData,
|
||||
} from "@/features/dashboard/expenses/top-expenses-queries";
|
||||
import { EstabelecimentoLogo } from "@/features/transactions/components/shared/establishment-logo";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Switch } from "@/shared/components/ui/switch";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
|
||||
type TopExpensesWidgetProps = {
|
||||
allExpenses: TopExpensesData;
|
||||
cardOnlyExpenses: TopExpensesData;
|
||||
};
|
||||
|
||||
const formatTransactionDate = (date: Date | string) => {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const formatter = new Intl.DateTimeFormat("pt-BR", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
const formatted = formatter.format(d);
|
||||
// Capitaliza a primeira letra do dia da semana
|
||||
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
|
||||
};
|
||||
|
||||
const shouldIncludeExpense = (expense: TopExpense) => {
|
||||
const normalizedName = expense.name.trim().toLowerCase();
|
||||
|
||||
if (normalizedName === "saldo inicial") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedName.includes("fatura")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const isCardExpense = (expense: TopExpense) =>
|
||||
expense.paymentMethod?.toLowerCase().includes("cartão") ?? false;
|
||||
|
||||
export function TopExpensesWidget({
|
||||
allExpenses,
|
||||
cardOnlyExpenses,
|
||||
}: TopExpensesWidgetProps) {
|
||||
const [cardOnly, setCardOnly] = useState(false);
|
||||
const normalizedAllExpenses = useMemo(() => {
|
||||
return allExpenses.expenses.filter(shouldIncludeExpense);
|
||||
}, [allExpenses]);
|
||||
|
||||
const normalizedCardOnlyExpenses = useMemo(() => {
|
||||
const merged = [...cardOnlyExpenses.expenses, ...normalizedAllExpenses];
|
||||
const seen = new Set<string>();
|
||||
|
||||
return merged.filter((expense) => {
|
||||
if (seen.has(expense.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isCardExpense(expense) || !shouldIncludeExpense(expense)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(expense.id);
|
||||
return true;
|
||||
});
|
||||
}, [cardOnlyExpenses, normalizedAllExpenses]);
|
||||
|
||||
const data = cardOnly
|
||||
? { expenses: normalizedCardOnlyExpenses }
|
||||
: { expenses: normalizedAllExpenses };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor="card-only-toggle"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{cardOnly
|
||||
? "Somente cartões de crédito ou débito."
|
||||
: "Todas as despesas"}
|
||||
</label>
|
||||
<Switch
|
||||
id="card-only-toggle"
|
||||
checked={cardOnly}
|
||||
onCheckedChange={setCardOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data.expenses.length === 0 ? (
|
||||
<div className="-mt-10">
|
||||
<WidgetEmptyState
|
||||
icon={
|
||||
<RiArrowUpDoubleLine className="size-6 text-muted-foreground" />
|
||||
}
|
||||
title="Nenhuma despesa encontrada"
|
||||
description="Quando houver despesas registradas, elas aparecerão aqui."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{data.expenses.map((expense) => {
|
||||
return (
|
||||
<li
|
||||
key={expense.id}
|
||||
className="flex items-center justify-between gap-3 border-b border-dashed py-2 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<EstabelecimentoLogo name={expense.name} size={37} />
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{expense.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatTransactionDate(expense.purchaseDate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-foreground">
|
||||
<MoneyValues amount={expense.amount} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/features/dashboard/components/welcome-widget.ts
Normal file
9
src/features/dashboard/components/welcome-widget.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {
|
||||
formatBusinessCurrentDate,
|
||||
getBusinessGreeting,
|
||||
} from "@/shared/utils/date";
|
||||
|
||||
export const formatCurrentDate = (date = new Date()) =>
|
||||
formatBusinessCurrentDate(date);
|
||||
|
||||
export const getGreeting = (date = new Date()) => getBusinessGreeting(date);
|
||||
99
src/features/dashboard/components/widget-settings-dialog.tsx
Normal file
99
src/features/dashboard/components/widget-settings-dialog.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { RiRefreshLine, RiSettings4Line } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import { widgetsConfig } from "@/features/dashboard/widgets/widgets-config";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Switch } from "@/shared/components/ui/switch";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
type WidgetSettingsDialogProps = {
|
||||
hiddenWidgets: string[];
|
||||
onToggleWidget: (widgetId: string) => void;
|
||||
onReset: () => void;
|
||||
triggerClassName?: string;
|
||||
};
|
||||
|
||||
export function WidgetSettingsDialog({
|
||||
hiddenWidgets,
|
||||
onToggleWidget,
|
||||
onReset,
|
||||
triggerClassName,
|
||||
}: WidgetSettingsDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("gap-2", triggerClassName)}
|
||||
>
|
||||
<RiSettings4Line className="size-4" />
|
||||
Widgets
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configurar Widgets</DialogTitle>
|
||||
<DialogDescription>
|
||||
Escolha quais widgets deseja exibir no seu dashboard.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto py-4">
|
||||
<div className="space-y-3">
|
||||
{widgetsConfig.map((widget) => {
|
||||
const isVisible = !hiddenWidgets.includes(widget.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={widget.id}
|
||||
className="flex items-center justify-between gap-4 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-primary shrink-0">{widget.icon}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{widget.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{widget.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isVisible}
|
||||
onCheckedChange={() => onToggleWidget(widget.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiRefreshLine className="size-4" />
|
||||
Restaurar Padrão
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
168
src/features/dashboard/dashboard-metrics-queries.ts
Normal file
168
src/features/dashboard/dashboard-metrics-queries.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { and, asc, eq, gte, lte, ne, sum } from "drizzle-orm";
|
||||
import { contas, lancamentos } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
excludeInitialBalanceWhenConfigured,
|
||||
} from "@/features/dashboard/lancamento-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber } from "@/shared/utils/number";
|
||||
import {
|
||||
addMonthsToPeriod,
|
||||
buildPeriodRange,
|
||||
comparePeriods,
|
||||
getPreviousPeriod,
|
||||
} from "@/shared/utils/period";
|
||||
|
||||
const RECEITA = "Receita";
|
||||
const DESPESA = "Despesa";
|
||||
const TRANSFERENCIA = "Transferência";
|
||||
|
||||
type MetricPair = {
|
||||
current: number;
|
||||
previous: number;
|
||||
};
|
||||
|
||||
export type DashboardCardMetrics = {
|
||||
period: string;
|
||||
previousPeriod: string;
|
||||
receitas: MetricPair;
|
||||
despesas: MetricPair;
|
||||
balanco: MetricPair;
|
||||
previsto: MetricPair;
|
||||
};
|
||||
|
||||
type PeriodTotals = {
|
||||
receitas: number;
|
||||
despesas: number;
|
||||
balanco: number;
|
||||
};
|
||||
|
||||
const createEmptyTotals = (): PeriodTotals => ({
|
||||
receitas: 0,
|
||||
despesas: 0,
|
||||
balanco: 0,
|
||||
});
|
||||
|
||||
const ensurePeriodTotals = (
|
||||
store: Map<string, PeriodTotals>,
|
||||
period: string,
|
||||
): PeriodTotals => {
|
||||
if (!store.has(period)) {
|
||||
store.set(period, createEmptyTotals());
|
||||
}
|
||||
const totals = store.get(period);
|
||||
// This should always exist since we just set it above
|
||||
if (!totals) {
|
||||
const emptyTotals = createEmptyTotals();
|
||||
store.set(period, emptyTotals);
|
||||
return emptyTotals;
|
||||
}
|
||||
return totals;
|
||||
};
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export { getPreviousPeriod };
|
||||
|
||||
export async function fetchDashboardCardMetrics(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<DashboardCardMetrics> {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return {
|
||||
period,
|
||||
previousPeriod,
|
||||
receitas: { current: 0, previous: 0 },
|
||||
despesas: { current: 0, previous: 0 },
|
||||
balanco: { current: 0, previous: 0 },
|
||||
previsto: { current: 0, previous: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// Limitar scan histórico a 24 meses para evitar scans progressivamente mais lentos
|
||||
const startPeriod = addMonthsToPeriod(period, -24);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
period: lancamentos.period,
|
||||
transactionType: lancamentos.transactionType,
|
||||
totalAmount: sum(lancamentos.amount).as("total"),
|
||||
})
|
||||
.from(lancamentos)
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminFilters({ userId, adminPagadorId }),
|
||||
gte(lancamentos.period, startPeriod),
|
||||
lte(lancamentos.period, period),
|
||||
ne(lancamentos.transactionType, TRANSFERENCIA),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeInitialBalanceWhenConfigured(),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.period, lancamentos.transactionType)
|
||||
.orderBy(asc(lancamentos.period), asc(lancamentos.transactionType));
|
||||
|
||||
const periodTotals = new Map<string, PeriodTotals>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.period) continue;
|
||||
const totals = ensurePeriodTotals(periodTotals, row.period);
|
||||
const total = safeToNumber(row.totalAmount);
|
||||
if (row.transactionType === RECEITA) {
|
||||
totals.receitas += total;
|
||||
} else if (row.transactionType === DESPESA) {
|
||||
totals.despesas += Math.abs(total);
|
||||
}
|
||||
}
|
||||
|
||||
ensurePeriodTotals(periodTotals, period);
|
||||
ensurePeriodTotals(periodTotals, previousPeriod);
|
||||
|
||||
const earliestPeriod =
|
||||
periodTotals.size > 0 ? Array.from(periodTotals.keys()).sort()[0] : period;
|
||||
|
||||
const startRangePeriod =
|
||||
comparePeriods(earliestPeriod, previousPeriod) <= 0
|
||||
? earliestPeriod
|
||||
: previousPeriod;
|
||||
|
||||
const periodRange = buildPeriodRange(startRangePeriod, period);
|
||||
const forecastByPeriod = new Map<string, number>();
|
||||
let runningForecast = 0;
|
||||
|
||||
for (const key of periodRange) {
|
||||
const totals = ensurePeriodTotals(periodTotals, key);
|
||||
totals.balanco = totals.receitas - totals.despesas;
|
||||
runningForecast += totals.balanco;
|
||||
forecastByPeriod.set(key, runningForecast);
|
||||
}
|
||||
|
||||
const currentTotals = ensurePeriodTotals(periodTotals, period);
|
||||
const previousTotals = ensurePeriodTotals(periodTotals, previousPeriod);
|
||||
|
||||
return {
|
||||
period,
|
||||
previousPeriod,
|
||||
receitas: {
|
||||
current: currentTotals.receitas,
|
||||
previous: previousTotals.receitas,
|
||||
},
|
||||
despesas: {
|
||||
current: currentTotals.despesas,
|
||||
previous: previousTotals.despesas,
|
||||
},
|
||||
balanco: {
|
||||
current: currentTotals.balanco,
|
||||
previous: previousTotals.balanco,
|
||||
},
|
||||
previsto: {
|
||||
current: forecastByPeriod.get(period) ?? runningForecast,
|
||||
previous: forecastByPeriod.get(previousPeriod) ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
181
src/features/dashboard/expenses/installment-analysis-queries.ts
Normal file
181
src/features/dashboard/expenses/installment-analysis-queries.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { and, eq, isNotNull, isNull, or, sql } from "drizzle-orm";
|
||||
import { cartoes, lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||
import {
|
||||
buildDateOnlyStringFromPeriodDay,
|
||||
parseLocalDateString,
|
||||
} from "@/shared/utils/date";
|
||||
import { safeToNumber as toNumber } from "@/shared/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 dueDateString = buildDateOnlyStringFromPeriodDay(period, dueDay);
|
||||
if (!dueDateString) return null;
|
||||
|
||||
const dueDate = parseLocalDateString(dueDateString);
|
||||
if (Number.isNaN(dueDate.getTime())) return null;
|
||||
|
||||
// Meio-dia evita drift visual em serialização/locales diferentes.
|
||||
dueDate.setHours(12, 0, 0, 0);
|
||||
return dueDate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type InstallmentDetail = {
|
||||
id: string;
|
||||
currentInstallment: number;
|
||||
amount: number;
|
||||
dueDate: Date | null;
|
||||
period: string;
|
||||
isAnticipated: boolean;
|
||||
purchaseDate: Date;
|
||||
isSettled: boolean;
|
||||
};
|
||||
|
||||
export type InstallmentGroup = {
|
||||
seriesId: string;
|
||||
name: string;
|
||||
paymentMethod: string;
|
||||
cartaoId: string | null;
|
||||
cartaoName: string | null;
|
||||
cartaoDueDay: string | null;
|
||||
cartaoLogo: string | null;
|
||||
totalInstallments: number;
|
||||
paidInstallments: number;
|
||||
pendingInstallments: InstallmentDetail[];
|
||||
totalPendingAmount: number;
|
||||
firstPurchaseDate: Date;
|
||||
};
|
||||
|
||||
export type InstallmentAnalysisData = {
|
||||
installmentGroups: InstallmentGroup[];
|
||||
totalPendingInstallments: number;
|
||||
};
|
||||
|
||||
export async function fetchInstallmentAnalysis(
|
||||
userId: string,
|
||||
): Promise<InstallmentAnalysisData> {
|
||||
// 1. Buscar todos os lançamentos parcelados não antecipados do pagador admin
|
||||
const installmentRows = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
seriesId: lancamentos.seriesId,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
paymentMethod: lancamentos.paymentMethod,
|
||||
currentInstallment: lancamentos.currentInstallment,
|
||||
installmentCount: lancamentos.installmentCount,
|
||||
dueDate: lancamentos.dueDate,
|
||||
period: lancamentos.period,
|
||||
isAnticipated: lancamentos.isAnticipated,
|
||||
isSettled: lancamentos.isSettled,
|
||||
purchaseDate: lancamentos.purchaseDate,
|
||||
cartaoId: lancamentos.cartaoId,
|
||||
cartaoName: cartoes.name,
|
||||
cartaoDueDay: cartoes.dueDay,
|
||||
cartaoLogo: cartoes.logo,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(lancamentos.condition, "Parcelado"),
|
||||
eq(lancamentos.isAnticipated, false),
|
||||
isNotNull(lancamentos.seriesId),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(lancamentos.purchaseDate, lancamentos.currentInstallment);
|
||||
|
||||
// Agrupar por seriesId
|
||||
const seriesMap = new Map<string, InstallmentGroup>();
|
||||
|
||||
for (const row of installmentRows) {
|
||||
if (!row.seriesId) continue;
|
||||
|
||||
const amount = Math.abs(toNumber(row.amount));
|
||||
|
||||
// Calcular vencimento correto baseado no período e dia de vencimento do cartão
|
||||
const calculatedDueDate = row.cartaoDueDay
|
||||
? calculateDueDate(row.period, row.cartaoDueDay)
|
||||
: row.dueDate;
|
||||
|
||||
const installmentDetail: InstallmentDetail = {
|
||||
id: row.id,
|
||||
currentInstallment: row.currentInstallment ?? 1,
|
||||
amount,
|
||||
dueDate: calculatedDueDate,
|
||||
period: row.period,
|
||||
isAnticipated: row.isAnticipated ?? false,
|
||||
purchaseDate: row.purchaseDate,
|
||||
isSettled: row.isSettled ?? false,
|
||||
};
|
||||
|
||||
if (seriesMap.has(row.seriesId)) {
|
||||
const group = seriesMap.get(row.seriesId);
|
||||
group?.pendingInstallments.push(installmentDetail);
|
||||
if (group) group.totalPendingAmount += amount;
|
||||
} else {
|
||||
seriesMap.set(row.seriesId, {
|
||||
seriesId: row.seriesId,
|
||||
name: row.name,
|
||||
paymentMethod: row.paymentMethod,
|
||||
cartaoId: row.cartaoId,
|
||||
cartaoName: row.cartaoName,
|
||||
cartaoDueDay: row.cartaoDueDay,
|
||||
cartaoLogo: row.cartaoLogo,
|
||||
totalInstallments: row.installmentCount ?? 0,
|
||||
paidInstallments: 0,
|
||||
pendingInstallments: [installmentDetail],
|
||||
totalPendingAmount: amount,
|
||||
firstPurchaseDate: row.purchaseDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calcular quantas parcelas já foram pagas para cada grupo
|
||||
const installmentGroups = Array.from(seriesMap.values())
|
||||
.map((group) => {
|
||||
// Contar quantas parcelas estão marcadas como pagas (settled)
|
||||
const paidCount = group.pendingInstallments.filter(
|
||||
(i) => i.isSettled,
|
||||
).length;
|
||||
group.paidInstallments = paidCount;
|
||||
return group;
|
||||
})
|
||||
// Filtrar apenas séries que têm pelo menos uma parcela em aberto (não paga)
|
||||
.filter((group) => {
|
||||
const hasUnpaidInstallments = group.pendingInstallments.some(
|
||||
(i) => !i.isSettled,
|
||||
);
|
||||
return hasUnpaidInstallments;
|
||||
});
|
||||
|
||||
// Calcular totais
|
||||
const totalPendingInstallments = installmentGroups.reduce(
|
||||
(sum, group) => sum + group.totalPendingAmount,
|
||||
0,
|
||||
);
|
||||
|
||||
return { installmentGroups, totalPendingInstallments };
|
||||
}
|
||||
100
src/features/dashboard/expenses/installment-expenses-queries.ts
Normal file
100
src/features/dashboard/expenses/installment-expenses-queries.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { lancamentos } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type InstallmentExpense = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
paymentMethod: string;
|
||||
currentInstallment: number | null;
|
||||
installmentCount: number | null;
|
||||
dueDate: Date | null;
|
||||
purchaseDate: Date;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export type InstallmentExpensesData = {
|
||||
expenses: InstallmentExpense[];
|
||||
};
|
||||
|
||||
export async function fetchInstallmentExpenses(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<InstallmentExpensesData> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { expenses: [] };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
paymentMethod: lancamentos.paymentMethod,
|
||||
currentInstallment: lancamentos.currentInstallment,
|
||||
installmentCount: lancamentos.installmentCount,
|
||||
dueDate: lancamentos.dueDate,
|
||||
purchaseDate: lancamentos.purchaseDate,
|
||||
period: lancamentos.period,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(lancamentos.condition, "Parcelado"),
|
||||
eq(lancamentos.isAnticipated, false),
|
||||
eq(lancamentos.pagadorId, adminPagadorId),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
and(
|
||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
|
||||
|
||||
type InstallmentExpenseRow = (typeof rows)[number];
|
||||
|
||||
const expenses = rows
|
||||
.map(
|
||||
(row: InstallmentExpenseRow): InstallmentExpense => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
paymentMethod: row.paymentMethod,
|
||||
currentInstallment: row.currentInstallment,
|
||||
installmentCount: row.installmentCount,
|
||||
dueDate: row.dueDate ?? null,
|
||||
purchaseDate: row.purchaseDate,
|
||||
period: row.period,
|
||||
}),
|
||||
)
|
||||
.sort((a: InstallmentExpense, b: InstallmentExpense) => {
|
||||
// Calcula parcelas restantes para cada item
|
||||
const remainingA =
|
||||
a.installmentCount && a.currentInstallment
|
||||
? a.installmentCount - a.currentInstallment
|
||||
: 0;
|
||||
const remainingB =
|
||||
b.installmentCount && b.currentInstallment
|
||||
? b.installmentCount - b.currentInstallment
|
||||
: 0;
|
||||
|
||||
// Ordena do menor número de parcelas restantes para o maior
|
||||
return remainingA - remainingB;
|
||||
});
|
||||
|
||||
return { expenses };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { lancamentos } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type RecurringExpense = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
paymentMethod: string;
|
||||
recurrenceCount: number | null;
|
||||
};
|
||||
|
||||
export type RecurringExpensesData = {
|
||||
expenses: RecurringExpense[];
|
||||
};
|
||||
|
||||
export async function fetchRecurringExpenses(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<RecurringExpensesData> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { expenses: [] };
|
||||
}
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
paymentMethod: lancamentos.paymentMethod,
|
||||
recurrenceCount: lancamentos.recurrenceCount,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(lancamentos.condition, "Recorrente"),
|
||||
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}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
|
||||
|
||||
const expenses = results.map(
|
||||
(row): RecurringExpense => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
paymentMethod: row.paymentMethod,
|
||||
recurrenceCount: row.recurrenceCount,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
expenses,
|
||||
};
|
||||
}
|
||||
82
src/features/dashboard/expenses/top-expenses-queries.ts
Normal file
82
src/features/dashboard/expenses/top-expenses-queries.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { cartoes, contas, lancamentos } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminPeriodFilters,
|
||||
excludeAutoGeneratedEntryNotes,
|
||||
} from "@/features/dashboard/lancamento-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type TopExpense = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
purchaseDate: Date;
|
||||
paymentMethod: string;
|
||||
logo?: string | null;
|
||||
};
|
||||
|
||||
export type TopExpensesData = {
|
||||
expenses: TopExpense[];
|
||||
};
|
||||
|
||||
export async function fetchTopExpenses(
|
||||
userId: string,
|
||||
period: string,
|
||||
cardOnly: boolean = false,
|
||||
): Promise<TopExpensesData> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { expenses: [] };
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
...buildDashboardAdminPeriodFilters({
|
||||
userId,
|
||||
period,
|
||||
adminPagadorId,
|
||||
}),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
excludeAutoGeneratedEntryNotes(),
|
||||
];
|
||||
|
||||
// Se cardOnly for true, filtra apenas pagamentos com cartão
|
||||
if (cardOnly) {
|
||||
conditions.push(eq(lancamentos.paymentMethod, "Cartão de Crédito"));
|
||||
}
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
purchaseDate: lancamentos.purchaseDate,
|
||||
paymentMethod: lancamentos.paymentMethod,
|
||||
cartaoId: lancamentos.cartaoId,
|
||||
contaId: lancamentos.contaId,
|
||||
cardLogo: cartoes.logo,
|
||||
accountLogo: contas.logo,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(lancamentos.amount))
|
||||
.limit(10);
|
||||
|
||||
const expenses = results.map(
|
||||
(row: (typeof results)[number]): TopExpense => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
purchaseDate: row.purchaseDate,
|
||||
paymentMethod: row.paymentMethod,
|
||||
logo: row.cardLogo ?? row.accountLogo ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
expenses,
|
||||
};
|
||||
}
|
||||
107
src/features/dashboard/fetch-dashboard-data.ts
Normal file
107
src/features/dashboard/fetch-dashboard-data.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { fetchDashboardAccounts } from "./accounts-queries";
|
||||
import { fetchDashboardBills } from "./bills-queries";
|
||||
import { fetchExpensesByCategory } from "./categories/expenses-by-category-queries";
|
||||
import { fetchIncomeByCategory } from "./categories/income-by-category-queries";
|
||||
import { fetchDashboardCardMetrics } from "./dashboard-metrics-queries";
|
||||
import { fetchInstallmentExpenses } from "./expenses/installment-expenses-queries";
|
||||
import { fetchRecurringExpenses } from "./expenses/recurring-expenses-queries";
|
||||
import { fetchTopExpenses } from "./expenses/top-expenses-queries";
|
||||
import { fetchGoalsProgressData } from "./goals-progress-queries";
|
||||
import { fetchIncomeExpenseBalance } from "./income-expense-balance-queries";
|
||||
import { fetchDashboardInvoices } from "./invoices-queries";
|
||||
import { fetchDashboardNotes } from "./notes-queries";
|
||||
import { fetchDashboardPagadores } from "./payers-queries";
|
||||
import { fetchPaymentConditions } from "./payments/payment-conditions-queries";
|
||||
import { fetchPaymentMethods } from "./payments/payment-methods-queries";
|
||||
import { fetchPaymentStatus } from "./payments/payment-status-queries";
|
||||
import { fetchPurchasesByCategory } from "./purchases-by-category-queries";
|
||||
import { fetchRecurringSeries } from "./recurring/recurring-series-queries";
|
||||
import { fetchTopEstablishments } from "./top-establishments-queries";
|
||||
|
||||
async function fetchDashboardDataInternal(userId: string, period: string) {
|
||||
const [
|
||||
metrics,
|
||||
accountsSnapshot,
|
||||
invoicesSnapshot,
|
||||
billsSnapshot,
|
||||
goalsProgressData,
|
||||
paymentStatusData,
|
||||
incomeExpenseBalanceData,
|
||||
pagadoresSnapshot,
|
||||
notesData,
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
recurringExpensesData,
|
||||
installmentExpensesData,
|
||||
topEstablishmentsData,
|
||||
topExpensesAll,
|
||||
topExpensesCardOnly,
|
||||
purchasesByCategoryData,
|
||||
incomeByCategoryData,
|
||||
expensesByCategoryData,
|
||||
recurringSeriesData,
|
||||
] = await Promise.all([
|
||||
fetchDashboardCardMetrics(userId, period),
|
||||
fetchDashboardAccounts(userId),
|
||||
fetchDashboardInvoices(userId, period),
|
||||
fetchDashboardBills(userId, period),
|
||||
fetchGoalsProgressData(userId, period),
|
||||
fetchPaymentStatus(userId, period),
|
||||
fetchIncomeExpenseBalance(userId, period),
|
||||
fetchDashboardPagadores(userId, period),
|
||||
fetchDashboardNotes(userId),
|
||||
fetchPaymentConditions(userId, period),
|
||||
fetchPaymentMethods(userId, period),
|
||||
fetchRecurringExpenses(userId, period),
|
||||
fetchInstallmentExpenses(userId, period),
|
||||
fetchTopEstablishments(userId, period),
|
||||
fetchTopExpenses(userId, period, false),
|
||||
fetchTopExpenses(userId, period, true),
|
||||
fetchPurchasesByCategory(userId, period),
|
||||
fetchIncomeByCategory(userId, period),
|
||||
fetchExpensesByCategory(userId, period),
|
||||
fetchRecurringSeries(userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
accountsSnapshot,
|
||||
invoicesSnapshot,
|
||||
billsSnapshot,
|
||||
goalsProgressData,
|
||||
paymentStatusData,
|
||||
incomeExpenseBalanceData,
|
||||
pagadoresSnapshot,
|
||||
notesData,
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
recurringExpensesData,
|
||||
installmentExpensesData,
|
||||
topEstablishmentsData,
|
||||
topExpensesAll,
|
||||
topExpensesCardOnly,
|
||||
purchasesByCategoryData,
|
||||
incomeByCategoryData,
|
||||
expensesByCategoryData,
|
||||
recurringSeriesData,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached dashboard data fetcher.
|
||||
* Uses unstable_cache with tags for revalidation on mutations.
|
||||
* Cache is keyed by userId + period, and invalidated via "dashboard" tag.
|
||||
*/
|
||||
export function fetchDashboardData(userId: string, period: string) {
|
||||
return unstable_cache(
|
||||
() => fetchDashboardDataInternal(userId, period),
|
||||
[`dashboard-${userId}-${period}`],
|
||||
{
|
||||
tags: ["dashboard", `dashboard-${userId}`],
|
||||
revalidate: 60,
|
||||
},
|
||||
)();
|
||||
}
|
||||
|
||||
export type DashboardData = Awaited<ReturnType<typeof fetchDashboardData>>;
|
||||
48
src/features/dashboard/goals-progress-helpers.ts
Normal file
48
src/features/dashboard/goals-progress-helpers.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
Budget,
|
||||
BudgetCategory,
|
||||
} from "@/features/budgets/components/types";
|
||||
import type {
|
||||
GoalProgressCategory,
|
||||
GoalProgressItem,
|
||||
GoalProgressStatus,
|
||||
} from "@/features/dashboard/goals-progress-queries";
|
||||
import { formatPercentage } from "@/shared/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,
|
||||
});
|
||||
147
src/features/dashboard/goals-progress-queries.ts
Normal file
147
src/features/dashboard/goals-progress-queries.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { and, eq, ne, sql } from "drizzle-orm";
|
||||
import { categorias, lancamentos, orcamentos } from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
const BUDGET_CRITICAL_THRESHOLD = 80;
|
||||
|
||||
export type GoalProgressStatus = "on-track" | "critical" | "exceeded";
|
||||
|
||||
export type GoalProgressItem = {
|
||||
id: string;
|
||||
categoryId: string | null;
|
||||
categoryName: string;
|
||||
categoryIcon: string | null;
|
||||
period: string;
|
||||
createdAt: string;
|
||||
budgetAmount: number;
|
||||
spentAmount: number;
|
||||
usedPercentage: number;
|
||||
status: GoalProgressStatus;
|
||||
};
|
||||
|
||||
export type GoalProgressCategory = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
export type GoalsProgressData = {
|
||||
items: GoalProgressItem[];
|
||||
categories: GoalProgressCategory[];
|
||||
totalBudgets: number;
|
||||
exceededCount: number;
|
||||
criticalCount: number;
|
||||
};
|
||||
|
||||
const resolveStatus = (usedPercentage: number): GoalProgressStatus => {
|
||||
if (usedPercentage >= 100) {
|
||||
return "exceeded";
|
||||
}
|
||||
if (usedPercentage >= BUDGET_CRITICAL_THRESHOLD) {
|
||||
return "critical";
|
||||
}
|
||||
return "on-track";
|
||||
};
|
||||
|
||||
export async function fetchGoalsProgressData(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<GoalsProgressData> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
|
||||
if (!adminPagadorId) {
|
||||
return {
|
||||
items: [],
|
||||
categories: [],
|
||||
totalBudgets: 0,
|
||||
exceededCount: 0,
|
||||
criticalCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const [rows, categoryRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
orcamentoId: orcamentos.id,
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
period: orcamentos.period,
|
||||
createdAt: orcamentos.createdAt,
|
||||
budgetAmount: orcamentos.amount,
|
||||
spentAmount: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`,
|
||||
})
|
||||
.from(orcamentos)
|
||||
.innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id))
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.categoriaId, orcamentos.categoriaId),
|
||||
eq(lancamentos.userId, orcamentos.userId),
|
||||
eq(lancamentos.period, orcamentos.period),
|
||||
eq(lancamentos.pagadorId, adminPagadorId),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
ne(lancamentos.condition, "cancelado"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period)))
|
||||
.groupBy(
|
||||
orcamentos.id,
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
orcamentos.period,
|
||||
orcamentos.createdAt,
|
||||
orcamentos.amount,
|
||||
),
|
||||
db.query.categorias.findMany({
|
||||
where: and(eq(categorias.userId, userId), eq(categorias.type, "despesa")),
|
||||
orderBy: (category, { asc }) => [asc(category.name)],
|
||||
}),
|
||||
]);
|
||||
|
||||
const categories: GoalProgressCategory[] = categoryRows.map((category) => ({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
icon: category.icon,
|
||||
}));
|
||||
|
||||
const items: GoalProgressItem[] = rows
|
||||
.map((row) => {
|
||||
const budgetAmount = toNumber(row.budgetAmount);
|
||||
const spentAmount = toNumber(row.spentAmount);
|
||||
const usedPercentage =
|
||||
budgetAmount > 0 ? (spentAmount / budgetAmount) * 100 : 0;
|
||||
|
||||
return {
|
||||
id: row.orcamentoId,
|
||||
categoryId: row.categoryId,
|
||||
categoryName: row.categoryName,
|
||||
categoryIcon: row.categoryIcon,
|
||||
period: row.period,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
budgetAmount,
|
||||
spentAmount,
|
||||
usedPercentage,
|
||||
status: resolveStatus(usedPercentage),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.usedPercentage - a.usedPercentage);
|
||||
|
||||
const exceededCount = items.filter(
|
||||
(item) => item.status === "exceeded",
|
||||
).length;
|
||||
const criticalCount = items.filter(
|
||||
(item) => item.status === "critical",
|
||||
).length;
|
||||
|
||||
return {
|
||||
items,
|
||||
categories,
|
||||
totalBudgets: items.length,
|
||||
exceededCount,
|
||||
criticalCount,
|
||||
};
|
||||
}
|
||||
96
src/features/dashboard/income-expense-balance-queries.ts
Normal file
96
src/features/dashboard/income-expense-balance-queries.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { contas, lancamentos } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
excludeInitialBalanceWhenConfigured,
|
||||
} from "@/features/dashboard/lancamento-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
import {
|
||||
buildPeriodWindow,
|
||||
formatPeriodMonthShort,
|
||||
getCurrentPeriod,
|
||||
} from "@/shared/utils/period";
|
||||
|
||||
export type MonthData = {
|
||||
month: string;
|
||||
monthLabel: string;
|
||||
income: number;
|
||||
expense: number;
|
||||
balance: number;
|
||||
};
|
||||
|
||||
export type IncomeExpenseBalanceData = {
|
||||
months: MonthData[];
|
||||
};
|
||||
|
||||
const generateLast6Months = (currentPeriod: string): string[] => {
|
||||
try {
|
||||
return buildPeriodWindow(currentPeriod, 6);
|
||||
} catch {
|
||||
return buildPeriodWindow(getCurrentPeriod(), 6);
|
||||
}
|
||||
};
|
||||
|
||||
export async function fetchIncomeExpenseBalance(
|
||||
userId: string,
|
||||
currentPeriod: string,
|
||||
): Promise<IncomeExpenseBalanceData> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { months: [] };
|
||||
}
|
||||
|
||||
const periods = generateLast6Months(currentPeriod);
|
||||
|
||||
// Single query: GROUP BY period + transactionType instead of 12 separate queries
|
||||
const rows = await db
|
||||
.select({
|
||||
period: lancamentos.period,
|
||||
transactionType: lancamentos.transactionType,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminFilters({ userId, adminPagadorId }),
|
||||
inArray(lancamentos.period, periods),
|
||||
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeInitialBalanceWhenConfigured(),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.period, lancamentos.transactionType);
|
||||
|
||||
// Build lookup from query results
|
||||
const dataMap = new Map<string, { income: number; expense: number }>();
|
||||
for (const row of rows) {
|
||||
if (!row.period) continue;
|
||||
const entry = dataMap.get(row.period) ?? { income: 0, expense: 0 };
|
||||
const total = Math.abs(toNumber(row.total));
|
||||
if (row.transactionType === "Receita") {
|
||||
entry.income = total;
|
||||
} else if (row.transactionType === "Despesa") {
|
||||
entry.expense = total;
|
||||
}
|
||||
dataMap.set(row.period, entry);
|
||||
}
|
||||
|
||||
// Build result array preserving period order
|
||||
const months = periods.map((period) => {
|
||||
const entry = dataMap.get(period) ?? { income: 0, expense: 0 };
|
||||
|
||||
return {
|
||||
month: period,
|
||||
monthLabel: formatPeriodMonthShort(period).toLowerCase(),
|
||||
income: entry.income,
|
||||
expense: entry.expense,
|
||||
balance: entry.income - entry.expense,
|
||||
};
|
||||
});
|
||||
|
||||
return { months };
|
||||
}
|
||||
116
src/features/dashboard/installment-expenses-helpers.ts
Normal file
116
src/features/dashboard/installment-expenses-helpers.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import {
|
||||
calculateLastInstallmentDate,
|
||||
formatLastInstallmentDate,
|
||||
} from "@/shared/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
src/features/dashboard/invoices-helpers.ts
Normal file
104
src/features/dashboard/invoices-helpers.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import type { PaymentDialogState } from "@/features/dashboard/use-payment-dialog-controller";
|
||||
import {
|
||||
INVOICE_PAYMENT_STATUS,
|
||||
type InvoicePaymentStatus,
|
||||
} from "@/shared/lib/invoices";
|
||||
import { getBusinessDateString } from "@/shared/utils/date";
|
||||
import {
|
||||
buildDueDateInfoFromPeriodDay,
|
||||
formatFinancialDateLabel,
|
||||
} from "@/shared/utils/financial-dates";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
import { formatPeriodForUrl } from "@/shared/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) =>
|
||||
`/cards/${cardId}/invoice?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;
|
||||
279
src/features/dashboard/invoices-queries.ts
Normal file
279
src/features/dashboard/invoices-queries.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { and, eq, ilike, isNotNull, sql } from "drizzle-orm";
|
||||
import { cartoes, faturas, lancamentos, pagadores } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import {
|
||||
INVOICE_PAYMENT_STATUS,
|
||||
INVOICE_STATUS_VALUES,
|
||||
type InvoicePaymentStatus,
|
||||
} from "@/shared/lib/invoices";
|
||||
import { toDateOnlyString } from "@/shared/utils/date";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
type RawDashboardInvoice = {
|
||||
invoiceId: string | null;
|
||||
cardId: string;
|
||||
cardName: string;
|
||||
cardBrand: string | null;
|
||||
cardStatus: string | null;
|
||||
logo: string | null;
|
||||
dueDay: string;
|
||||
period: string | null;
|
||||
paymentStatus: string | null;
|
||||
totalAmount: string | number | null;
|
||||
transactionCount: string | number | null;
|
||||
invoiceCreatedAt: Date | null;
|
||||
};
|
||||
|
||||
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;
|
||||
pagadorAvatar: string | null;
|
||||
amount: number;
|
||||
};
|
||||
|
||||
export type DashboardInvoice = {
|
||||
id: string;
|
||||
cardId: string;
|
||||
cardName: string;
|
||||
cardBrand: string | null;
|
||||
cardStatus: string | null;
|
||||
logo: string | null;
|
||||
dueDay: string;
|
||||
period: string;
|
||||
paymentStatus: InvoicePaymentStatus;
|
||||
totalAmount: number;
|
||||
paidAt: string | null;
|
||||
pagadorBreakdown: InvoicePagadorBreakdown[];
|
||||
};
|
||||
|
||||
export type DashboardInvoicesSnapshot = {
|
||||
invoices: DashboardInvoice[];
|
||||
totalPending: number;
|
||||
};
|
||||
|
||||
const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
|
||||
typeof value === "string" &&
|
||||
(INVOICE_STATUS_VALUES as string[]).includes(value);
|
||||
|
||||
const buildFallbackId = (cardId: string, period: string) =>
|
||||
`${cardId}:${period}`;
|
||||
|
||||
export async function fetchDashboardInvoices(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<DashboardInvoicesSnapshot> {
|
||||
const paymentRows = await db
|
||||
.select({
|
||||
note: lancamentos.note,
|
||||
purchaseDate: lancamentos.purchaseDate,
|
||||
createdAt: lancamentos.createdAt,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`),
|
||||
),
|
||||
);
|
||||
|
||||
const paymentMap = new Map<string, string>();
|
||||
for (const row of paymentRows) {
|
||||
const note = row.note;
|
||||
if (!note || !note.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
const parts = note.split(":");
|
||||
if (parts.length < 3) {
|
||||
continue;
|
||||
}
|
||||
const cardIdPart = parts[1];
|
||||
const periodPart = parts[2];
|
||||
if (!cardIdPart || !periodPart) {
|
||||
continue;
|
||||
}
|
||||
const key = `${cardIdPart}:${periodPart}`;
|
||||
const resolvedDate =
|
||||
row.purchaseDate instanceof Date &&
|
||||
!Number.isNaN(row.purchaseDate.valueOf())
|
||||
? row.purchaseDate
|
||||
: row.createdAt;
|
||||
const isoDate = toDateOnlyString(resolvedDate);
|
||||
if (!isoDate) {
|
||||
continue;
|
||||
}
|
||||
const existing = paymentMap.get(key);
|
||||
if (!existing || existing < isoDate) {
|
||||
paymentMap.set(key, isoDate);
|
||||
}
|
||||
}
|
||||
|
||||
const [rows, breakdownRows]: [
|
||||
RawDashboardInvoice[],
|
||||
RawInvoiceBreakdownRow[],
|
||||
] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
invoiceId: faturas.id,
|
||||
cardId: cartoes.id,
|
||||
cardName: cartoes.name,
|
||||
logo: cartoes.logo,
|
||||
dueDay: cartoes.dueDay,
|
||||
period: faturas.period,
|
||||
paymentStatus: faturas.paymentStatus,
|
||||
invoiceCreatedAt: faturas.createdAt,
|
||||
totalAmount: sql<number | null>`
|
||||
COALESCE(SUM(${lancamentos.amount}), 0)
|
||||
`,
|
||||
transactionCount: sql<number | null>`COUNT(${lancamentos.id})`,
|
||||
})
|
||||
.from(cartoes)
|
||||
.leftJoin(
|
||||
faturas,
|
||||
and(
|
||||
eq(faturas.cartaoId, cartoes.id),
|
||||
eq(faturas.userId, userId),
|
||||
eq(faturas.period, period),
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.cartaoId, cartoes.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
),
|
||||
)
|
||||
.where(eq(cartoes.userId, userId))
|
||||
.groupBy(
|
||||
faturas.id,
|
||||
cartoes.id,
|
||||
cartoes.name,
|
||||
cartoes.brand,
|
||||
cartoes.status,
|
||||
cartoes.logo,
|
||||
cartoes.dueDay,
|
||||
faturas.period,
|
||||
faturas.paymentStatus,
|
||||
),
|
||||
db
|
||||
.select({
|
||||
cardId: lancamentos.cartaoId,
|
||||
period: lancamentos.period,
|
||||
pagadorId: lancamentos.pagadorId,
|
||||
pagadorName: pagadores.name,
|
||||
pagadorAvatar: pagadores.avatarUrl,
|
||||
amount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
isNotNull(lancamentos.cartaoId),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
lancamentos.cartaoId,
|
||||
lancamentos.period,
|
||||
lancamentos.pagadorId,
|
||||
pagadores.name,
|
||||
pagadores.avatarUrl,
|
||||
),
|
||||
]);
|
||||
|
||||
const breakdownMap = new Map<string, InvoicePagadorBreakdown[]>();
|
||||
for (const row of breakdownRows) {
|
||||
if (!row.cardId) {
|
||||
continue;
|
||||
}
|
||||
const resolvedPeriod = row.period ?? period;
|
||||
const amount = Math.abs(toNumber(row.amount));
|
||||
if (amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key = `${row.cardId}:${resolvedPeriod}`;
|
||||
const current = breakdownMap.get(key) ?? [];
|
||||
current.push({
|
||||
pagadorId: row.pagadorId ?? null,
|
||||
pagadorName: row.pagadorName?.trim() || "Sem pagador",
|
||||
pagadorAvatar: row.pagadorAvatar ?? null,
|
||||
amount,
|
||||
});
|
||||
breakdownMap.set(key, current);
|
||||
}
|
||||
|
||||
const invoices: DashboardInvoice[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const totalAmount = toNumber(row.totalAmount);
|
||||
const transactionCount = toNumber(row.transactionCount);
|
||||
const paymentStatus = isInvoiceStatus(row.paymentStatus)
|
||||
? row.paymentStatus
|
||||
: INVOICE_PAYMENT_STATUS.PENDING;
|
||||
|
||||
const shouldInclude =
|
||||
transactionCount > 0 ||
|
||||
Math.abs(totalAmount) > 0 ||
|
||||
row.invoiceId !== null;
|
||||
|
||||
if (!shouldInclude) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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) {
|
||||
return total;
|
||||
}
|
||||
return total + invoice.totalAmount;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
invoices,
|
||||
totalPending,
|
||||
};
|
||||
}
|
||||
56
src/features/dashboard/lancamento-filters.ts
Normal file
56
src/features/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 "@/shared/lib/accounts/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
src/features/dashboard/notes-mappers.ts
Normal file
15
src/features/dashboard/notes-mappers.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { DashboardNote } from "@/features/dashboard/notes-queries";
|
||||
import type { Note } from "@/features/notes/components/types";
|
||||
|
||||
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);
|
||||
73
src/features/dashboard/notes-queries.ts
Normal file
73
src/features/dashboard/notes-queries.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { anotacoes } from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
|
||||
export type DashboardTask = {
|
||||
id: string;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
};
|
||||
|
||||
export type DashboardNote = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: "nota" | "tarefa";
|
||||
tasks?: DashboardTask[];
|
||||
arquivada: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const parseTasks = (value: string | null): DashboardTask[] | undefined => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parsed
|
||||
.filter((item): item is DashboardTask => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return false;
|
||||
}
|
||||
const candidate = item as Partial<DashboardTask>;
|
||||
return (
|
||||
typeof candidate.id === "string" &&
|
||||
typeof candidate.text === "string" &&
|
||||
typeof candidate.completed === "boolean"
|
||||
);
|
||||
})
|
||||
.map((task) => ({
|
||||
id: task.id,
|
||||
text: task.text,
|
||||
completed: task.completed,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to parse dashboard note tasks", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export async function fetchDashboardNotes(
|
||||
userId: string,
|
||||
): Promise<DashboardNote[]> {
|
||||
const notes = await db.query.anotacoes.findMany({
|
||||
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)),
|
||||
orderBy: (note, { desc }) => [desc(note.createdAt)],
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
return notes.map((note) => ({
|
||||
id: note.id,
|
||||
title: (note.title ?? "").trim(),
|
||||
description: (note.description ?? "").trim(),
|
||||
type: (note.type ?? "nota") as "nota" | "tarefa",
|
||||
tasks: parseTasks(note.tasks),
|
||||
arquivada: note.arquivada,
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
350
src/features/dashboard/notifications-queries.ts
Normal file
350
src/features/dashboard/notifications-queries.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, lt, ne, sql } from "drizzle-orm";
|
||||
import {
|
||||
cartoes,
|
||||
categorias,
|
||||
faturas,
|
||||
lancamentos,
|
||||
orcamentos,
|
||||
} from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import {
|
||||
buildDateOnlyStringFromPeriodDay,
|
||||
getBusinessDateString,
|
||||
isDateOnlyPast,
|
||||
isDateOnlyWithinDays,
|
||||
toDateOnlyString,
|
||||
} from "@/shared/utils/date";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type NotificationType = "overdue" | "due_soon";
|
||||
|
||||
export type DashboardNotification = {
|
||||
id: string;
|
||||
type: "invoice" | "boleto";
|
||||
name: string;
|
||||
dueDate: string;
|
||||
status: NotificationType;
|
||||
amount: number;
|
||||
period?: string;
|
||||
showAmount: boolean;
|
||||
cardLogo?: string | null;
|
||||
};
|
||||
|
||||
export type BudgetStatus = "exceeded" | "critical";
|
||||
|
||||
export type BudgetNotification = {
|
||||
id: string;
|
||||
categoryName: string;
|
||||
budgetAmount: number;
|
||||
spentAmount: number;
|
||||
usedPercentage: number;
|
||||
status: BudgetStatus;
|
||||
};
|
||||
|
||||
export type DashboardNotificationsSnapshot = {
|
||||
notifications: DashboardNotification[];
|
||||
totalCount: number;
|
||||
budgetNotifications: BudgetNotification[];
|
||||
};
|
||||
|
||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||
const BUDGET_CRITICAL_THRESHOLD = 80;
|
||||
|
||||
/**
|
||||
* Busca todas as notificações do dashboard:
|
||||
* - Faturas de cartão atrasadas ou com vencimento próximo
|
||||
* - Boletos não pagos atrasados ou com vencimento próximo
|
||||
* - Orçamentos excedidos (≥ 100%) ou críticos (≥ 80%)
|
||||
*/
|
||||
export async function fetchDashboardNotifications(
|
||||
userId: string,
|
||||
currentPeriod: string,
|
||||
): Promise<DashboardNotificationsSnapshot> {
|
||||
const today = getBusinessDateString();
|
||||
const DAYS_THRESHOLD = 5;
|
||||
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
|
||||
// --- Faturas atrasadas (períodos anteriores) ---
|
||||
const overdueInvoices = await db
|
||||
.select({
|
||||
invoiceId: faturas.id,
|
||||
cardId: cartoes.id,
|
||||
cardName: cartoes.name,
|
||||
cardLogo: cartoes.logo,
|
||||
dueDay: cartoes.dueDay,
|
||||
period: faturas.period,
|
||||
totalAmount: sql<number | null>`
|
||||
COALESCE(
|
||||
(SELECT SUM(${lancamentos.amount})
|
||||
FROM ${lancamentos}
|
||||
WHERE ${lancamentos.cartaoId} = ${cartoes.id}
|
||||
AND ${lancamentos.period} = ${faturas.period}
|
||||
AND ${lancamentos.userId} = ${faturas.userId}),
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(faturas)
|
||||
.innerJoin(cartoes, eq(faturas.cartaoId, cartoes.id))
|
||||
.where(
|
||||
and(
|
||||
eq(faturas.userId, userId),
|
||||
eq(faturas.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING),
|
||||
lt(faturas.period, currentPeriod),
|
||||
),
|
||||
);
|
||||
|
||||
// --- Faturas do período atual ---
|
||||
const currentInvoices = await db
|
||||
.select({
|
||||
invoiceId: faturas.id,
|
||||
cardId: cartoes.id,
|
||||
cardName: cartoes.name,
|
||||
cardLogo: cartoes.logo,
|
||||
dueDay: cartoes.dueDay,
|
||||
period: sql<string>`COALESCE(${faturas.period}, ${currentPeriod})`,
|
||||
paymentStatus: faturas.paymentStatus,
|
||||
totalAmount: sql<number | null>`
|
||||
COALESCE(SUM(${lancamentos.amount}), 0)
|
||||
`,
|
||||
transactionCount: sql<number | null>`COUNT(${lancamentos.id})`,
|
||||
})
|
||||
.from(cartoes)
|
||||
.leftJoin(
|
||||
faturas,
|
||||
and(
|
||||
eq(faturas.cartaoId, cartoes.id),
|
||||
eq(faturas.userId, userId),
|
||||
eq(faturas.period, currentPeriod),
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.cartaoId, cartoes.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, currentPeriod),
|
||||
),
|
||||
)
|
||||
.where(eq(cartoes.userId, userId))
|
||||
.groupBy(
|
||||
faturas.id,
|
||||
cartoes.id,
|
||||
cartoes.name,
|
||||
cartoes.logo,
|
||||
cartoes.dueDay,
|
||||
faturas.period,
|
||||
faturas.paymentStatus,
|
||||
);
|
||||
|
||||
// --- Boletos não pagos ---
|
||||
const boletosConditions = [
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
eq(lancamentos.isSettled, false),
|
||||
];
|
||||
if (adminPagadorId) {
|
||||
boletosConditions.push(eq(lancamentos.pagadorId, adminPagadorId));
|
||||
}
|
||||
|
||||
const boletosRows = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
dueDate: lancamentos.dueDate,
|
||||
period: lancamentos.period,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(and(...boletosConditions));
|
||||
|
||||
// --- Orçamentos do período atual ---
|
||||
const budgetJoinConditions = [
|
||||
eq(lancamentos.categoriaId, orcamentos.categoriaId),
|
||||
eq(lancamentos.userId, orcamentos.userId),
|
||||
eq(lancamentos.period, orcamentos.period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
ne(lancamentos.condition, "cancelado"),
|
||||
];
|
||||
if (adminPagadorId) {
|
||||
budgetJoinConditions.push(eq(lancamentos.pagadorId, adminPagadorId));
|
||||
}
|
||||
|
||||
const budgetRows = await db
|
||||
.select({
|
||||
orcamentoId: orcamentos.id,
|
||||
budgetAmount: orcamentos.amount,
|
||||
categoriaName: categorias.name,
|
||||
spentAmount: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`,
|
||||
})
|
||||
.from(orcamentos)
|
||||
.innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id))
|
||||
.leftJoin(lancamentos, and(...budgetJoinConditions))
|
||||
.where(
|
||||
and(eq(orcamentos.userId, userId), eq(orcamentos.period, currentPeriod)),
|
||||
)
|
||||
.groupBy(orcamentos.id, orcamentos.amount, categorias.name);
|
||||
|
||||
// =====================
|
||||
// Processar notificações
|
||||
// =====================
|
||||
|
||||
const notifications: DashboardNotification[] = [];
|
||||
|
||||
// Faturas atrasadas (períodos anteriores)
|
||||
for (const invoice of overdueInvoices) {
|
||||
if (!invoice.period || !invoice.dueDay) continue;
|
||||
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}`;
|
||||
|
||||
notifications.push({
|
||||
id: notificationId,
|
||||
type: "invoice",
|
||||
name: invoice.cardName,
|
||||
dueDate,
|
||||
status: "overdue",
|
||||
amount: Math.abs(amount),
|
||||
period: invoice.period,
|
||||
showAmount: true,
|
||||
cardLogo: invoice.cardLogo,
|
||||
});
|
||||
}
|
||||
|
||||
// Faturas do período atual
|
||||
for (const invoice of currentInvoices) {
|
||||
if (!invoice.period || !invoice.dueDay) continue;
|
||||
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;
|
||||
|
||||
const shouldInclude =
|
||||
transactionCount > 0 ||
|
||||
Math.abs(amount) > 0 ||
|
||||
invoice.invoiceId !== null;
|
||||
if (!shouldInclude) continue;
|
||||
if (paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
|
||||
|
||||
const invoiceIsOverdue = isDateOnlyPast(dueDate, today);
|
||||
const invoiceIsDueSoon = isDateOnlyWithinDays(
|
||||
dueDate,
|
||||
DAYS_THRESHOLD,
|
||||
today,
|
||||
);
|
||||
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
|
||||
|
||||
const notificationId = invoice.invoiceId
|
||||
? `invoice-${invoice.invoiceId}`
|
||||
: `invoice-${invoice.cardId}-${invoice.period}`;
|
||||
|
||||
notifications.push({
|
||||
id: notificationId,
|
||||
type: "invoice",
|
||||
name: invoice.cardName,
|
||||
dueDate,
|
||||
status: invoiceIsOverdue ? "overdue" : "due_soon",
|
||||
amount: Math.abs(amount),
|
||||
period: invoice.period,
|
||||
showAmount: invoiceIsOverdue,
|
||||
cardLogo: invoice.cardLogo,
|
||||
});
|
||||
}
|
||||
|
||||
// Boletos
|
||||
for (const boleto of boletosRows) {
|
||||
const dueDate = toDateOnlyString(boleto.dueDate);
|
||||
if (!dueDate) continue;
|
||||
|
||||
const boletoIsOverdue = isDateOnlyPast(dueDate, today);
|
||||
const boletoIsDueSoon = isDateOnlyWithinDays(
|
||||
dueDate,
|
||||
DAYS_THRESHOLD,
|
||||
today,
|
||||
);
|
||||
const isOldPeriod = boleto.period < currentPeriod;
|
||||
const isCurrentPeriod = boleto.period === currentPeriod;
|
||||
const amount = toNumber(boleto.amount);
|
||||
|
||||
if (isOldPeriod) {
|
||||
notifications.push({
|
||||
id: `boleto-${boleto.id}`,
|
||||
type: "boleto",
|
||||
name: boleto.name,
|
||||
dueDate,
|
||||
status: "overdue",
|
||||
amount: Math.abs(amount),
|
||||
period: boleto.period,
|
||||
showAmount: true,
|
||||
});
|
||||
} else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) {
|
||||
notifications.push({
|
||||
id: `boleto-${boleto.id}`,
|
||||
type: "boleto",
|
||||
name: boleto.name,
|
||||
dueDate,
|
||||
status: boletoIsOverdue ? "overdue" : "due_soon",
|
||||
amount: Math.abs(amount),
|
||||
period: boleto.period,
|
||||
showAmount: boletoIsOverdue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ordenar: atrasados primeiro, depois por data de vencimento
|
||||
notifications.sort((a, b) => {
|
||||
if (a.status === "overdue" && b.status !== "overdue") return -1;
|
||||
if (a.status !== "overdue" && b.status === "overdue") return 1;
|
||||
return a.dueDate.localeCompare(b.dueDate);
|
||||
});
|
||||
|
||||
// Orçamentos excedidos e críticos
|
||||
const budgetNotifications: BudgetNotification[] = [];
|
||||
|
||||
for (const row of budgetRows) {
|
||||
const budgetAmount = toNumber(row.budgetAmount);
|
||||
const spentAmount = toNumber(row.spentAmount);
|
||||
if (budgetAmount <= 0) continue;
|
||||
|
||||
const usedPercentage = (spentAmount / budgetAmount) * 100;
|
||||
if (usedPercentage < BUDGET_CRITICAL_THRESHOLD) continue;
|
||||
|
||||
budgetNotifications.push({
|
||||
id: `budget-${row.orcamentoId}`,
|
||||
categoryName: row.categoriaName,
|
||||
budgetAmount,
|
||||
spentAmount,
|
||||
usedPercentage,
|
||||
status: usedPercentage >= 100 ? "exceeded" : "critical",
|
||||
});
|
||||
}
|
||||
|
||||
// Excedidos primeiro, depois por percentual decrescente
|
||||
budgetNotifications.sort((a, b) => {
|
||||
if (a.status === "exceeded" && b.status !== "exceeded") return -1;
|
||||
if (a.status !== "exceeded" && b.status === "exceeded") return 1;
|
||||
return b.usedPercentage - a.usedPercentage;
|
||||
});
|
||||
|
||||
return {
|
||||
notifications,
|
||||
totalCount: notifications.length,
|
||||
budgetNotifications,
|
||||
};
|
||||
}
|
||||
125
src/features/dashboard/payers-queries.ts
Normal file
125
src/features/dashboard/payers-queries.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { lancamentos, pagadores } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||
import { calculatePercentageChange } from "@/shared/utils/math";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
import { getPreviousPeriod } from "@/shared/utils/period";
|
||||
|
||||
export type DashboardPagador = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
totalExpenses: number;
|
||||
previousExpenses: number;
|
||||
percentageChange: number | null;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
export type DashboardPagadoresSnapshot = {
|
||||
pagadores: DashboardPagador[];
|
||||
totalExpenses: number;
|
||||
};
|
||||
|
||||
export async function fetchDashboardPagadores(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<DashboardPagadoresSnapshot> {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: pagadores.id,
|
||||
name: pagadores.name,
|
||||
email: pagadores.email,
|
||||
avatarUrl: pagadores.avatarUrl,
|
||||
role: pagadores.role,
|
||||
period: lancamentos.period,
|
||||
totalExpenses: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
inArray(lancamentos.period, [period, previousPeriod]),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
pagadores.id,
|
||||
pagadores.name,
|
||||
pagadores.email,
|
||||
pagadores.avatarUrl,
|
||||
pagadores.role,
|
||||
lancamentos.period,
|
||||
)
|
||||
.orderBy(desc(sql`SUM(ABS(${lancamentos.amount}))`));
|
||||
|
||||
const groupedPagadores = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
isAdmin: boolean;
|
||||
currentExpenses: number;
|
||||
previousExpenses: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const entry = groupedPagadores.get(row.id) ?? {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
avatarUrl: row.avatarUrl,
|
||||
isAdmin: row.role === PAGADOR_ROLE_ADMIN,
|
||||
currentExpenses: 0,
|
||||
previousExpenses: 0,
|
||||
};
|
||||
|
||||
const amount = toNumber(row.totalExpenses);
|
||||
if (row.period === period) {
|
||||
entry.currentExpenses = amount;
|
||||
} else {
|
||||
entry.previousExpenses = amount;
|
||||
}
|
||||
|
||||
groupedPagadores.set(row.id, entry);
|
||||
}
|
||||
|
||||
const pagadoresList = Array.from(groupedPagadores.values())
|
||||
.filter((p) => p.currentExpenses > 0)
|
||||
.map((pagador) => ({
|
||||
id: pagador.id,
|
||||
name: pagador.name,
|
||||
email: pagador.email,
|
||||
avatarUrl: pagador.avatarUrl,
|
||||
totalExpenses: pagador.currentExpenses,
|
||||
previousExpenses: pagador.previousExpenses,
|
||||
percentageChange: calculatePercentageChange(
|
||||
pagador.currentExpenses,
|
||||
pagador.previousExpenses,
|
||||
),
|
||||
isAdmin: pagador.isAdmin,
|
||||
}))
|
||||
.sort((a, b) => b.totalExpenses - a.totalExpenses);
|
||||
|
||||
const totalExpenses = pagadoresList.reduce(
|
||||
(sum, p) => sum + p.totalExpenses,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
pagadores: pagadoresList,
|
||||
totalExpenses,
|
||||
};
|
||||
}
|
||||
10
src/features/dashboard/payment-breakdown-formatters.ts
Normal file
10
src/features/dashboard/payment-breakdown-formatters.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { formatPercentage } from "@/shared/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
src/features/dashboard/payment-overview-tabs.ts
Normal file
11
src/features/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;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { lancamentos } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminPeriodFilters,
|
||||
excludeAutoGeneratedEntryNotes,
|
||||
} from "@/features/dashboard/lancamento-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type PaymentConditionSummary = {
|
||||
condition: string;
|
||||
amount: number;
|
||||
percentage: number;
|
||||
transactions: number;
|
||||
};
|
||||
|
||||
export type PaymentConditionsData = {
|
||||
conditions: PaymentConditionSummary[];
|
||||
};
|
||||
|
||||
export async function fetchPaymentConditions(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<PaymentConditionsData> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { conditions: [] };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
condition: lancamentos.condition,
|
||||
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
transactions: sql<number>`count(${lancamentos.id})`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminPeriodFilters({
|
||||
userId,
|
||||
period,
|
||||
adminPagadorId,
|
||||
}),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
excludeAutoGeneratedEntryNotes(),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.condition);
|
||||
|
||||
const summaries = rows.map((row: (typeof rows)[number]) => {
|
||||
const totalAmount = Math.abs(toNumber(row.totalAmount));
|
||||
const transactions = Number(row.transactions ?? 0);
|
||||
|
||||
return {
|
||||
condition: row.condition,
|
||||
amount: totalAmount,
|
||||
transactions,
|
||||
};
|
||||
});
|
||||
|
||||
const overallTotal = summaries.reduce(
|
||||
(acc: number, item: (typeof summaries)[number]) => acc + item.amount,
|
||||
0,
|
||||
);
|
||||
|
||||
const conditions = summaries
|
||||
.map((item: (typeof summaries)[number]) => ({
|
||||
condition: item.condition,
|
||||
amount: item.amount,
|
||||
transactions: item.transactions,
|
||||
percentage:
|
||||
overallTotal > 0
|
||||
? Number(((item.amount / overallTotal) * 100).toFixed(2))
|
||||
: 0,
|
||||
}))
|
||||
.sort(
|
||||
(a: (typeof summaries)[number], b: (typeof summaries)[number]) =>
|
||||
b.amount - a.amount,
|
||||
);
|
||||
|
||||
return {
|
||||
conditions,
|
||||
};
|
||||
}
|
||||
85
src/features/dashboard/payments/payment-methods-queries.ts
Normal file
85
src/features/dashboard/payments/payment-methods-queries.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { lancamentos } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminPeriodFilters,
|
||||
excludeAutoGeneratedEntryNotes,
|
||||
} from "@/features/dashboard/lancamento-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type PaymentMethodSummary = {
|
||||
paymentMethod: string;
|
||||
amount: number;
|
||||
percentage: number;
|
||||
transactions: number;
|
||||
};
|
||||
|
||||
export type PaymentMethodsData = {
|
||||
methods: PaymentMethodSummary[];
|
||||
};
|
||||
|
||||
export async function fetchPaymentMethods(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<PaymentMethodsData> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { methods: [] };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
paymentMethod: lancamentos.paymentMethod,
|
||||
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
transactions: sql<number>`count(${lancamentos.id})`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminPeriodFilters({
|
||||
userId,
|
||||
period,
|
||||
adminPagadorId,
|
||||
}),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
excludeAutoGeneratedEntryNotes(),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.paymentMethod);
|
||||
|
||||
const summaries = rows.map((row: (typeof rows)[number]) => {
|
||||
const amount = Math.abs(toNumber(row.totalAmount));
|
||||
const transactions = Number(row.transactions ?? 0);
|
||||
|
||||
return {
|
||||
paymentMethod: row.paymentMethod,
|
||||
amount,
|
||||
transactions,
|
||||
};
|
||||
});
|
||||
|
||||
const overallTotal = summaries.reduce(
|
||||
(acc: number, item: (typeof summaries)[number]) => acc + item.amount,
|
||||
0,
|
||||
);
|
||||
|
||||
const methods = summaries
|
||||
.map((item: (typeof summaries)[number]) => ({
|
||||
paymentMethod: item.paymentMethod,
|
||||
amount: item.amount,
|
||||
transactions: item.transactions,
|
||||
percentage:
|
||||
overallTotal > 0
|
||||
? Number(((item.amount / overallTotal) * 100).toFixed(2))
|
||||
: 0,
|
||||
}))
|
||||
.sort(
|
||||
(a: (typeof summaries)[number], b: (typeof summaries)[number]) =>
|
||||
b.amount - a.amount,
|
||||
);
|
||||
|
||||
return {
|
||||
methods,
|
||||
};
|
||||
}
|
||||
87
src/features/dashboard/payments/payment-status-queries.ts
Normal file
87
src/features/dashboard/payments/payment-status-queries.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { and, inArray, sql } from "drizzle-orm";
|
||||
import { lancamentos } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminPeriodFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
} from "@/features/dashboard/lancamento-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type PaymentStatusCategory = {
|
||||
total: number;
|
||||
confirmed: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
export type PaymentStatusData = {
|
||||
income: PaymentStatusCategory;
|
||||
expenses: PaymentStatusCategory;
|
||||
};
|
||||
|
||||
const emptyCategory = (): PaymentStatusCategory => ({
|
||||
total: 0,
|
||||
confirmed: 0,
|
||||
pending: 0,
|
||||
});
|
||||
|
||||
export async function fetchPaymentStatus(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<PaymentStatusData> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { income: emptyCategory(), expenses: emptyCategory() };
|
||||
}
|
||||
|
||||
// Single query: GROUP BY transactionType instead of 2 separate queries
|
||||
const rows = await db
|
||||
.select({
|
||||
transactionType: lancamentos.transactionType,
|
||||
confirmed: sql<number>`
|
||||
coalesce(
|
||||
sum(case when ${lancamentos.isSettled} = true then ${lancamentos.amount} else 0 end),
|
||||
0
|
||||
)
|
||||
`,
|
||||
pending: sql<number>`
|
||||
coalesce(
|
||||
sum(case when ${lancamentos.isSettled} = false or ${lancamentos.isSettled} is null then ${lancamentos.amount} else 0 end),
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminPeriodFilters({
|
||||
userId,
|
||||
period,
|
||||
adminPagadorId,
|
||||
}),
|
||||
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
|
||||
excludeAutoInvoiceEntries(),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.transactionType);
|
||||
|
||||
const result = { income: emptyCategory(), expenses: emptyCategory() };
|
||||
|
||||
for (const row of rows) {
|
||||
const confirmed = toNumber(row.confirmed);
|
||||
const pending = toNumber(row.pending);
|
||||
const category = {
|
||||
total: confirmed + pending,
|
||||
confirmed,
|
||||
pending,
|
||||
};
|
||||
|
||||
if (row.transactionType === "Receita") {
|
||||
result.income = category;
|
||||
} else if (row.transactionType === "Despesa") {
|
||||
result.expenses = category;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
23
src/features/dashboard/preferences-queries.ts
Normal file
23
src/features/dashboard/preferences-queries.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { WidgetPreferences } from "@/features/dashboard/widgets/actions";
|
||||
import { db, schema } from "@/shared/lib/db";
|
||||
|
||||
export interface UserDashboardPreferences {
|
||||
dashboardWidgets: WidgetPreferences | null;
|
||||
}
|
||||
|
||||
export async function fetchUserDashboardPreferences(
|
||||
userId: string,
|
||||
): Promise<UserDashboardPreferences> {
|
||||
const result = await db
|
||||
.select({
|
||||
dashboardWidgets: schema.preferenciasUsuario.dashboardWidgets,
|
||||
})
|
||||
.from(schema.preferenciasUsuario)
|
||||
.where(eq(schema.preferenciasUsuario.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return {
|
||||
dashboardWidgets: result[0]?.dashboardWidgets ?? null,
|
||||
};
|
||||
}
|
||||
137
src/features/dashboard/purchases-by-category-queries.ts
Normal file
137
src/features/dashboard/purchases-by-category-queries.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { cartoes, categorias, contas, lancamentos } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminPeriodFilters,
|
||||
excludeAutoGeneratedEntryNotes,
|
||||
} from "@/features/dashboard/lancamento-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type CategoryOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type CategoryTransaction = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
purchaseDate: Date;
|
||||
logo: string | null;
|
||||
};
|
||||
|
||||
export type PurchasesByCategoryData = {
|
||||
categories: CategoryOption[];
|
||||
transactionsByCategory: Record<string, CategoryTransaction[]>;
|
||||
};
|
||||
|
||||
const shouldIncludeTransaction = (name: string) => {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
|
||||
if (normalized === "saldo inicial") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.includes("fatura")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export async function fetchPurchasesByCategory(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<PurchasesByCategoryData> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { categories: [], transactionsByCategory: {} };
|
||||
}
|
||||
|
||||
const transactionsRows = await db
|
||||
.select({
|
||||
id: lancamentos.id,
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
purchaseDate: lancamentos.purchaseDate,
|
||||
categoryId: lancamentos.categoriaId,
|
||||
categoryName: categorias.name,
|
||||
categoryType: categorias.type,
|
||||
cardLogo: cartoes.logo,
|
||||
accountLogo: contas.logo,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminPeriodFilters({
|
||||
userId,
|
||||
period,
|
||||
adminPagadorId,
|
||||
}),
|
||||
inArray(categorias.type, ["despesa", "receita"]),
|
||||
excludeAutoGeneratedEntryNotes(),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate));
|
||||
|
||||
const transactionsByCategory: Record<string, CategoryTransaction[]> = {};
|
||||
const categoriesMap = new Map<string, CategoryOption>();
|
||||
|
||||
for (const row of transactionsRows) {
|
||||
const categoryId = row.categoryId;
|
||||
|
||||
if (!categoryId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!shouldIncludeTransaction(row.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Adiciona a categoria ao mapa se ainda não existir
|
||||
if (!categoriesMap.has(categoryId)) {
|
||||
categoriesMap.set(categoryId, {
|
||||
id: categoryId,
|
||||
name: row.categoryName,
|
||||
type: row.categoryType,
|
||||
});
|
||||
}
|
||||
|
||||
const entry: CategoryTransaction = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
purchaseDate: row.purchaseDate,
|
||||
logo: row.cardLogo ?? row.accountLogo ?? null,
|
||||
};
|
||||
|
||||
if (!transactionsByCategory[categoryId]) {
|
||||
transactionsByCategory[categoryId] = [];
|
||||
}
|
||||
|
||||
const categoryTransactions = transactionsByCategory[categoryId];
|
||||
if (categoryTransactions && categoryTransactions.length < 10) {
|
||||
categoryTransactions.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Ordena as categorias: receitas primeiro, depois despesas (alfabeticamente dentro de cada tipo)
|
||||
const categories = Array.from(categoriesMap.values()).sort((a, b) => {
|
||||
// Receita vem antes de despesa
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "receita" ? -1 : 1;
|
||||
}
|
||||
// Dentro do mesmo tipo, ordena alfabeticamente
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return {
|
||||
categories,
|
||||
transactionsByCategory,
|
||||
};
|
||||
}
|
||||
100
src/features/dashboard/recurring/recurring-series-queries.ts
Normal file
100
src/features/dashboard/recurring/recurring-series-queries.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { RecurringSeriesTemplate } from "@/db/schema";
|
||||
import { categorias, recurringSeries } from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
import { addMonthsToPeriod } from "@/shared/utils/period";
|
||||
|
||||
export type RecurringSeriesItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
categoryName: string | null;
|
||||
categoryIcon: string | null;
|
||||
paymentMethod: string;
|
||||
dayOfMonth: number;
|
||||
status: "active" | "paused" | "cancelled";
|
||||
nextPeriod: string;
|
||||
lastGeneratedPeriod: string;
|
||||
};
|
||||
|
||||
export type RecurringSeriesData = {
|
||||
series: RecurringSeriesItem[];
|
||||
};
|
||||
|
||||
export async function fetchRecurringSeries(
|
||||
userId: string,
|
||||
): Promise<RecurringSeriesData> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: recurringSeries.id,
|
||||
status: recurringSeries.status,
|
||||
dayOfMonth: recurringSeries.dayOfMonth,
|
||||
lastGeneratedPeriod: recurringSeries.lastGeneratedPeriod,
|
||||
templateData: recurringSeries.templateData,
|
||||
})
|
||||
.from(recurringSeries)
|
||||
.where(
|
||||
and(
|
||||
eq(recurringSeries.userId, userId),
|
||||
inArray(recurringSeries.status, ["active", "paused"]),
|
||||
),
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { series: [] };
|
||||
}
|
||||
|
||||
// Fetch category names for all series in one query
|
||||
const categoryIds = rows
|
||||
.map((r) => (r.templateData as RecurringSeriesTemplate).categoriaId)
|
||||
.filter((id): id is string => id !== null);
|
||||
|
||||
const categoryMap = new Map<string, { name: string; icon: string | null }>();
|
||||
if (categoryIds.length > 0) {
|
||||
const cats = await db
|
||||
.select({
|
||||
id: categorias.id,
|
||||
name: categorias.name,
|
||||
icon: categorias.icon,
|
||||
})
|
||||
.from(categorias)
|
||||
.where(inArray(categorias.id, categoryIds));
|
||||
for (const cat of cats) {
|
||||
categoryMap.set(cat.id, { name: cat.name, icon: cat.icon });
|
||||
}
|
||||
}
|
||||
|
||||
const series = rows
|
||||
.filter((row) => {
|
||||
// If admin pagador exists, only show series belonging to admin
|
||||
if (!adminPagadorId) return true;
|
||||
const template = row.templateData as RecurringSeriesTemplate;
|
||||
return (
|
||||
template.pagadorId === adminPagadorId || template.pagadorId === null
|
||||
);
|
||||
})
|
||||
.map((row): RecurringSeriesItem => {
|
||||
const template = row.templateData as RecurringSeriesTemplate;
|
||||
const category = template.categoriaId
|
||||
? categoryMap.get(template.categoriaId)
|
||||
: null;
|
||||
return {
|
||||
id: row.id,
|
||||
name: template.name,
|
||||
amount: Math.abs(toNumber(template.amount)),
|
||||
categoryName: category?.name ?? null,
|
||||
categoryIcon: category?.icon ?? null,
|
||||
paymentMethod: template.paymentMethod,
|
||||
dayOfMonth: row.dayOfMonth,
|
||||
status: row.status as "active" | "paused",
|
||||
nextPeriod: addMonthsToPeriod(row.lastGeneratedPeriod, 1),
|
||||
lastGeneratedPeriod: row.lastGeneratedPeriod,
|
||||
};
|
||||
});
|
||||
|
||||
return { series };
|
||||
}
|
||||
91
src/features/dashboard/top-establishments-queries.ts
Normal file
91
src/features/dashboard/top-establishments-queries.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { cartoes, contas, lancamentos } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminPeriodFilters,
|
||||
excludeAutoGeneratedEntryNotes,
|
||||
} from "@/features/dashboard/lancamento-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPagadorId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type TopEstablishment = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
occurrences: number;
|
||||
logo: string | null;
|
||||
};
|
||||
|
||||
export type TopEstablishmentsData = {
|
||||
establishments: TopEstablishment[];
|
||||
};
|
||||
|
||||
const shouldIncludeEstablishment = (name: string) => {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
|
||||
if (normalized === "saldo inicial") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.includes("fatura")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export async function fetchTopEstablishments(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<TopEstablishmentsData> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
if (!adminPagadorId) {
|
||||
return { establishments: [] };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
name: lancamentos.name,
|
||||
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
occurrences: sql<number>`count(${lancamentos.id})`,
|
||||
logo: sql<string | null>`max(coalesce(${cartoes.logo}, ${contas.logo}))`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminPeriodFilters({
|
||||
userId,
|
||||
period,
|
||||
adminPagadorId,
|
||||
}),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
excludeAutoGeneratedEntryNotes(),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.name)
|
||||
.orderBy(
|
||||
sql`count(${lancamentos.id}) DESC`,
|
||||
sql`ABS(sum(${lancamentos.amount})) DESC`,
|
||||
)
|
||||
.limit(10);
|
||||
|
||||
const establishments = rows
|
||||
.filter((row: (typeof rows)[number]) =>
|
||||
shouldIncludeEstablishment(row.name),
|
||||
)
|
||||
.map(
|
||||
(row: (typeof rows)[number]): TopEstablishment => ({
|
||||
id: row.name,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.totalAmount)),
|
||||
occurrences: Number(row.occurrences ?? 0),
|
||||
logo: row.logo ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
establishments,
|
||||
};
|
||||
}
|
||||
46
src/features/dashboard/use-bill-widget-controller.ts
Normal file
46
src/features/dashboard/use-bill-widget-controller.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type BillDialogState,
|
||||
getCurrentBillDateString,
|
||||
markBillAsSettled,
|
||||
} from "@/features/dashboard/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import {
|
||||
type PaymentDialogController,
|
||||
usePaymentDialogController,
|
||||
} from "@/features/dashboard/use-payment-dialog-controller";
|
||||
import { toggleLancamentoSettlementAction } from "@/features/transactions/actions";
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type {
|
||||
Budget,
|
||||
BudgetCategory,
|
||||
} from "@/features/budgets/components/types";
|
||||
import {
|
||||
mapGoalProgressCategoriesToBudgetCategories,
|
||||
mapGoalProgressItemToBudget,
|
||||
} from "@/features/dashboard/goals-progress-helpers";
|
||||
import type {
|
||||
GoalProgressItem,
|
||||
GoalsProgressData,
|
||||
} from "@/features/dashboard/goals-progress-queries";
|
||||
|
||||
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
src/features/dashboard/use-invoices-widget-controller.ts
Normal file
46
src/features/dashboard/use-invoices-widget-controller.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
getCurrentDateString,
|
||||
type InvoiceDialogState,
|
||||
isInvoicePaid,
|
||||
markInvoiceAsPaid,
|
||||
} from "@/features/dashboard/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import {
|
||||
type PaymentDialogController,
|
||||
usePaymentDialogController,
|
||||
} from "@/features/dashboard/use-payment-dialog-controller";
|
||||
import { updateInvoicePaymentStatusAction } from "@/features/invoices/actions";
|
||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||
|
||||
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
src/features/dashboard/use-notes-widget-controller.ts
Normal file
65
src/features/dashboard/use-notes-widget-controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { mapDashboardNotesToNotes } from "@/features/dashboard/notes-mappers";
|
||||
import type { DashboardNote } from "@/features/dashboard/notes-queries";
|
||||
import type { Note } from "@/features/notes/components/types";
|
||||
|
||||
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
src/features/dashboard/use-payment-dialog-controller.ts
Normal file
110
src/features/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 "@/shared/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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DEFAULT_PAYMENT_OVERVIEW_TAB,
|
||||
type PaymentOverviewTab,
|
||||
parsePaymentOverviewTab,
|
||||
} from "@/features/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,
|
||||
};
|
||||
}
|
||||
70
src/features/dashboard/widgets/actions.ts
Normal file
70
src/features/dashboard/widgets/actions.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { db, schema } from "@/shared/lib/db";
|
||||
|
||||
export type WidgetPreferences = {
|
||||
order: string[];
|
||||
hidden: string[];
|
||||
};
|
||||
|
||||
export async function updateWidgetPreferences(
|
||||
preferences: WidgetPreferences,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
// Check if preferences exist
|
||||
const existing = await db
|
||||
.select({ id: schema.preferenciasUsuario.id })
|
||||
.from(schema.preferenciasUsuario)
|
||||
.where(eq(schema.preferenciasUsuario.userId, user.id))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(schema.preferenciasUsuario)
|
||||
.set({
|
||||
dashboardWidgets: preferences,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.preferenciasUsuario.userId, user.id));
|
||||
} else {
|
||||
await db.insert(schema.preferenciasUsuario).values({
|
||||
userId: user.id,
|
||||
dashboardWidgets: preferences,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error updating widget preferences:", error);
|
||||
return { success: false, error: "Erro ao salvar preferências" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetWidgetPreferences(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
await db
|
||||
.update(schema.preferenciasUsuario)
|
||||
.set({
|
||||
dashboardWidgets: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.preferenciasUsuario.userId, user.id));
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error resetting widget preferences:", error);
|
||||
return { success: false, error: "Erro ao resetar preferências" };
|
||||
}
|
||||
}
|
||||
231
src/features/dashboard/widgets/widgets-config.tsx
Normal file
231
src/features/dashboard/widgets/widgets-config.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
RiArrowRightLine,
|
||||
RiArrowUpDoubleLine,
|
||||
RiBarChartBoxLine,
|
||||
RiBarcodeLine,
|
||||
RiBillLine,
|
||||
RiExchangeLine,
|
||||
RiGroupLine,
|
||||
RiLineChartLine,
|
||||
RiNumbersLine,
|
||||
RiPieChartLine,
|
||||
RiRefreshLine,
|
||||
RiStore3Line,
|
||||
RiTodoLine,
|
||||
RiWallet3Line,
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { BillWidget } from "@/features/dashboard/components/bill-widget";
|
||||
import { ExpensesByCategoryWidgetWithChart } from "@/features/dashboard/components/expenses-by-category-widget-with-chart";
|
||||
import { GoalsProgressWidget } from "@/features/dashboard/components/goals-progress-widget";
|
||||
import { IncomeByCategoryWidgetWithChart } from "@/features/dashboard/components/income-by-category-widget-with-chart";
|
||||
import { IncomeExpenseBalanceWidget } from "@/features/dashboard/components/income-expense-balance-widget";
|
||||
import { InstallmentExpensesWidget } from "@/features/dashboard/components/installment-expenses-widget";
|
||||
import { InvoicesWidget } from "@/features/dashboard/components/invoices-widget";
|
||||
import { MyAccountsWidget } from "@/features/dashboard/components/my-accounts-widget";
|
||||
import { NotesWidget } from "@/features/dashboard/components/notes-widget";
|
||||
import { PayersWidget } from "@/features/dashboard/components/payers-widget";
|
||||
import { PaymentOverviewWidget } from "@/features/dashboard/components/payment-overview-widget";
|
||||
import { PaymentStatusWidget } from "@/features/dashboard/components/payment-status-widget";
|
||||
import { PurchasesByCategoryWidget } from "@/features/dashboard/components/purchases-by-category-widget";
|
||||
import { RecurringExpensesWidget } from "@/features/dashboard/components/recurring-expenses-widget";
|
||||
import { RecurringSeriesWidget } from "@/features/dashboard/components/recurring-series-widget";
|
||||
import { SpendingOverviewWidget } from "@/features/dashboard/components/spending-overview-widget";
|
||||
import type { DashboardData } from "../fetch-dashboard-data";
|
||||
|
||||
export type WidgetConfig = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: ReactNode;
|
||||
component: (props: { data: DashboardData; period: string }) => ReactNode;
|
||||
action?: ReactNode;
|
||||
};
|
||||
|
||||
export const widgetsConfig: WidgetConfig[] = [
|
||||
{
|
||||
id: "my-accounts",
|
||||
title: "Minhas Contas",
|
||||
subtitle: "Saldo consolidado disponível",
|
||||
icon: <RiBarChartBoxLine className="size-4" />,
|
||||
component: ({ data, period }) => (
|
||||
<MyAccountsWidget
|
||||
accounts={data.accountsSnapshot.accounts}
|
||||
totalBalance={data.accountsSnapshot.totalBalance}
|
||||
period={period}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "invoices",
|
||||
title: "Faturas",
|
||||
subtitle: "Resumo das faturas do período",
|
||||
icon: <RiBillLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<InvoicesWidget invoices={data.invoicesSnapshot.invoices} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "boletos",
|
||||
title: "Boletos",
|
||||
subtitle: "Controle de boletos do período",
|
||||
icon: <RiBarcodeLine className="size-4" />,
|
||||
component: ({ data }) => <BillWidget bills={data.billsSnapshot.bills} />,
|
||||
},
|
||||
{
|
||||
id: "payment-status",
|
||||
title: "Status de Pagamento",
|
||||
subtitle: "Valores Confirmados E Pendentes",
|
||||
icon: <RiWallet3Line className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<PaymentStatusWidget data={data.paymentStatusData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "income-expense-balance",
|
||||
title: "Receita, Despesa e Balanço",
|
||||
subtitle: "Últimos 6 Meses",
|
||||
icon: <RiLineChartLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<IncomeExpenseBalanceWidget data={data.incomeExpenseBalanceData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "pagadores",
|
||||
title: "Pagadores",
|
||||
subtitle: "Despesas por pagador no período",
|
||||
icon: <RiGroupLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<PayersWidget pagadores={data.pagadoresSnapshot.pagadores} />
|
||||
),
|
||||
action: (
|
||||
<Link
|
||||
href="/payers"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Ver todos
|
||||
<RiArrowRightLine className="size-4" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "notes",
|
||||
title: "Anotações",
|
||||
subtitle: "Últimas anotações ativas",
|
||||
icon: <RiTodoLine className="size-4" />,
|
||||
component: ({ data }) => <NotesWidget notes={data.notesData} />,
|
||||
action: (
|
||||
<Link
|
||||
href="/notes"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Ver todas
|
||||
<RiArrowRightLine className="size-4" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "goals-progress",
|
||||
title: "Progresso de Orçamentos",
|
||||
subtitle: "Orçamentos por categoria no período",
|
||||
icon: <RiExchangeLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<GoalsProgressWidget data={data.goalsProgressData} />
|
||||
),
|
||||
action: (
|
||||
<Link
|
||||
href="/budgets"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Ver todos
|
||||
<RiArrowRightLine className="size-4" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "payment-overview",
|
||||
title: "Comportamento de Pagamento",
|
||||
subtitle: "Despesas por condição e forma de pagamento",
|
||||
icon: <RiWallet3Line className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<PaymentOverviewWidget
|
||||
paymentConditionsData={data.paymentConditionsData}
|
||||
paymentMethodsData={data.paymentMethodsData}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "recurring-expenses",
|
||||
title: "Lançamentos Recorrentes",
|
||||
subtitle: "Despesas recorrentes do período",
|
||||
icon: <RiRefreshLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<RecurringExpensesWidget data={data.recurringExpensesData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "recurring-series",
|
||||
title: "Séries Recorrentes",
|
||||
subtitle: "Gerencie seus lançamentos recorrentes",
|
||||
icon: <RiRefreshLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<RecurringSeriesWidget data={data.recurringSeriesData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "installment-expenses",
|
||||
title: "Lançamentos Parcelados",
|
||||
subtitle: "Acompanhe as parcelas abertas",
|
||||
icon: <RiNumbersLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<InstallmentExpensesWidget data={data.installmentExpensesData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "spending-overview",
|
||||
title: "Panorama de Gastos",
|
||||
subtitle: "Principais despesas e frequência por local",
|
||||
icon: <RiArrowUpDoubleLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<SpendingOverviewWidget
|
||||
topExpensesAll={data.topExpensesAll}
|
||||
topExpensesCardOnly={data.topExpensesCardOnly}
|
||||
topEstablishmentsData={data.topEstablishmentsData}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "purchases-by-category",
|
||||
title: "Lançamentos por Categorias",
|
||||
subtitle: "Distribuição de lançamentos por categoria",
|
||||
icon: <RiStore3Line className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<PurchasesByCategoryWidget data={data.purchasesByCategoryData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "income-by-category",
|
||||
title: "Categorias por Receitas",
|
||||
subtitle: "Distribuição de receitas por categoria",
|
||||
icon: <RiPieChartLine className="size-4" />,
|
||||
component: ({ data, period }) => (
|
||||
<IncomeByCategoryWidgetWithChart
|
||||
data={data.incomeByCategoryData}
|
||||
period={period}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "expenses-by-category",
|
||||
title: "Categorias por Despesas",
|
||||
subtitle: "Distribuição de despesas por categoria",
|
||||
icon: <RiPieChartLine className="size-4" />,
|
||||
component: ({ data, period }) => (
|
||||
<ExpensesByCategoryWidgetWithChart
|
||||
data={data.expensesByCategoryData}
|
||||
period={period}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user