mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 02:51:46 +00:00
refactor(dashboard): reorganizar módulos em subdiretórios e nova arquitetura de widgets
Arquivos de queries, helpers e controllers dispersos na raiz de dashboard/ foram movidos para subdiretórios temáticos (bills/, invoices/, notes/, notifications/, overview/, payments/, goals-progress/, categories/). ~25 widgets monolíticos obsoletos removidos em favor de nova arquitetura baseada em widget-registry com components/widgets/. Novos componentes: category-breakdown-chart/list, goals-progress-item, percentage-change-indicator. Imports atualizados em fetch-dashboard-data e transaction-filters limpos. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,7 @@ export type DashboardAccount = {
|
||||
excludeFromBalance: boolean;
|
||||
};
|
||||
|
||||
export type DashboardAccountsSnapshot = {
|
||||
type DashboardAccountsSnapshot = {
|
||||
totalBalance: number;
|
||||
accounts: DashboardAccount[];
|
||||
};
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { transactions } from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import {
|
||||
compareDateOnly,
|
||||
getBusinessDateString,
|
||||
isDateOnlyPast,
|
||||
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;
|
||||
};
|
||||
|
||||
const compareDateOnlyAscWithNullsLast = (
|
||||
left: string | null,
|
||||
right: string | null,
|
||||
) => {
|
||||
if (!left && !right) return 0;
|
||||
if (!left) return 1;
|
||||
if (!right) return -1;
|
||||
return compareDateOnly(left, right);
|
||||
};
|
||||
|
||||
const compareDateOnlyDescWithNullsLast = (
|
||||
left: string | null,
|
||||
right: string | null,
|
||||
) => {
|
||||
if (!left && !right) return 0;
|
||||
if (!left) return 1;
|
||||
if (!right) return -1;
|
||||
return compareDateOnly(right, left);
|
||||
};
|
||||
|
||||
export async function fetchDashboardBills(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<DashboardBillsSnapshot> {
|
||||
const today = getBusinessDateString();
|
||||
const adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) {
|
||||
return { bills: [], totalPendingAmount: 0, pendingCount: 0 };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: transactions.id,
|
||||
name: transactions.name,
|
||||
amount: transactions.amount,
|
||||
dueDate: transactions.dueDate,
|
||||
boletoPaymentDate: transactions.boletoPaymentDate,
|
||||
isSettled: transactions.isSettled,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.period, period),
|
||||
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
eq(transactions.payerId, adminPayerId),
|
||||
),
|
||||
);
|
||||
|
||||
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),
|
||||
};
|
||||
});
|
||||
|
||||
bills.sort((a, b) => {
|
||||
if (a.isSettled !== b.isSettled) {
|
||||
return a.isSettled ? 1 : -1;
|
||||
}
|
||||
|
||||
if (!a.isSettled && !b.isSettled) {
|
||||
const aIsOverdue = a.dueDate ? isDateOnlyPast(a.dueDate, today) : false;
|
||||
const bIsOverdue = b.dueDate ? isDateOnlyPast(b.dueDate, today) : false;
|
||||
|
||||
if (aIsOverdue !== bIsOverdue) {
|
||||
return aIsOverdue ? -1 : 1;
|
||||
}
|
||||
|
||||
const dueDateDiff = compareDateOnlyAscWithNullsLast(a.dueDate, b.dueDate);
|
||||
if (dueDateDiff !== 0) {
|
||||
return dueDateDiff;
|
||||
}
|
||||
|
||||
const amountDiff = b.amount - a.amount;
|
||||
if (amountDiff !== 0) {
|
||||
return amountDiff;
|
||||
}
|
||||
}
|
||||
|
||||
if (a.isSettled && b.isSettled) {
|
||||
const paidAtDiff = compareDateOnlyDescWithNullsLast(
|
||||
a.boletoPaymentDate,
|
||||
b.boletoPaymentDate,
|
||||
);
|
||||
if (paidAtDiff !== 0) {
|
||||
return paidAtDiff;
|
||||
}
|
||||
|
||||
const amountDiff = b.amount - a.amount;
|
||||
if (amountDiff !== 0) {
|
||||
return amountDiff;
|
||||
}
|
||||
}
|
||||
|
||||
const nameDiff = a.name.localeCompare(b.name, "pt-BR", {
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (nameDiff !== 0) {
|
||||
return nameDiff;
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
let totalPendingAmount = 0;
|
||||
let pendingCount = 0;
|
||||
|
||||
for (const bill of bills) {
|
||||
if (!bill.isSettled) {
|
||||
totalPendingAmount += bill.amount;
|
||||
pendingCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bills,
|
||||
totalPendingAmount,
|
||||
pendingCount,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import type { PaymentDialogState } from "@/features/dashboard/use-payment-dialog-controller";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
||||
import type { PaymentDialogState } from "@/features/dashboard/payments/use-payment-dialog-controller";
|
||||
import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date";
|
||||
import {
|
||||
buildFinancialStatusLabel,
|
||||
14
src/features/dashboard/bills/bills-queries.ts
Normal file
14
src/features/dashboard/bills/bills-queries.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
};
|
||||
@@ -4,17 +4,17 @@ import {
|
||||
type BillDialogState,
|
||||
getCurrentBillDateString,
|
||||
markBillAsSettled,
|
||||
} from "@/features/dashboard/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
} from "@/features/dashboard/bills/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
||||
import {
|
||||
type PaymentDialogController,
|
||||
usePaymentDialogController,
|
||||
} from "@/features/dashboard/use-payment-dialog-controller";
|
||||
} from "@/features/dashboard/payments/use-payment-dialog-controller";
|
||||
import { toggleTransactionSettlementAction } from "@/features/transactions/actions";
|
||||
|
||||
const EMPTY_BILLS: DashboardBill[] = [];
|
||||
|
||||
export type BillWidgetController = Omit<
|
||||
type BillWidgetController = Omit<
|
||||
PaymentDialogController<DashboardBill>,
|
||||
"selectedItem"
|
||||
> & {
|
||||
@@ -51,7 +51,7 @@ type UniqueCategory = {
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
export async function fetchAllCategories(
|
||||
async function fetchAllCategories(
|
||||
userId: string,
|
||||
): Promise<CategoryOption[]> {
|
||||
const result = await db
|
||||
|
||||
@@ -8,14 +8,14 @@ import {
|
||||
import {
|
||||
buildCategoryBreakdownData,
|
||||
type DashboardCategoryBreakdownData,
|
||||
} from "@/features/dashboard/categories/category-breakdown";
|
||||
} from "@/features/dashboard/categories/category-breakdown-helpers";
|
||||
import type { ExpensesByCategoryData } from "@/features/dashboard/categories/expenses-by-category-queries";
|
||||
import type { IncomeByCategoryData } from "@/features/dashboard/categories/income-by-category-queries";
|
||||
import type {
|
||||
GoalProgressCategory,
|
||||
GoalProgressItem,
|
||||
GoalsProgressData,
|
||||
} from "@/features/dashboard/goals-progress-queries";
|
||||
} from "@/features/dashboard/goals-progress/goals-progress-queries";
|
||||
import {
|
||||
buildDashboardAdminFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
@@ -50,7 +50,7 @@ type BudgetSnapshotRow = {
|
||||
amount: string | number | null;
|
||||
};
|
||||
|
||||
export type DashboardCategoryOverview = {
|
||||
type DashboardCategoryOverview = {
|
||||
goalsProgressData: GoalsProgressData;
|
||||
incomeByCategoryData: IncomeByCategoryData;
|
||||
expensesByCategoryData: ExpensesByCategoryData;
|
||||
@@ -1,82 +1,3 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import {
|
||||
budgets,
|
||||
categories,
|
||||
financialAccounts,
|
||||
transactions,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
buildCategoryBreakdownData,
|
||||
type DashboardCategoryBreakdownData,
|
||||
type DashboardCategoryBreakdownItem,
|
||||
} from "@/features/dashboard/categories/category-breakdown";
|
||||
import {
|
||||
buildDashboardAdminFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
excludeTransactionsFromExcludedAccounts,
|
||||
} from "@/features/dashboard/transaction-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { getPreviousPeriod } from "@/shared/utils/period";
|
||||
import type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown-helpers";
|
||||
|
||||
export type CategoryExpenseItem = DashboardCategoryBreakdownItem;
|
||||
export type ExpensesByCategoryData = DashboardCategoryBreakdownData;
|
||||
|
||||
export async function fetchExpensesByCategory(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<ExpensesByCategoryData> {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
|
||||
const adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) {
|
||||
return { categories: [], currentTotal: 0, previousTotal: 0 };
|
||||
}
|
||||
|
||||
// Single query: GROUP BY categoryId + period for both current and previous periods
|
||||
const [rows, budgetRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
categoryId: categories.id,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
period: transactions.period,
|
||||
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||
})
|
||||
.from(transactions)
|
||||
.innerJoin(categories, eq(transactions.categoryId, categories.id))
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
||||
inArray(transactions.period, [period, previousPeriod]),
|
||||
eq(transactions.transactionType, "Despesa"),
|
||||
eq(categories.type, "despesa"),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
categories.id,
|
||||
categories.name,
|
||||
categories.icon,
|
||||
transactions.period,
|
||||
),
|
||||
db
|
||||
.select({
|
||||
categoryId: budgets.categoryId,
|
||||
amount: budgets.amount,
|
||||
})
|
||||
.from(budgets)
|
||||
.where(and(eq(budgets.userId, userId), eq(budgets.period, period))),
|
||||
]);
|
||||
|
||||
return buildCategoryBreakdownData({
|
||||
rows,
|
||||
budgetRows,
|
||||
period,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,84 +1,3 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import {
|
||||
budgets,
|
||||
categories,
|
||||
financialAccounts,
|
||||
transactions,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
buildCategoryBreakdownData,
|
||||
type DashboardCategoryBreakdownData,
|
||||
type DashboardCategoryBreakdownItem,
|
||||
} from "@/features/dashboard/categories/category-breakdown";
|
||||
import {
|
||||
buildDashboardAdminFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
excludeInitialBalanceWhenConfigured,
|
||||
excludeTransactionsFromExcludedAccounts,
|
||||
} from "@/features/dashboard/transaction-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { getPreviousPeriod } from "@/shared/utils/period";
|
||||
import type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown-helpers";
|
||||
|
||||
export type CategoryIncomeItem = DashboardCategoryBreakdownItem;
|
||||
export type IncomeByCategoryData = DashboardCategoryBreakdownData;
|
||||
|
||||
export async function fetchIncomeByCategory(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<IncomeByCategoryData> {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
|
||||
const adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) {
|
||||
return { categories: [], currentTotal: 0, previousTotal: 0 };
|
||||
}
|
||||
|
||||
// Single query: GROUP BY categoryId + period for both current and previous periods
|
||||
const [rows, budgetRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
categoryId: categories.id,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
period: transactions.period,
|
||||
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||
})
|
||||
.from(transactions)
|
||||
.innerJoin(categories, eq(transactions.categoryId, categories.id))
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
||||
inArray(transactions.period, [period, previousPeriod]),
|
||||
eq(transactions.transactionType, "Receita"),
|
||||
eq(categories.type, "receita"),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeInitialBalanceWhenConfigured(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
categories.id,
|
||||
categories.name,
|
||||
categories.icon,
|
||||
transactions.period,
|
||||
),
|
||||
db
|
||||
.select({
|
||||
categoryId: budgets.categoryId,
|
||||
amount: budgets.amount,
|
||||
})
|
||||
.from(budgets)
|
||||
.where(and(eq(budgets.userId, userId), eq(budgets.period, period))),
|
||||
]);
|
||||
|
||||
return buildCategoryBreakdownData({
|
||||
rows,
|
||||
budgetRows,
|
||||
period,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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[]>;
|
||||
};
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
buildBillStatusLabel,
|
||||
buildBillWidgetStatusLabel,
|
||||
isBillOverdue,
|
||||
} from "@/features/dashboard/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
} from "@/features/dashboard/bills/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
@@ -82,8 +82,8 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
|
||||
onClick={() => onPay(bill.id)}
|
||||
>
|
||||
{bill.isSettled ? (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<RiCheckboxCircleFill className="size-4" /> Pago
|
||||
<span className="flex items-center gap-0.5 text-success">
|
||||
<RiCheckboxCircleFill className="size-3.5" /> Pago
|
||||
</span>
|
||||
) : overdue ? (
|
||||
<span className="overdue-blink">
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
type BillDialogState,
|
||||
formatBillDateLabel,
|
||||
getBillStatusBadgeVariant,
|
||||
} from "@/features/dashboard/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
} from "@/features/dashboard/bills/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { PaymentSuccess } from "@/shared/components/payment-success";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RiBarcodeFill } from "@remixicon/react";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { BillListItem } from "./bill-list-item";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { BillDialogState } from "@/features/dashboard/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import type { BillDialogState } from "@/features/dashboard/bills/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
||||
import { BillPaymentDialog } from "./bill-payment-dialog";
|
||||
import { BillsList } from "./bills-list";
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Pie, PieChart, Tooltip } from "recharts";
|
||||
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
|
||||
import { type ChartConfig, ChartContainer } from "@/shared/components/ui/chart";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
import { formatPercentage as formatPercentageValue } from "@/shared/utils/percentage";
|
||||
|
||||
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 formatPercentage = (value: number, digits: number) =>
|
||||
formatPercentageValue(value, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
type CategoryBreakdownChartProps = {
|
||||
categories: DashboardCategoryBreakdownItem[];
|
||||
percentageDigits: number;
|
||||
};
|
||||
|
||||
export function CategoryBreakdownChart({
|
||||
categories,
|
||||
percentageDigits,
|
||||
}: CategoryBreakdownChartProps) {
|
||||
const chartConfig = useMemo(() => {
|
||||
const nextConfig: ChartConfig = {};
|
||||
|
||||
const topCategories = categories.slice(0, 7);
|
||||
topCategories.forEach((category, index) => {
|
||||
nextConfig[category.categoryId] = {
|
||||
label: category.categoryName,
|
||||
color:
|
||||
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
|
||||
};
|
||||
});
|
||||
|
||||
if (categories.length > 7) {
|
||||
nextConfig.outros = { label: "Outros", color: "var(--chart-6)" };
|
||||
}
|
||||
|
||||
return nextConfig;
|
||||
}, [categories]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (categories.length <= 7) {
|
||||
return categories.map((category) => ({
|
||||
category: category.categoryId,
|
||||
name: category.categoryName,
|
||||
value: category.currentAmount,
|
||||
percentage: category.percentageOfTotal,
|
||||
fill: chartConfig[category.categoryId]?.color,
|
||||
}));
|
||||
}
|
||||
|
||||
const topCategories = categories.slice(0, 7);
|
||||
const otherCategories = categories.slice(7);
|
||||
const otherTotal = otherCategories.reduce(
|
||||
(sum, c) => sum + c.currentAmount,
|
||||
0,
|
||||
);
|
||||
const otherPercentage = otherCategories.reduce(
|
||||
(sum, c) => sum + c.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;
|
||||
}, [categories, chartConfig]);
|
||||
|
||||
return (
|
||||
<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,
|
||||
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-xs uppercase text-muted-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{formatCurrency(entry.value)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatPercentage(entry.percentage, 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { RiExternalLinkLine, RiWallet3Line } from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
|
||||
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
import { formatPercentage as formatPercentageValue } from "@/shared/utils/percentage";
|
||||
|
||||
type CategoryBreakdownListItemConfig = {
|
||||
shareLabel: string;
|
||||
percentageDigits: number;
|
||||
positiveTrend: "up" | "down";
|
||||
includeBudgetAmount: boolean;
|
||||
};
|
||||
|
||||
type CategoryBreakdownListItemProps = {
|
||||
category: DashboardCategoryBreakdownItem;
|
||||
periodParam: string;
|
||||
config: CategoryBreakdownListItemConfig;
|
||||
};
|
||||
|
||||
const formatPercentage = (value: number, digits: number) =>
|
||||
formatPercentageValue(value, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
export function CategoryBreakdownListItem({
|
||||
category,
|
||||
periodParam,
|
||||
config,
|
||||
}: CategoryBreakdownListItemProps) {
|
||||
const hasBudget = category.budgetAmount !== null;
|
||||
const budgetExceeded =
|
||||
hasBudget &&
|
||||
category.budgetUsedPercentage !== null &&
|
||||
category.budgetUsedPercentage > 100;
|
||||
const exceededAmount =
|
||||
budgetExceeded && category.budgetAmount
|
||||
? category.currentAmount - category.budgetAmount
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3 transition-all duration-300 py-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<CategoryIconBadge
|
||||
icon={category.categoryIcon}
|
||||
name={category.categoryName}
|
||||
/>
|
||||
<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 flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatPercentage(
|
||||
category.percentageOfTotal,
|
||||
config.percentageDigits,
|
||||
)}{" "}
|
||||
da {config.shareLabel}
|
||||
</span>
|
||||
{hasBudget && category.budgetUsedPercentage !== null ? (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span
|
||||
className={`flex items-center gap-1 ${budgetExceeded ? "text-destructive" : "text-info"}`}
|
||||
>
|
||||
<RiWallet3Line className="size-3 shrink-0" />
|
||||
{budgetExceeded ? (
|
||||
<>
|
||||
excedeu{" "}
|
||||
<span className="font-medium">
|
||||
{formatCurrency(exceededAmount)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{formatPercentage(
|
||||
category.budgetUsedPercentage,
|
||||
config.percentageDigits,
|
||||
)}{" "}
|
||||
do limite
|
||||
{config.includeBudgetAmount &&
|
||||
category.budgetAmount !== null
|
||||
? ` ${formatCurrency(category.budgetAmount)}`
|
||||
: ""}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||
<MoneyValues
|
||||
className="text-foreground font-medium"
|
||||
amount={category.currentAmount}
|
||||
/>
|
||||
<PercentageChangeIndicator
|
||||
value={category.percentageChange}
|
||||
label={
|
||||
category.percentageChange !== null
|
||||
? formatPercentage(
|
||||
category.percentageChange,
|
||||
config.percentageDigits,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
positiveTrend={config.positiveTrend}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
|
||||
import { CategoryBreakdownListItem } from "./category-breakdown-list-item";
|
||||
|
||||
type CategoryBreakdownListConfig = {
|
||||
shareLabel: string;
|
||||
percentageDigits: number;
|
||||
positiveTrend: "up" | "down";
|
||||
includeBudgetAmount: boolean;
|
||||
};
|
||||
|
||||
type CategoryBreakdownListProps = {
|
||||
categories: DashboardCategoryBreakdownItem[];
|
||||
periodParam: string;
|
||||
config: CategoryBreakdownListConfig;
|
||||
};
|
||||
|
||||
export function CategoryBreakdownList({
|
||||
categories,
|
||||
periodParam,
|
||||
config,
|
||||
}: CategoryBreakdownListProps) {
|
||||
return (
|
||||
<div>
|
||||
{categories.map((category) => (
|
||||
<CategoryBreakdownListItem
|
||||
key={category.categoryId}
|
||||
category={category}
|
||||
periodParam={periodParam}
|
||||
config={config}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,12 @@
|
||||
"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 type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown";
|
||||
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { type ChartConfig, ChartContainer } from "@/shared/components/ui/chart";
|
||||
import { useState } from "react";
|
||||
import type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown-helpers";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -23,9 +14,9 @@ import {
|
||||
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";
|
||||
import { CategoryBreakdownChart } from "./category-breakdown-chart";
|
||||
import { CategoryBreakdownList } from "./category-breakdown-list";
|
||||
|
||||
type CategoryBreakdownVariant = "income" | "expense";
|
||||
|
||||
@@ -35,16 +26,6 @@ type CategoryBreakdownWidgetViewProps = {
|
||||
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",
|
||||
@@ -52,10 +33,7 @@ const VARIANT_CONFIG = {
|
||||
"Quando houver receitas registradas, elas aparecerão aqui.",
|
||||
shareLabel: "receita total",
|
||||
percentageDigits: 1,
|
||||
changeClassName: {
|
||||
increase: "text-success",
|
||||
decrease: "text-destructive",
|
||||
},
|
||||
positiveTrend: "up",
|
||||
includeBudgetAmount: true,
|
||||
},
|
||||
expense: {
|
||||
@@ -64,21 +42,11 @@ const VARIANT_CONFIG = {
|
||||
"Quando houver despesas registradas, elas aparecerão aqui.",
|
||||
shareLabel: "despesa total",
|
||||
percentageDigits: 0,
|
||||
changeClassName: {
|
||||
increase: "text-destructive",
|
||||
decrease: "text-success",
|
||||
},
|
||||
positiveTrend: "down",
|
||||
includeBudgetAmount: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const formatPercentage = (value: number, digits: number) =>
|
||||
formatPercentageValue(value, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
export function CategoryBreakdownWidgetView({
|
||||
data,
|
||||
period,
|
||||
@@ -88,78 +56,6 @@ export function CategoryBreakdownWidgetView({
|
||||
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
|
||||
@@ -178,11 +74,17 @@ export function CategoryBreakdownWidgetView({
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="list" className="text-xs">
|
||||
<TabsTrigger
|
||||
value="list"
|
||||
className="text-xs data-[state=active]:bg-transparent"
|
||||
>
|
||||
<RiListUnordered className="mr-1 size-3.5" />
|
||||
Lista
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chart" className="text-xs">
|
||||
<TabsTrigger
|
||||
value="chart"
|
||||
className="text-xs data-[state=active]:bg-transparent"
|
||||
>
|
||||
<RiPieChart2Line className="mr-1 size-3.5" />
|
||||
Gráfico
|
||||
</TabsTrigger>
|
||||
@@ -190,195 +92,18 @@ export function CategoryBreakdownWidgetView({
|
||||
</div>
|
||||
|
||||
<TabsContent value="list" className="mt-0">
|
||||
<div>
|
||||
{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}>
|
||||
<div className="flex items-center justify-between gap-3 transition-all duration-300 py-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<CategoryIconBadge
|
||||
icon={category.categoryIcon}
|
||||
name={category.categoryName}
|
||||
/>
|
||||
|
||||
<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 flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatPercentage(
|
||||
category.percentageOfTotal,
|
||||
config.percentageDigits,
|
||||
)}{" "}
|
||||
da {config.shareLabel}
|
||||
</span>
|
||||
{hasBudget && category.budgetUsedPercentage !== null ? (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span
|
||||
className={`flex items-center gap-1 ${budgetExceeded ? "text-destructive" : "text-info"}`}
|
||||
>
|
||||
<RiWallet3Line className="size-3 shrink-0" />
|
||||
{budgetExceeded ? (
|
||||
<>
|
||||
excedeu{" "}
|
||||
<span className="font-medium">
|
||||
{formatCurrency(exceededAmount)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{formatPercentage(
|
||||
category.budgetUsedPercentage,
|
||||
config.percentageDigits,
|
||||
)}{" "}
|
||||
do limite
|
||||
{config.includeBudgetAmount &&
|
||||
category.budgetAmount !== null
|
||||
? ` ${formatCurrency(category.budgetAmount)}`
|
||||
: ""}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||
<MoneyValues
|
||||
className="text-foreground font-medium"
|
||||
amount={category.currentAmount}
|
||||
/>
|
||||
{category.percentageChange !== null ? (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs font-medium ${changeClassName}`}
|
||||
>
|
||||
{hasIncrease ? (
|
||||
<RiArrowUpSFill className="size-3" />
|
||||
) : null}
|
||||
{hasDecrease ? (
|
||||
<RiArrowDownSFill className="size-3" />
|
||||
) : null}
|
||||
{formatPercentage(
|
||||
category.percentageChange,
|
||||
config.percentageDigits,
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<CategoryBreakdownList
|
||||
categories={data.categories}
|
||||
periodParam={periodParam}
|
||||
config={config}
|
||||
/>
|
||||
</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-xs uppercase text-muted-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="font-medium 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>
|
||||
<CategoryBreakdownChart
|
||||
categories={data.categories}
|
||||
percentageDigits={config.percentageDigits}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -25,19 +25,19 @@ import {
|
||||
} 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 { SortableWidget } from "@/features/dashboard/components/widgets/sortable-widget";
|
||||
import { WidgetSettingsDialog } from "@/features/dashboard/components/widgets/widget-settings-dialog";
|
||||
import type { DashboardData } from "@/features/dashboard/fetch-dashboard-data";
|
||||
import {
|
||||
resetWidgetPreferences,
|
||||
updateWidgetPreferences,
|
||||
type WidgetPreferences,
|
||||
} from "@/features/dashboard/widgets/actions";
|
||||
} from "@/features/dashboard/widget-registry/widget-actions";
|
||||
import {
|
||||
type DashboardWidgetQuickActionOptions,
|
||||
type WidgetConfig,
|
||||
widgetsConfig,
|
||||
} from "@/features/dashboard/widgets/widgets-config";
|
||||
} from "@/features/dashboard/widget-registry/widget-config";
|
||||
import { NoteDialog } from "@/features/notes/components/note-dialog";
|
||||
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
||||
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import {
|
||||
RiArrowDownLine,
|
||||
RiArrowDownSFill,
|
||||
RiArrowUpLine,
|
||||
RiArrowUpSFill,
|
||||
RiCalendarCheckLine,
|
||||
RiScalesLine,
|
||||
RiSubtractLine,
|
||||
RiArrowLeftRightLine,
|
||||
RiArrowRightDownLine,
|
||||
RiArrowRightUpLine,
|
||||
RiCalendar2Line,
|
||||
} from "@remixicon/react";
|
||||
import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-card-info-button";
|
||||
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
|
||||
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||
import type { DashboardCardMetrics } from "@/features/dashboard/overview/dashboard-metrics-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
Card,
|
||||
@@ -34,7 +32,7 @@ const CARDS = [
|
||||
label: "Receitas",
|
||||
subtitle: "Entradas do período",
|
||||
key: "receitas",
|
||||
icon: RiArrowDownLine,
|
||||
icon: RiArrowRightDownLine,
|
||||
invertTrend: false,
|
||||
iconClass: "text-success",
|
||||
helpTitle: "Como calculamos receitas",
|
||||
@@ -50,7 +48,7 @@ const CARDS = [
|
||||
label: "Despesas",
|
||||
subtitle: "Saídas do período",
|
||||
key: "despesas",
|
||||
icon: RiArrowUpLine,
|
||||
icon: RiArrowRightUpLine,
|
||||
invertTrend: true,
|
||||
iconClass: "text-destructive",
|
||||
helpTitle: "Como calculamos despesas",
|
||||
@@ -66,7 +64,7 @@ const CARDS = [
|
||||
label: "Balanço",
|
||||
subtitle: "Receitas, despesas e ajustes entre contas",
|
||||
key: "balanco",
|
||||
icon: RiScalesLine,
|
||||
icon: RiArrowLeftRightLine,
|
||||
invertTrend: false,
|
||||
iconClass: "text-warning",
|
||||
helpTitle: "Como calculamos o balanço",
|
||||
@@ -81,7 +79,7 @@ const CARDS = [
|
||||
label: "Previsto",
|
||||
subtitle: "Saldo acumulado projetado",
|
||||
key: "previsto",
|
||||
icon: RiCalendarCheckLine,
|
||||
icon: RiCalendar2Line,
|
||||
invertTrend: false,
|
||||
iconClass: "text-cyan-600",
|
||||
helpTitle: "Como calculamos o previsto",
|
||||
@@ -94,12 +92,6 @@ const CARDS = [
|
||||
},
|
||||
] 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";
|
||||
@@ -126,12 +118,6 @@ const getPercentChange = (current: number, previous: number): string => {
|
||||
});
|
||||
};
|
||||
|
||||
const getTrendBadgeClass = (trend: Trend, invertTrend: boolean): string => {
|
||||
if (trend === "flat") return "text-muted-foreground";
|
||||
const isPositive = invertTrend ? trend === "down" : trend === "up";
|
||||
return isPositive ? "text-success" : "text-destructive";
|
||||
};
|
||||
|
||||
export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||
@@ -148,8 +134,6 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||
}) => {
|
||||
const metric = metrics[key];
|
||||
const trend = getTrend(metric.current, metric.previous);
|
||||
const TrendIcon = TREND_ICONS[trend];
|
||||
const trendBadgeClass = getTrendBadgeClass(trend, invertTrend);
|
||||
const percentChange = getPercentChange(
|
||||
metric.current,
|
||||
metric.previous,
|
||||
@@ -157,23 +141,19 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||
|
||||
return (
|
||||
<Card key={label} className="gap-2 overflow-hidden">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-1.5 ">
|
||||
<Icon className={cn("size-4", iconClass)} aria-hidden />
|
||||
{label}
|
||||
<MetricsCardInfoButton
|
||||
label={label}
|
||||
helpTitle={helpTitle}
|
||||
helpLines={helpLines}
|
||||
/>
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5 tracking-tight">
|
||||
{subtitle}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<CardHeader className="gap-1">
|
||||
<CardTitle className="flex items-center gap-1">
|
||||
<Icon className={cn("size-4", iconClass)} aria-hidden />
|
||||
{label}
|
||||
<MetricsCardInfoButton
|
||||
label={label}
|
||||
helpTitle={helpTitle}
|
||||
helpLines={helpLines}
|
||||
/>
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1 tracking-tight">
|
||||
{subtitle}
|
||||
</CardDescription>
|
||||
<Separator className="mt-1" />
|
||||
</CardHeader>
|
||||
|
||||
@@ -183,15 +163,14 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||
className="text-2xl leading-none font-medium"
|
||||
amount={metric.current}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-xs font-medium",
|
||||
trendBadgeClass,
|
||||
)}
|
||||
>
|
||||
<TrendIcon className="size-3.5" aria-hidden />
|
||||
<span>{percentChange}</span>
|
||||
</div>
|
||||
<PercentageChangeIndicator
|
||||
trend={trend}
|
||||
label={percentChange}
|
||||
positiveTrend={invertTrend ? "down" : "up"}
|
||||
showFlatIcon
|
||||
className="gap-1"
|
||||
iconClassName="size-3.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatCurrentDate, getGreeting } from "./welcome-widget";
|
||||
import { formatCurrentDate, getGreeting } from "@/features/dashboard/widget-registry/welcome-widget";
|
||||
|
||||
type DashboardWelcomeProps = {
|
||||
name?: string | null;
|
||||
@@ -10,13 +10,11 @@ export function DashboardWelcome({ name }: DashboardWelcomeProps) {
|
||||
const greeting = getGreeting();
|
||||
|
||||
return (
|
||||
<section className="py-4">
|
||||
<div>
|
||||
<h1 className="text-xl tracking-tight">
|
||||
{greeting}, {displayName}
|
||||
</h1>
|
||||
<h2 className="mt-1 text-sm text-muted-foreground">{formattedDate}</h2>
|
||||
</div>
|
||||
<section className="py-4 space-y-1">
|
||||
<h1 className="text-xl tracking-tight">
|
||||
<span className="text-muted-foreground">{greeting},</span> {displayName}
|
||||
</h1>
|
||||
<h2 className="text-sm text-muted-foreground">{formattedDate}</h2>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { RiPencilLine } from "@remixicon/react";
|
||||
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||
import {
|
||||
clampGoalProgress,
|
||||
formatGoalProgressPercentage,
|
||||
} from "@/features/dashboard/goals-progress-helpers";
|
||||
import type { GoalProgressItem as GoalProgressItemData } from "@/features/dashboard/goals-progress-queries";
|
||||
} from "@/features/dashboard/goals-progress/goals-progress-helpers";
|
||||
import type { GoalProgressItem as GoalProgressItemData } from "@/features/dashboard/goals-progress/goals-progress-queries";
|
||||
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
@@ -22,12 +23,6 @@ export function GoalProgressItem({
|
||||
}: GoalProgressItemProps) {
|
||||
const progressValue = clampGoalProgress(item.usedPercentage, 0, 100);
|
||||
const percentageDelta = item.usedPercentage - 100;
|
||||
const deltaColor =
|
||||
percentageDelta > 0
|
||||
? "text-destructive"
|
||||
: percentageDelta < 0
|
||||
? "text-success"
|
||||
: "text-muted-foreground";
|
||||
const isExceeded = item.status === "exceeded";
|
||||
|
||||
return (
|
||||
@@ -47,9 +42,12 @@ export function GoalProgressItem({
|
||||
<MoneyValues className="font-medium" amount={item.spentAmount} />{" "}
|
||||
de{" "}
|
||||
<MoneyValues className="font-medium" amount={item.budgetAmount} />
|
||||
<span className={`ml-1.5 font-medium ${deltaColor}`}>
|
||||
{formatGoalProgressPercentage(percentageDelta, true)}
|
||||
</span>
|
||||
<PercentageChangeIndicator
|
||||
value={percentageDelta}
|
||||
label={formatGoalProgressPercentage(percentageDelta, true)}
|
||||
positiveTrend="down"
|
||||
className="ml-1.5 align-middle"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RiFundsLine } from "@remixicon/react";
|
||||
import type { GoalProgressItem } from "@/features/dashboard/goals-progress-queries";
|
||||
import type { GoalProgressItem } from "@/features/dashboard/goals-progress/goals-progress-queries";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { GoalProgressItem as GoalProgressListItem } from "./goal-progress-item";
|
||||
import { GoalProgressItem as GoalProgressListItem } from "./goals-progress-item";
|
||||
|
||||
type GoalsProgressListProps = {
|
||||
items: GoalProgressItem[];
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
import type {
|
||||
GoalProgressItem,
|
||||
GoalsProgressData,
|
||||
} from "@/features/dashboard/goals-progress-queries";
|
||||
} from "@/features/dashboard/goals-progress/goals-progress-queries";
|
||||
import { GoalsProgressList } from "./goals-progress-list";
|
||||
import { GoalsProgressWidgetDialogs } from "./goals-progress-widget-dialogs";
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ export function InstallmentAnalysisPage({
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Card de resumo principal */}
|
||||
<Card className="border-none bg-primary/15">
|
||||
<Card className="border-none bg-primary/10 dark:bg-primary/10">
|
||||
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Se você pagar tudo que está selecionado:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Image from "next/image";
|
||||
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import { buildInstallmentExpenseDisplay } from "@/features/dashboard/installment-expenses-helpers";
|
||||
import { buildInstallmentExpenseDisplay } from "@/features/dashboard/expenses/installment-expenses-helpers";
|
||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||
import {
|
||||
buildInvoiceDetailsHref,
|
||||
buildInvoiceInitials,
|
||||
@@ -8,8 +9,8 @@ import {
|
||||
getInvoiceShareLabel,
|
||||
parseInvoiceDueDate,
|
||||
parseInvoiceWidgetDueDate,
|
||||
} from "@/features/dashboard/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
} from "@/features/dashboard/invoices/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
Avatar,
|
||||
@@ -83,7 +84,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
||||
{hasBreakdown ? (
|
||||
<HoverCard openDelay={150}>
|
||||
<HoverCardTrigger asChild>{linkNode}</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-72 space-y-3">
|
||||
<HoverCardContent align="start" className="w-80 space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Distribuição por pagador
|
||||
</p>
|
||||
@@ -115,11 +116,14 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
<div className="flex shrink-0 flex-col items-end gap-0.5 text-sm font-medium text-foreground">
|
||||
<MoneyValues
|
||||
className="font-medium"
|
||||
amount={share.amount}
|
||||
/>
|
||||
<PercentageChangeIndicator
|
||||
value={share.percentageChange}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
@@ -179,8 +183,8 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
||||
onClick={() => onPay(invoice.id)}
|
||||
>
|
||||
{isPaid ? (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<RiCheckboxCircleFill className="size-4" /> Pago
|
||||
<span className="flex items-center gap-0.5 text-success">
|
||||
<RiCheckboxCircleFill className="size-3.5" /> Pago
|
||||
</span>
|
||||
) : isOverdue ? (
|
||||
<span className="overdue-blink">
|
||||
|
||||
@@ -2,7 +2,7 @@ import Image from "next/image";
|
||||
import {
|
||||
buildInvoiceInitials,
|
||||
type InvoiceLogoTone,
|
||||
} from "@/features/dashboard/invoices-helpers";
|
||||
} from "@/features/dashboard/invoices/invoices-helpers";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
getInvoiceStatusBadgeVariant,
|
||||
type InvoiceDialogState,
|
||||
parseInvoiceDueDate,
|
||||
} from "@/features/dashboard/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
} from "@/features/dashboard/invoices/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { PaymentSuccess } from "@/shared/components/payment-success";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RiBillLine } from "@remixicon/react";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { InvoiceListItem } from "./invoice-list-item";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { InvoiceDialogState } from "@/features/dashboard/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import type { InvoiceDialogState } from "@/features/dashboard/invoices/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
||||
import { InvoicePaymentDialog } from "./invoice-payment-dialog";
|
||||
import { InvoicesList } from "./invoices-list";
|
||||
|
||||
|
||||
@@ -29,14 +29,12 @@ export function NoteListItem({
|
||||
{displayTitle}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-xs">
|
||||
{getNoteTasksSummary(note)}
|
||||
</Badge>
|
||||
{createdAtLabel ? (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{createdAtLabel}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{createdAtLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
|
||||
import {
|
||||
formatPaymentBreakdownPercentage,
|
||||
formatPaymentBreakdownTransactionsLabel,
|
||||
} from "@/features/dashboard/payment-breakdown-formatters";
|
||||
} from "@/features/dashboard/payments/payment-breakdown-formatters";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
|
||||
import type { PaymentOverviewTab } from "@/features/dashboard/payment-overview-tabs";
|
||||
import type { PaymentOverviewTab } from "@/features/dashboard/payments/payment-overview-tabs";
|
||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||
import {
|
||||
@@ -31,11 +31,17 @@ export function PaymentOverviewWidgetView({
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="conditions" className="text-xs">
|
||||
<TabsTrigger
|
||||
value="conditions"
|
||||
className="text-xs data-[state=active]:bg-transparent"
|
||||
>
|
||||
<RiSlideshowLine className="mr-1 size-3.5" />
|
||||
Condições
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="methods" className="text-xs">
|
||||
<TabsTrigger
|
||||
value="methods"
|
||||
className="text-xs data-[state=active]:bg-transparent"
|
||||
>
|
||||
<RiMoneyDollarCircleLine className="mr-1 size-3.5" />
|
||||
Formas
|
||||
</TabsTrigger>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
RiArrowDownSFill,
|
||||
RiArrowUpSFill,
|
||||
RiSubtractLine,
|
||||
} from "@remixicon/react";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
export type PercentageChangeTrend = "up" | "down" | "flat";
|
||||
|
||||
type PercentageChangeIndicatorProps = {
|
||||
value?: number | null;
|
||||
label?: string;
|
||||
trend?: PercentageChangeTrend;
|
||||
positiveTrend?: Exclude<PercentageChangeTrend, "flat">;
|
||||
showFlatIcon?: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
export function PercentageChangeIndicator({
|
||||
value,
|
||||
label,
|
||||
trend,
|
||||
positiveTrend = "down",
|
||||
showFlatIcon = false,
|
||||
className,
|
||||
iconClassName,
|
||||
}: PercentageChangeIndicatorProps) {
|
||||
const hasNumericValue = typeof value === "number" && Number.isFinite(value);
|
||||
const resolvedTrend =
|
||||
trend ??
|
||||
(hasNumericValue
|
||||
? value > 0
|
||||
? "up"
|
||||
: value < 0
|
||||
? "down"
|
||||
: "flat"
|
||||
: "flat");
|
||||
const resolvedLabel =
|
||||
label ?? (hasNumericValue ? formatPercentage(value) : null);
|
||||
|
||||
if (!resolvedLabel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-0.5 text-xs font-medium",
|
||||
resolvedTrend === "flat"
|
||||
? "text-muted-foreground"
|
||||
: resolvedTrend === positiveTrend
|
||||
? "text-success"
|
||||
: "text-destructive",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{resolvedTrend === "up" ? (
|
||||
<RiArrowUpSFill className={cn("size-3", iconClassName)} />
|
||||
) : null}
|
||||
{resolvedTrend === "down" ? (
|
||||
<RiArrowDownSFill className={cn("size-3", iconClassName)} />
|
||||
) : null}
|
||||
{resolvedTrend === "flat" && showFlatIcon ? (
|
||||
<RiSubtractLine className={cn("size-3", iconClassName)} />
|
||||
) : null}
|
||||
{resolvedLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"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";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
||||
import { useBillWidgetController } from "@/features/dashboard/bills/use-bill-widget-controller";
|
||||
import { BillsWidgetView } from "../bills/bills-widget-view";
|
||||
|
||||
type BillWidgetProps = {
|
||||
bills?: DashboardBill[];
|
||||
@@ -1,15 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiArrowDownSFill,
|
||||
RiArrowUpSFill,
|
||||
RiLineChartLine,
|
||||
} from "@remixicon/react";
|
||||
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown";
|
||||
import { RiLineChartLine } from "@remixicon/react";
|
||||
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
|
||||
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
|
||||
type CategoryTrendsWidgetProps = {
|
||||
categories: DashboardCategoryBreakdownItem[];
|
||||
@@ -40,7 +37,6 @@ export function CategoryTrendsWidget({
|
||||
<ul className="flex flex-col space-y-1">
|
||||
{trending.map((category) => {
|
||||
const change = category.percentageChange ?? 0;
|
||||
const isUp = change > 0;
|
||||
|
||||
return (
|
||||
<li key={category.categoryId}>
|
||||
@@ -62,19 +58,17 @@ export function CategoryTrendsWidget({
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-0.5 font-semibold text-sm",
|
||||
isUp ? " text-destructive" : " text-success",
|
||||
)}
|
||||
>
|
||||
{isUp ? (
|
||||
<RiArrowUpSFill className="size-3.5" />
|
||||
) : (
|
||||
<RiArrowDownSFill className="size-3.5" />
|
||||
)}
|
||||
{Math.abs(change).toFixed(0)}%
|
||||
</span>
|
||||
<PercentageChangeIndicator
|
||||
value={change}
|
||||
label={formatPercentage(change, {
|
||||
absolute: true,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
positiveTrend="down"
|
||||
className="shrink-0 text-sm font-semibold"
|
||||
iconClassName="size-3.5"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ExpensesByCategoryData } from "@/features/dashboard/categories/expenses-by-category-queries";
|
||||
import { CategoryBreakdownWidgetView } from "./category-breakdown/category-breakdown-widget-view";
|
||||
import { CategoryBreakdownWidgetView } from "../category-breakdown/category-breakdown-widget-view";
|
||||
|
||||
type ExpensesByCategoryWidgetWithChartProps = {
|
||||
data: ExpensesByCategoryData;
|
||||
@@ -1,8 +1,8 @@
|
||||
"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";
|
||||
import type { GoalsProgressData } from "@/features/dashboard/goals-progress/goals-progress-queries";
|
||||
import { useGoalsProgressWidgetController } from "@/features/dashboard/goals-progress/use-goals-progress-widget-controller";
|
||||
import { GoalsProgressWidgetView } from "../goals-progress/goals-progress-widget-view";
|
||||
|
||||
type GoalsProgressWidgetProps = {
|
||||
data: GoalsProgressData;
|
||||
@@ -10,7 +10,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { DashboardInboxSnapshot } from "@/features/dashboard/inbox-snapshot-queries";
|
||||
import type { DashboardWidgetQuickActionOptions } from "@/features/dashboard/widgets/widgets-config";
|
||||
import type { DashboardWidgetQuickActionOptions } from "@/features/dashboard/widget-registry/widget-config";
|
||||
import {
|
||||
discardInboxItemAction,
|
||||
markInboxAsProcessedAction,
|
||||
@@ -178,7 +178,7 @@ export function InboxWidget({
|
||||
key={item.id}
|
||||
className="flex items-center justify-between py-1.5"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 py-1">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<Image
|
||||
src={displayLogo}
|
||||
alt={item.sourceAppName ?? ""}
|
||||
@@ -188,9 +188,11 @@ export function InboxWidget({
|
||||
unoptimized
|
||||
/>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{displayName}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{displayName.length > 30
|
||||
? `${displayName.slice(0, 30)}...`
|
||||
: displayName}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{item.sourceAppName && <span>{item.sourceAppName}</span>}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { IncomeByCategoryData } from "@/features/dashboard/categories/income-by-category-queries";
|
||||
import { CategoryBreakdownWidgetView } from "./category-breakdown/category-breakdown-widget-view";
|
||||
import { CategoryBreakdownWidgetView } from "../category-breakdown/category-breakdown-widget-view";
|
||||
|
||||
type IncomeByCategoryWidgetWithChartProps = {
|
||||
data: IncomeByCategoryData;
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { RiLineChartLine } from "@remixicon/react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
||||
import type { IncomeExpenseBalanceData } from "@/features/dashboard/income-expense-balance-queries";
|
||||
import type { IncomeExpenseBalanceData } from "@/features/dashboard/overview/income-expense-balance-queries";
|
||||
import { CardContent } from "@/shared/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
@@ -19,15 +19,15 @@ type IncomeExpenseBalanceWidgetProps = {
|
||||
const chartConfig = {
|
||||
receita: {
|
||||
label: "Receita",
|
||||
color: "var(--data-9)",
|
||||
color: "var(--success)",
|
||||
},
|
||||
despesa: {
|
||||
label: "Despesa",
|
||||
color: "var(--data-1)",
|
||||
color: "var(--destructive)",
|
||||
},
|
||||
balanco: {
|
||||
label: "Balanço",
|
||||
color: "var(--data-4)",
|
||||
color: "var(--warning)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import { InstallmentExpensesWidgetView } from "./installment-expenses/installment-expenses-widget-view";
|
||||
import { InstallmentExpensesWidgetView } from "../installment-expenses/installment-expenses-widget-view";
|
||||
|
||||
type InstallmentExpensesWidgetProps = {
|
||||
data: InstallmentExpensesData;
|
||||
@@ -1,8 +1,8 @@
|
||||
"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";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
||||
import { useInvoicesWidgetController } from "@/features/dashboard/invoices/use-invoices-widget-controller";
|
||||
import { InvoicesWidgetView } from "../invoices/invoices-widget-view";
|
||||
|
||||
type InvoicesWidgetProps = {
|
||||
invoices: DashboardInvoice[];
|
||||
@@ -11,7 +11,7 @@ import Link from "next/link";
|
||||
import { useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { DashboardAccount } from "@/features/dashboard/accounts-queries";
|
||||
import { updateMyAccountsWidgetPreference } from "@/features/dashboard/widgets/actions";
|
||||
import { updateMyAccountsWidgetPreference } from "@/features/dashboard/widget-registry/widget-actions";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
@@ -1,8 +1,8 @@
|
||||
"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";
|
||||
import type { DashboardNote } from "@/features/dashboard/notes/notes-queries";
|
||||
import { useNotesWidgetController } from "@/features/dashboard/notes/use-notes-widget-controller";
|
||||
import { NotesWidgetView } from "../notes/notes-widget-view";
|
||||
|
||||
type NotesWidgetProps = {
|
||||
notes: DashboardNote[];
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiArrowDownSFill,
|
||||
RiArrowUpSFill,
|
||||
RiExternalLinkLine,
|
||||
RiGroupLine,
|
||||
RiVerifiedBadgeFill,
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||
import type { DashboardPagador } from "@/features/dashboard/payers-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||
import { buildInitials } from "@/shared/utils/initials";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
|
||||
type PayersWidgetProps = {
|
||||
payers: DashboardPagador[];
|
||||
@@ -87,25 +85,7 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
|
||||
className="font-medium"
|
||||
amount={payer.totalExpenses}
|
||||
/>
|
||||
{percentageChange !== null && (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs font-medium ${
|
||||
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>
|
||||
)}
|
||||
<PercentageChangeIndicator value={percentageChange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
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";
|
||||
import { usePaymentOverviewWidgetController } from "@/features/dashboard/payments/use-payment-overview-widget-controller";
|
||||
import { PaymentOverviewWidgetView } from "../payment-overview/payment-overview-widget-view";
|
||||
|
||||
type PaymentOverviewWidgetProps = {
|
||||
paymentConditionsData: PaymentConditionsData;
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
|
||||
import { PaymentStatusWidgetView } from "./payment-status/payment-status-widget-view";
|
||||
import { PaymentStatusWidgetView } from "../payment-status/payment-status-widget-view";
|
||||
|
||||
type PaymentStatusWidgetProps = {
|
||||
data: PaymentStatusData;
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { RiArrowDownSFill, RiStore3Line } from "@remixicon/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PurchasesByCategoryData } from "@/features/dashboard/purchases-by-category-queries";
|
||||
import type { PurchasesByCategoryData } from "@/features/dashboard/categories/purchases-by-category-queries";
|
||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
@@ -37,11 +37,17 @@ export function SpendingOverviewWidget({
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="expenses" className="text-xs">
|
||||
<TabsTrigger
|
||||
value="expenses"
|
||||
className="text-xs data-[state=active]:bg-transparent"
|
||||
>
|
||||
<RiArrowUpDoubleLine className="mr-1 size-3.5" />
|
||||
Top gastos
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="establishments" className="text-xs">
|
||||
<TabsTrigger
|
||||
value="establishments"
|
||||
className="text-xs data-[state=active]:bg-transparent"
|
||||
>
|
||||
<RiStore2Line className="mr-1 size-3.5" />
|
||||
Estabelecimentos
|
||||
</TabsTrigger>
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { RiRefreshLine, RiSettings4Line } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import { widgetsConfig } from "@/features/dashboard/widgets/widgets-config";
|
||||
import { widgetsConfig } from "@/features/dashboard/widget-registry/widget-config";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -1,189 +0,0 @@
|
||||
import { and, asc, eq, gte, inArray, lte, sum } from "drizzle-orm";
|
||||
import { financialAccounts, transactions } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
excludeInitialBalanceWhenConfigured,
|
||||
excludeTransactionsFromExcludedAccounts,
|
||||
} from "@/features/dashboard/transaction-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } 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;
|
||||
transferAdjustment: number;
|
||||
balanco: number;
|
||||
};
|
||||
|
||||
const createEmptyTotals = (): PeriodTotals => ({
|
||||
receitas: 0,
|
||||
despesas: 0,
|
||||
transferAdjustment: 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 async function fetchDashboardCardMetrics(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<DashboardCardMetrics> {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
|
||||
const adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) {
|
||||
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: transactions.period,
|
||||
transactionType: transactions.transactionType,
|
||||
totalAmount: sum(transactions.amount).as("total"),
|
||||
accountExcludeFromBalance: financialAccounts.excludeFromBalance,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
||||
gte(transactions.period, startPeriod),
|
||||
lte(transactions.period, period),
|
||||
inArray(transactions.transactionType, [
|
||||
RECEITA,
|
||||
DESPESA,
|
||||
TRANSFERENCIA,
|
||||
]),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeInitialBalanceWhenConfigured(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
transactions.period,
|
||||
transactions.transactionType,
|
||||
financialAccounts.excludeFromBalance,
|
||||
)
|
||||
.orderBy(asc(transactions.period), asc(transactions.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);
|
||||
} else if (
|
||||
row.transactionType === TRANSFERENCIA &&
|
||||
row.accountExcludeFromBalance === false
|
||||
) {
|
||||
totals.transferAdjustment += 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 + totals.transferAdjustment;
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,3 @@
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { transactions } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type InstallmentExpense = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -23,78 +13,3 @@ export type InstallmentExpense = {
|
||||
export type InstallmentExpensesData = {
|
||||
expenses: InstallmentExpense[];
|
||||
};
|
||||
|
||||
export async function fetchInstallmentExpenses(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<InstallmentExpensesData> {
|
||||
const adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) {
|
||||
return { expenses: [] };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: transactions.id,
|
||||
name: transactions.name,
|
||||
amount: transactions.amount,
|
||||
paymentMethod: transactions.paymentMethod,
|
||||
currentInstallment: transactions.currentInstallment,
|
||||
installmentCount: transactions.installmentCount,
|
||||
dueDate: transactions.dueDate,
|
||||
purchaseDate: transactions.purchaseDate,
|
||||
period: transactions.period,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.period, period),
|
||||
eq(transactions.transactionType, "Despesa"),
|
||||
eq(transactions.condition, "Parcelado"),
|
||||
eq(transactions.isAnticipated, false),
|
||||
eq(transactions.payerId, adminPayerId),
|
||||
or(
|
||||
isNull(transactions.note),
|
||||
and(
|
||||
sql`${transactions.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(transactions.purchaseDate), desc(transactions.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 };
|
||||
}
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { transactions } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type RecurringExpense = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -19,54 +9,3 @@ export type RecurringExpense = {
|
||||
export type RecurringExpensesData = {
|
||||
expenses: RecurringExpense[];
|
||||
};
|
||||
|
||||
export async function fetchRecurringExpenses(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<RecurringExpensesData> {
|
||||
const adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) {
|
||||
return { expenses: [] };
|
||||
}
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: transactions.id,
|
||||
name: transactions.name,
|
||||
amount: transactions.amount,
|
||||
paymentMethod: transactions.paymentMethod,
|
||||
recurrenceCount: transactions.recurrenceCount,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.period, period),
|
||||
eq(transactions.transactionType, "Despesa"),
|
||||
eq(transactions.condition, "Recorrente"),
|
||||
eq(transactions.payerId, adminPayerId),
|
||||
or(
|
||||
isNull(transactions.note),
|
||||
and(
|
||||
sql`${transactions.note} != ${INITIAL_BALANCE_NOTE}`,
|
||||
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(transactions.purchaseDate), desc(transactions.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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { cards, financialAccounts, transactions } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminPeriodFilters,
|
||||
excludeAutoGeneratedEntryNotes,
|
||||
} from "@/features/dashboard/transaction-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type TopExpense = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -20,66 +10,3 @@ export type TopExpense = {
|
||||
export type TopExpensesData = {
|
||||
expenses: TopExpense[];
|
||||
};
|
||||
|
||||
export async function fetchTopExpenses(
|
||||
userId: string,
|
||||
period: string,
|
||||
cardOnly: boolean = false,
|
||||
): Promise<TopExpensesData> {
|
||||
const adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) {
|
||||
return { expenses: [] };
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
...buildDashboardAdminPeriodFilters({
|
||||
userId,
|
||||
period,
|
||||
adminPayerId,
|
||||
}),
|
||||
eq(transactions.transactionType, "Despesa"),
|
||||
excludeAutoGeneratedEntryNotes(),
|
||||
];
|
||||
|
||||
// Se cardOnly for true, filtra apenas pagamentos com cartão
|
||||
if (cardOnly) {
|
||||
conditions.push(eq(transactions.paymentMethod, "Cartão de Crédito"));
|
||||
}
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: transactions.id,
|
||||
name: transactions.name,
|
||||
amount: transactions.amount,
|
||||
purchaseDate: transactions.purchaseDate,
|
||||
paymentMethod: transactions.paymentMethod,
|
||||
cardId: transactions.cardId,
|
||||
accountId: transactions.accountId,
|
||||
cardLogo: cards.logo,
|
||||
accountLogo: financialAccounts.logo,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(cards, eq(transactions.cardId, cards.id))
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(transactions.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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { cacheLife, cacheTag } from "next/cache";
|
||||
import { fetchAttachmentsForPeriod } from "@/features/attachments/queries";
|
||||
import { fetchDashboardAccounts } from "./accounts-queries";
|
||||
import { fetchDashboardCategoryOverview } from "./category-overview-queries";
|
||||
import { fetchDashboardCurrentPeriodOverview } from "./current-period-overview-queries";
|
||||
import { fetchDashboardCategoryOverview } from "./categories/category-overview-queries";
|
||||
import { fetchDashboardInboxSnapshot } from "./inbox-snapshot-queries";
|
||||
import { fetchDashboardInvoices } from "./invoices-queries";
|
||||
import { fetchDashboardNotes } from "./notes-queries";
|
||||
import { fetchDashboardInvoices } from "./invoices/invoices-queries";
|
||||
import { fetchDashboardNotes } from "./notes/notes-queries";
|
||||
import { fetchDashboardCurrentPeriodOverview } from "./overview/current-period-overview-queries";
|
||||
import { fetchDashboardPeriodOverview } from "./overview/period-overview-queries";
|
||||
import { fetchDashboardPayers } from "./payers-queries";
|
||||
import { fetchDashboardPeriodOverview } from "./period-overview-queries";
|
||||
|
||||
async function fetchDashboardDataInternal(userId: string, period: string) {
|
||||
const [
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { and, eq, ne, sql } from "drizzle-orm";
|
||||
import { budgets, categories, transactions } from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } 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 adminPayerId = await getAdminPayerId(userId);
|
||||
|
||||
if (!adminPayerId) {
|
||||
return {
|
||||
items: [],
|
||||
categories: [],
|
||||
totalBudgets: 0,
|
||||
exceededCount: 0,
|
||||
criticalCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const [rows, categoryRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
orcamentoId: budgets.id,
|
||||
categoryId: categories.id,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
period: budgets.period,
|
||||
createdAt: budgets.createdAt,
|
||||
budgetAmount: budgets.amount,
|
||||
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
|
||||
})
|
||||
.from(budgets)
|
||||
.innerJoin(categories, eq(budgets.categoryId, categories.id))
|
||||
.leftJoin(
|
||||
transactions,
|
||||
and(
|
||||
eq(transactions.categoryId, budgets.categoryId),
|
||||
eq(transactions.userId, budgets.userId),
|
||||
eq(transactions.period, budgets.period),
|
||||
eq(transactions.payerId, adminPayerId),
|
||||
eq(transactions.transactionType, "Despesa"),
|
||||
ne(transactions.condition, "cancelado"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(budgets.userId, userId), eq(budgets.period, period)))
|
||||
.groupBy(
|
||||
budgets.id,
|
||||
categories.id,
|
||||
categories.name,
|
||||
categories.icon,
|
||||
budgets.period,
|
||||
budgets.createdAt,
|
||||
budgets.amount,
|
||||
),
|
||||
db.query.categories.findMany({
|
||||
where: and(eq(categories.userId, userId), eq(categories.type, "despesa")),
|
||||
orderBy: (category, { asc }) => [asc(category.name)],
|
||||
}),
|
||||
]);
|
||||
|
||||
const categoryList: 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: categoryList,
|
||||
totalBudgets: items.length,
|
||||
exceededCount,
|
||||
criticalCount,
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
GoalProgressCategory,
|
||||
GoalProgressItem,
|
||||
GoalProgressStatus,
|
||||
} from "@/features/dashboard/goals-progress-queries";
|
||||
} from "@/features/dashboard/goals-progress/goals-progress-queries";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
|
||||
export const clampGoalProgress = (value: number, min: number, max: number) =>
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
};
|
||||
@@ -8,13 +8,13 @@ import type {
|
||||
import {
|
||||
mapGoalProgressCategoriesToBudgetCategories,
|
||||
mapGoalProgressItemToBudget,
|
||||
} from "@/features/dashboard/goals-progress-helpers";
|
||||
} from "@/features/dashboard/goals-progress/goals-progress-helpers";
|
||||
import type {
|
||||
GoalProgressItem,
|
||||
GoalsProgressData,
|
||||
} from "@/features/dashboard/goals-progress-queries";
|
||||
} from "@/features/dashboard/goals-progress/goals-progress-queries";
|
||||
|
||||
export type GoalsProgressWidgetController = {
|
||||
type GoalsProgressWidgetController = {
|
||||
selectedBudget: Budget | null;
|
||||
editOpen: boolean;
|
||||
categories: BudgetCategory[];
|
||||
@@ -1,126 +0,0 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { financialAccounts, transactions } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
excludeInitialBalanceWhenConfigured,
|
||||
excludeTransactionsFromExcludedAccounts,
|
||||
} from "@/features/dashboard/transaction-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } 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 adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) {
|
||||
return { months: [] };
|
||||
}
|
||||
|
||||
const periods = generateLast6Months(currentPeriod);
|
||||
|
||||
// Single query: GROUP BY period + transactionType instead of 12 separate queries
|
||||
const rows = await db
|
||||
.select({
|
||||
period: transactions.period,
|
||||
transactionType: transactions.transactionType,
|
||||
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||
accountExcludeFromBalance: financialAccounts.excludeFromBalance,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
||||
inArray(transactions.period, periods),
|
||||
inArray(transactions.transactionType, [
|
||||
"Receita",
|
||||
"Despesa",
|
||||
"Transferência",
|
||||
]),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeInitialBalanceWhenConfigured(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
transactions.period,
|
||||
transactions.transactionType,
|
||||
financialAccounts.excludeFromBalance,
|
||||
);
|
||||
|
||||
// Build lookup from query results
|
||||
const dataMap = new Map<
|
||||
string,
|
||||
{ income: number; expense: number; transferAdjustment: number }
|
||||
>();
|
||||
for (const row of rows) {
|
||||
if (!row.period) continue;
|
||||
const entry = dataMap.get(row.period) ?? {
|
||||
income: 0,
|
||||
expense: 0,
|
||||
transferAdjustment: 0,
|
||||
};
|
||||
const total = toNumber(row.total);
|
||||
if (row.transactionType === "Receita") {
|
||||
entry.income += Math.abs(total);
|
||||
} else if (row.transactionType === "Despesa") {
|
||||
entry.expense += Math.abs(total);
|
||||
} else if (
|
||||
row.transactionType === "Transferência" &&
|
||||
row.accountExcludeFromBalance === false
|
||||
) {
|
||||
entry.transferAdjustment += 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,
|
||||
transferAdjustment: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
month: period,
|
||||
monthLabel: formatPeriodMonthShort(period).toLowerCase(),
|
||||
income: entry.income,
|
||||
expense: entry.expense,
|
||||
balance: entry.income - entry.expense + entry.transferAdjustment,
|
||||
};
|
||||
});
|
||||
|
||||
return { months };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import type { PaymentDialogState } from "@/features/dashboard/use-payment-dialog-controller";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
||||
import type { PaymentDialogState } from "@/features/dashboard/payments/use-payment-dialog-controller";
|
||||
import {
|
||||
INVOICE_PAYMENT_STATUS,
|
||||
type InvoicePaymentStatus,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, ilike, isNotNull, sql } from "drizzle-orm";
|
||||
import { and, eq, ilike, inArray, isNotNull, sql } from "drizzle-orm";
|
||||
import { cards, invoices, payers, transactions } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
isDateOnlyPast,
|
||||
toDateOnlyString,
|
||||
} from "@/shared/utils/date";
|
||||
import { calculatePercentageChange } from "@/shared/utils/math";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
import { getPreviousPeriod } from "@/shared/utils/period";
|
||||
|
||||
type RawDashboardInvoice = {
|
||||
invoiceId: string | null;
|
||||
@@ -45,6 +47,7 @@ export type InvoicePagadorBreakdown = {
|
||||
pagadorName: string;
|
||||
pagadorAvatar: string | null;
|
||||
amount: number;
|
||||
percentageChange: number | null;
|
||||
};
|
||||
|
||||
export type DashboardInvoice = {
|
||||
@@ -62,7 +65,7 @@ export type DashboardInvoice = {
|
||||
pagadorBreakdown: InvoicePagadorBreakdown[];
|
||||
};
|
||||
|
||||
export type DashboardInvoicesSnapshot = {
|
||||
type DashboardInvoicesSnapshot = {
|
||||
invoices: DashboardInvoice[];
|
||||
totalPending: number;
|
||||
};
|
||||
@@ -99,6 +102,7 @@ export async function fetchDashboardInvoices(
|
||||
period: string,
|
||||
): Promise<DashboardInvoicesSnapshot> {
|
||||
const today = getBusinessDateString();
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
const paymentRows = await db
|
||||
.select({
|
||||
note: transactions.note,
|
||||
@@ -203,7 +207,7 @@ export async function fetchDashboardInvoices(
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.period, period),
|
||||
inArray(transactions.period, [period, previousPeriod]),
|
||||
isNotNull(transactions.cardId),
|
||||
),
|
||||
)
|
||||
@@ -216,23 +220,74 @@ export async function fetchDashboardInvoices(
|
||||
),
|
||||
])) as [RawDashboardInvoice[], RawInvoiceBreakdownRow[]];
|
||||
|
||||
const breakdownMap = new Map<string, InvoicePagadorBreakdown[]>();
|
||||
const groupedBreakdown = new Map<
|
||||
string,
|
||||
{
|
||||
cardId: string;
|
||||
payerId: string | null;
|
||||
pagadorName: string;
|
||||
pagadorAvatar: string | null;
|
||||
currentAmount: number;
|
||||
previousAmount: number;
|
||||
}
|
||||
>();
|
||||
|
||||
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 payerId = row.payerId ?? null;
|
||||
const pagadorName = row.pagadorName?.trim() || "Sem pagador";
|
||||
const pagadorAvatar = row.pagadorAvatar ?? null;
|
||||
const payerKey = payerId ?? "__without-payer__";
|
||||
const key = `${row.cardId}:${payerKey}`;
|
||||
const current = groupedBreakdown.get(key) ?? {
|
||||
cardId: row.cardId,
|
||||
payerId,
|
||||
pagadorName,
|
||||
pagadorAvatar,
|
||||
currentAmount: 0,
|
||||
previousAmount: 0,
|
||||
};
|
||||
|
||||
if (resolvedPeriod === period) {
|
||||
current.payerId = payerId;
|
||||
current.pagadorName = pagadorName;
|
||||
current.pagadorAvatar = pagadorAvatar;
|
||||
current.currentAmount = amount;
|
||||
}
|
||||
|
||||
if (resolvedPeriod === previousPeriod) {
|
||||
current.previousAmount = amount;
|
||||
}
|
||||
|
||||
groupedBreakdown.set(key, current);
|
||||
}
|
||||
|
||||
const breakdownMap = new Map<string, InvoicePagadorBreakdown[]>();
|
||||
for (const share of groupedBreakdown.values()) {
|
||||
if (share.currentAmount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${share.cardId}:${period}`;
|
||||
const current = breakdownMap.get(key) ?? [];
|
||||
current.push({
|
||||
payerId: row.payerId ?? null,
|
||||
pagadorName: row.pagadorName?.trim() || "Sem pagador",
|
||||
pagadorAvatar: row.pagadorAvatar ?? null,
|
||||
amount,
|
||||
payerId: share.payerId,
|
||||
pagadorName: share.pagadorName,
|
||||
pagadorAvatar: share.pagadorAvatar,
|
||||
amount: share.currentAmount,
|
||||
percentageChange: calculatePercentageChange(
|
||||
share.currentAmount,
|
||||
share.previousAmount,
|
||||
),
|
||||
});
|
||||
breakdownMap.set(key, current);
|
||||
}
|
||||
@@ -5,16 +5,16 @@ import {
|
||||
type InvoiceDialogState,
|
||||
isInvoicePaid,
|
||||
markInvoiceAsPaid,
|
||||
} from "@/features/dashboard/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
} from "@/features/dashboard/invoices/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
||||
import {
|
||||
type PaymentDialogController,
|
||||
usePaymentDialogController,
|
||||
} from "@/features/dashboard/use-payment-dialog-controller";
|
||||
} from "@/features/dashboard/payments/use-payment-dialog-controller";
|
||||
import { updateInvoicePaymentStatusAction } from "@/features/invoices/actions";
|
||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||
|
||||
export type InvoicesWidgetController = Omit<
|
||||
type InvoicesWidgetController = Omit<
|
||||
PaymentDialogController<DashboardInvoice>,
|
||||
"selectedItem"
|
||||
> & {
|
||||
@@ -8,9 +8,9 @@ import { getBusinessDateString } from "@/shared/utils/date";
|
||||
import {
|
||||
type DashboardNotificationsSnapshot,
|
||||
fetchDashboardNotifications,
|
||||
} from "./notifications-queries";
|
||||
} from "./notifications/notifications-queries";
|
||||
|
||||
export type DashboardNavbarData = {
|
||||
type DashboardNavbarData = {
|
||||
pagadorAvatarUrl: string | null;
|
||||
preLancamentosCount: number;
|
||||
notificationsSnapshot: DashboardNotificationsSnapshot;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DashboardNote } from "@/features/dashboard/notes-queries";
|
||||
import type { DashboardNote } from "@/features/dashboard/notes/notes-queries";
|
||||
import type { Note } from "@/features/notes/components/types";
|
||||
|
||||
export const mapDashboardNoteToNote = (note: DashboardNote): Note => ({
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { mapDashboardNotesToNotes } from "@/features/dashboard/notes-mappers";
|
||||
import type { DashboardNote } from "@/features/dashboard/notes-queries";
|
||||
import { mapDashboardNotesToNotes } from "@/features/dashboard/notes/notes-mappers";
|
||||
import type { DashboardNote } from "@/features/dashboard/notes/notes-queries";
|
||||
import type { Note } from "@/features/notes/components/types";
|
||||
|
||||
export type NotesWidgetController = {
|
||||
type NotesWidgetController = {
|
||||
mappedNotes: Note[];
|
||||
noteToEdit: Note | null;
|
||||
isEditOpen: boolean;
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
invoices,
|
||||
transactions,
|
||||
} from "@/db/schema";
|
||||
import { buildInvoiceDetailsHref } from "@/features/dashboard/invoices-helpers";
|
||||
import { buildInvoiceDetailsHref } from "@/features/dashboard/invoices/invoices-helpers";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||
import { isNotificationStatesTableMissing } from "@/shared/lib/notifications/is-table-missing";
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
financialAccounts,
|
||||
transactions,
|
||||
} from "@/db/schema";
|
||||
import type { DashboardBillsSnapshot } from "@/features/dashboard/bills-queries";
|
||||
import type { DashboardBillsSnapshot } from "@/features/dashboard/bills/bills-queries";
|
||||
import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries";
|
||||
import type {
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||
import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
|
||||
import type { PurchasesByCategoryData } from "@/features/dashboard/purchases-by-category-queries";
|
||||
import type { PurchasesByCategoryData } from "@/features/dashboard/categories/purchases-by-category-queries";
|
||||
import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
|
||||
import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/transaction-filters";
|
||||
import {
|
||||
@@ -67,7 +67,7 @@ type CategoryOption = PurchasesByCategoryData["categories"][number];
|
||||
type CategoryTransaction =
|
||||
PurchasesByCategoryData["transactionsByCategory"][string][number];
|
||||
|
||||
export type DashboardCurrentPeriodOverview = {
|
||||
type DashboardCurrentPeriodOverview = {
|
||||
billsSnapshot: DashboardBillsSnapshot;
|
||||
paymentStatusData: PaymentStatusData;
|
||||
paymentConditionsData: PaymentConditionsData;
|
||||
13
src/features/dashboard/overview/dashboard-metrics-queries.ts
Normal file
13
src/features/dashboard/overview/dashboard-metrics-queries.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
type MetricPair = {
|
||||
current: number;
|
||||
previous: number;
|
||||
};
|
||||
|
||||
export type DashboardCardMetrics = {
|
||||
period: string;
|
||||
previousPeriod: string;
|
||||
receitas: MetricPair;
|
||||
despesas: MetricPair;
|
||||
balanco: MetricPair;
|
||||
previsto: MetricPair;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export type MonthData = {
|
||||
month: string;
|
||||
monthLabel: string;
|
||||
income: number;
|
||||
expense: number;
|
||||
balance: number;
|
||||
};
|
||||
|
||||
export type IncomeExpenseBalanceData = {
|
||||
months: MonthData[];
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { and, asc, eq, gte, inArray, lte, sum } from "drizzle-orm";
|
||||
import { financialAccounts, transactions } from "@/db/schema";
|
||||
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
|
||||
import type { DashboardCardMetrics } from "@/features/dashboard/overview/dashboard-metrics-queries";
|
||||
import type {
|
||||
IncomeExpenseBalanceData,
|
||||
MonthData,
|
||||
} from "@/features/dashboard/income-expense-balance-queries";
|
||||
} from "@/features/dashboard/overview/income-expense-balance-queries";
|
||||
import {
|
||||
buildDashboardAdminFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
@@ -42,7 +42,7 @@ type PeriodSummaryRow = {
|
||||
accountExcludeFromBalance: boolean | null;
|
||||
};
|
||||
|
||||
export type DashboardPeriodOverview = {
|
||||
type DashboardPeriodOverview = {
|
||||
metrics: DashboardCardMetrics;
|
||||
incomeExpenseBalanceData: IncomeExpenseBalanceData;
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
fetchTransactionFilterSources,
|
||||
} from "@/features/transactions/queries";
|
||||
|
||||
export type DashboardQuickActionOptions = {
|
||||
type DashboardQuickActionOptions = {
|
||||
payerOptions: ReturnType<typeof buildOptionSets>["payerOptions"];
|
||||
splitPayerOptions: ReturnType<typeof buildOptionSets>["splitPayerOptions"];
|
||||
defaultPayerId: string | null;
|
||||
|
||||
@@ -19,7 +19,7 @@ export type DashboardPagador = {
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
export type DashboardPayersSnapshot = {
|
||||
type DashboardPayersSnapshot = {
|
||||
payers: DashboardPagador[];
|
||||
totalExpenses: number;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { transactions } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminPeriodFilters,
|
||||
excludeAutoGeneratedEntryNotes,
|
||||
} from "@/features/dashboard/transaction-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type PaymentConditionSummary = {
|
||||
condition: string;
|
||||
amount: number;
|
||||
@@ -18,68 +8,3 @@ export type PaymentConditionSummary = {
|
||||
export type PaymentConditionsData = {
|
||||
conditions: PaymentConditionSummary[];
|
||||
};
|
||||
|
||||
export async function fetchPaymentConditions(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<PaymentConditionsData> {
|
||||
const adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) {
|
||||
return { conditions: [] };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
condition: transactions.condition,
|
||||
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||
transactions: sql<number>`count(${transactions.id})`,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminPeriodFilters({
|
||||
userId,
|
||||
period,
|
||||
adminPayerId,
|
||||
}),
|
||||
eq(transactions.transactionType, "Despesa"),
|
||||
excludeAutoGeneratedEntryNotes(),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { transactions } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminPeriodFilters,
|
||||
excludeAutoGeneratedEntryNotes,
|
||||
} from "@/features/dashboard/transaction-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type PaymentMethodSummary = {
|
||||
paymentMethod: string;
|
||||
amount: number;
|
||||
@@ -18,68 +8,3 @@ export type PaymentMethodSummary = {
|
||||
export type PaymentMethodsData = {
|
||||
methods: PaymentMethodSummary[];
|
||||
};
|
||||
|
||||
export async function fetchPaymentMethods(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<PaymentMethodsData> {
|
||||
const adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) {
|
||||
return { methods: [] };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
paymentMethod: transactions.paymentMethod,
|
||||
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||
transactions: sql<number>`count(${transactions.id})`,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminPeriodFilters({
|
||||
userId,
|
||||
period,
|
||||
adminPayerId,
|
||||
}),
|
||||
eq(transactions.transactionType, "Despesa"),
|
||||
excludeAutoGeneratedEntryNotes(),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { financialAccounts, transactions } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminPeriodFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
excludeInitialBalanceWhenConfigured,
|
||||
excludeTransactionsFromExcludedAccounts,
|
||||
} from "@/features/dashboard/transaction-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type PaymentStatusCategory = {
|
||||
total: number;
|
||||
confirmed: number;
|
||||
@@ -20,76 +8,3 @@ 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 adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) {
|
||||
return { income: emptyCategory(), expenses: emptyCategory() };
|
||||
}
|
||||
|
||||
// Single query: GROUP BY transactionType instead of 2 separate queries
|
||||
const rows = await db
|
||||
.select({
|
||||
transactionType: transactions.transactionType,
|
||||
confirmed: sql<number>`
|
||||
coalesce(
|
||||
sum(case when ${transactions.isSettled} = true then ${transactions.amount} else 0 end),
|
||||
0
|
||||
)
|
||||
`,
|
||||
pending: sql<number>`
|
||||
coalesce(
|
||||
sum(case when ${transactions.isSettled} = false or ${transactions.isSettled} is null then ${transactions.amount} else 0 end),
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminPeriodFilters({
|
||||
userId,
|
||||
period,
|
||||
adminPayerId,
|
||||
}),
|
||||
inArray(transactions.transactionType, ["Receita", "Despesa"]),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeInitialBalanceWhenConfigured(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.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;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
DEFAULT_PAYMENT_OVERVIEW_TAB,
|
||||
type PaymentOverviewTab,
|
||||
parsePaymentOverviewTab,
|
||||
} from "@/features/dashboard/payment-overview-tabs";
|
||||
} from "@/features/dashboard/payments/payment-overview-tabs";
|
||||
|
||||
export type PaymentOverviewWidgetController = {
|
||||
type PaymentOverviewWidgetController = {
|
||||
activeTab: PaymentOverviewTab;
|
||||
handleTabChange: (value: string) => void;
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { cacheLife, cacheTag } from "next/cache";
|
||||
import type { WidgetPreferences } from "@/features/dashboard/widgets/actions";
|
||||
import type { WidgetPreferences } from "@/features/dashboard/widget-registry/widget-actions";
|
||||
import { db, schema } from "@/shared/lib/db";
|
||||
|
||||
export interface UserDashboardPreferences {
|
||||
interface UserDashboardPreferences {
|
||||
dashboardWidgets: WidgetPreferences | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
cards,
|
||||
categories,
|
||||
financialAccounts,
|
||||
transactions,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminPeriodFilters,
|
||||
excludeAutoGeneratedEntryNotes,
|
||||
} from "@/features/dashboard/transaction-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } 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 adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) {
|
||||
return { categories: [], transactionsByCategory: {} };
|
||||
}
|
||||
|
||||
const transactionsRows = await db
|
||||
.select({
|
||||
id: transactions.id,
|
||||
name: transactions.name,
|
||||
amount: transactions.amount,
|
||||
purchaseDate: transactions.purchaseDate,
|
||||
categoryId: transactions.categoryId,
|
||||
categoryName: categories.name,
|
||||
categoryType: categories.type,
|
||||
cardLogo: cards.logo,
|
||||
accountLogo: financialAccounts.logo,
|
||||
})
|
||||
.from(transactions)
|
||||
.innerJoin(categories, eq(transactions.categoryId, categories.id))
|
||||
.leftJoin(cards, eq(transactions.cardId, cards.id))
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminPeriodFilters({
|
||||
userId,
|
||||
period,
|
||||
adminPayerId,
|
||||
}),
|
||||
inArray(categories.type, ["despesa", "receita"]),
|
||||
excludeAutoGeneratedEntryNotes(),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(transactions.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 categories: receitas primeiro, depois despesas (alfabeticamente dentro de cada tipo)
|
||||
const categoryList = Array.from(categoriesMap.values()).sort((a, b) => {
|
||||
// Receita vem antes de despesa
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "receita" ? -1 : 1;
|
||||
}
|
||||
// Dentro do mesmo tipo, ordena alfabeticamente
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return {
|
||||
categories: categoryList,
|
||||
transactionsByCategory,
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,3 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { cards, financialAccounts, transactions } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminPeriodFilters,
|
||||
excludeAutoGeneratedEntryNotes,
|
||||
} from "@/features/dashboard/transaction-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type TopEstablishment = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -19,78 +9,3 @@ export type TopEstablishment = {
|
||||
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 adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) {
|
||||
return { establishments: [] };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
name: transactions.name,
|
||||
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||
occurrences: sql<number>`count(${transactions.id})`,
|
||||
logo: sql<
|
||||
string | null
|
||||
>`max(coalesce(${cards.logo}, ${financialAccounts.logo}))`,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(cards, eq(transactions.cardId, cards.id))
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminPeriodFilters({
|
||||
userId,
|
||||
period,
|
||||
adminPayerId,
|
||||
}),
|
||||
eq(transactions.transactionType, "Despesa"),
|
||||
excludeAutoGeneratedEntryNotes(),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.name)
|
||||
.orderBy(
|
||||
sql`count(${transactions.id}) DESC`,
|
||||
sql`ABS(sum(${transactions.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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, ilike, isNull, ne, not, or } from "drizzle-orm";
|
||||
import { eq, ilike, isNull, ne, not, or } from "drizzle-orm";
|
||||
import { financialAccounts, transactions } from "@/db/schema";
|
||||
import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
@@ -12,10 +12,6 @@ type DashboardAdminFiltersParams = {
|
||||
adminPayerId: string;
|
||||
};
|
||||
|
||||
type DashboardAdminPeriodFiltersParams = DashboardAdminFiltersParams & {
|
||||
period: string;
|
||||
};
|
||||
|
||||
export const buildDashboardAdminFilters = ({
|
||||
userId,
|
||||
adminPayerId,
|
||||
@@ -25,31 +21,12 @@ export const buildDashboardAdminFilters = ({
|
||||
eq(transactions.payerId, adminPayerId),
|
||||
] as const;
|
||||
|
||||
export const buildDashboardAdminPeriodFilters = ({
|
||||
userId,
|
||||
period,
|
||||
adminPayerId,
|
||||
}: DashboardAdminPeriodFiltersParams) =>
|
||||
[
|
||||
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
||||
eq(transactions.period, period),
|
||||
] as const;
|
||||
|
||||
export const excludeAutoInvoiceEntries = () =>
|
||||
or(
|
||||
isNull(transactions.note),
|
||||
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
|
||||
);
|
||||
|
||||
export const excludeAutoGeneratedEntryNotes = () =>
|
||||
or(
|
||||
isNull(transactions.note),
|
||||
and(
|
||||
ne(transactions.note, INITIAL_BALANCE_NOTE),
|
||||
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
|
||||
),
|
||||
);
|
||||
|
||||
export const excludeInitialBalanceWhenConfigured = () =>
|
||||
or(
|
||||
isNull(transactions.note),
|
||||
|
||||
@@ -18,25 +18,25 @@ import {
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { AttachmentsWidget } from "@/features/dashboard/components/attachments-widget";
|
||||
import { BillWidget } from "@/features/dashboard/components/bill-widget";
|
||||
import { CategoryTrendsWidget } from "@/features/dashboard/components/category-trends-widget";
|
||||
import { ExpensesByCategoryWidgetWithChart } from "@/features/dashboard/components/expenses-by-category-widget-with-chart";
|
||||
import { GoalsProgressWidget } from "@/features/dashboard/components/goals-progress-widget";
|
||||
import { InboxWidget } from "@/features/dashboard/components/inbox-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 { SpendingOverviewWidget } from "@/features/dashboard/components/spending-overview-widget";
|
||||
import type { WidgetPreferences } from "@/features/dashboard/widgets/actions";
|
||||
import { AttachmentsWidget } from "@/features/dashboard/components/widgets/attachments-widget";
|
||||
import { BillWidget } from "@/features/dashboard/components/widgets/bill-widget";
|
||||
import { CategoryTrendsWidget } from "@/features/dashboard/components/widgets/category-trends-widget";
|
||||
import { ExpensesByCategoryWidgetWithChart } from "@/features/dashboard/components/widgets/expenses-by-category-widget-with-chart";
|
||||
import { GoalsProgressWidget } from "@/features/dashboard/components/widgets/goals-progress-widget";
|
||||
import { InboxWidget } from "@/features/dashboard/components/widgets/inbox-widget";
|
||||
import { IncomeByCategoryWidgetWithChart } from "@/features/dashboard/components/widgets/income-by-category-widget-with-chart";
|
||||
import { IncomeExpenseBalanceWidget } from "@/features/dashboard/components/widgets/income-expense-balance-widget";
|
||||
import { InstallmentExpensesWidget } from "@/features/dashboard/components/widgets/installment-expenses-widget";
|
||||
import { InvoicesWidget } from "@/features/dashboard/components/widgets/invoices-widget";
|
||||
import { MyAccountsWidget } from "@/features/dashboard/components/widgets/my-accounts-widget";
|
||||
import { NotesWidget } from "@/features/dashboard/components/widgets/notes-widget";
|
||||
import { PayersWidget } from "@/features/dashboard/components/widgets/payers-widget";
|
||||
import { PaymentOverviewWidget } from "@/features/dashboard/components/widgets/payment-overview-widget";
|
||||
import { PaymentStatusWidget } from "@/features/dashboard/components/widgets/payment-status-widget";
|
||||
import { PurchasesByCategoryWidget } from "@/features/dashboard/components/widgets/purchases-by-category-widget";
|
||||
import { RecurringExpensesWidget } from "@/features/dashboard/components/widgets/recurring-expenses-widget";
|
||||
import { SpendingOverviewWidget } from "@/features/dashboard/components/widgets/spending-overview-widget";
|
||||
import type { WidgetPreferences } from "@/features/dashboard/widget-registry/widget-actions";
|
||||
import type { SelectOption } from "@/features/transactions/components/types";
|
||||
import type { DashboardData } from "../fetch-dashboard-data";
|
||||
|
||||
Reference in New Issue
Block a user