mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +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;
|
excludeFromBalance: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardAccountsSnapshot = {
|
type DashboardAccountsSnapshot = {
|
||||||
totalBalance: number;
|
totalBalance: number;
|
||||||
accounts: DashboardAccount[];
|
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 { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
||||||
import type { PaymentDialogState } from "@/features/dashboard/use-payment-dialog-controller";
|
import type { PaymentDialogState } from "@/features/dashboard/payments/use-payment-dialog-controller";
|
||||||
import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date";
|
import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date";
|
||||||
import {
|
import {
|
||||||
buildFinancialStatusLabel,
|
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,
|
type BillDialogState,
|
||||||
getCurrentBillDateString,
|
getCurrentBillDateString,
|
||||||
markBillAsSettled,
|
markBillAsSettled,
|
||||||
} from "@/features/dashboard/bills-helpers";
|
} from "@/features/dashboard/bills/bills-helpers";
|
||||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
||||||
import {
|
import {
|
||||||
type PaymentDialogController,
|
type PaymentDialogController,
|
||||||
usePaymentDialogController,
|
usePaymentDialogController,
|
||||||
} from "@/features/dashboard/use-payment-dialog-controller";
|
} from "@/features/dashboard/payments/use-payment-dialog-controller";
|
||||||
import { toggleTransactionSettlementAction } from "@/features/transactions/actions";
|
import { toggleTransactionSettlementAction } from "@/features/transactions/actions";
|
||||||
|
|
||||||
const EMPTY_BILLS: DashboardBill[] = [];
|
const EMPTY_BILLS: DashboardBill[] = [];
|
||||||
|
|
||||||
export type BillWidgetController = Omit<
|
type BillWidgetController = Omit<
|
||||||
PaymentDialogController<DashboardBill>,
|
PaymentDialogController<DashboardBill>,
|
||||||
"selectedItem"
|
"selectedItem"
|
||||||
> & {
|
> & {
|
||||||
@@ -51,7 +51,7 @@ type UniqueCategory = {
|
|||||||
icon: string | null;
|
icon: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchAllCategories(
|
async function fetchAllCategories(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<CategoryOption[]> {
|
): Promise<CategoryOption[]> {
|
||||||
const result = await db
|
const result = await db
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildCategoryBreakdownData,
|
buildCategoryBreakdownData,
|
||||||
type DashboardCategoryBreakdownData,
|
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 { ExpensesByCategoryData } from "@/features/dashboard/categories/expenses-by-category-queries";
|
||||||
import type { IncomeByCategoryData } from "@/features/dashboard/categories/income-by-category-queries";
|
import type { IncomeByCategoryData } from "@/features/dashboard/categories/income-by-category-queries";
|
||||||
import type {
|
import type {
|
||||||
GoalProgressCategory,
|
GoalProgressCategory,
|
||||||
GoalProgressItem,
|
GoalProgressItem,
|
||||||
GoalsProgressData,
|
GoalsProgressData,
|
||||||
} from "@/features/dashboard/goals-progress-queries";
|
} from "@/features/dashboard/goals-progress/goals-progress-queries";
|
||||||
import {
|
import {
|
||||||
buildDashboardAdminFilters,
|
buildDashboardAdminFilters,
|
||||||
excludeAutoInvoiceEntries,
|
excludeAutoInvoiceEntries,
|
||||||
@@ -50,7 +50,7 @@ type BudgetSnapshotRow = {
|
|||||||
amount: string | number | null;
|
amount: string | number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardCategoryOverview = {
|
type DashboardCategoryOverview = {
|
||||||
goalsProgressData: GoalsProgressData;
|
goalsProgressData: GoalsProgressData;
|
||||||
incomeByCategoryData: IncomeByCategoryData;
|
incomeByCategoryData: IncomeByCategoryData;
|
||||||
expensesByCategoryData: ExpensesByCategoryData;
|
expensesByCategoryData: ExpensesByCategoryData;
|
||||||
@@ -1,82 +1,3 @@
|
|||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
import type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown-helpers";
|
||||||
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";
|
|
||||||
|
|
||||||
export type CategoryExpenseItem = DashboardCategoryBreakdownItem;
|
|
||||||
export type ExpensesByCategoryData = DashboardCategoryBreakdownData;
|
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 type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown-helpers";
|
||||||
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";
|
|
||||||
|
|
||||||
export type CategoryIncomeItem = DashboardCategoryBreakdownItem;
|
|
||||||
export type IncomeByCategoryData = DashboardCategoryBreakdownData;
|
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,
|
buildBillStatusLabel,
|
||||||
buildBillWidgetStatusLabel,
|
buildBillWidgetStatusLabel,
|
||||||
isBillOverdue,
|
isBillOverdue,
|
||||||
} from "@/features/dashboard/bills-helpers";
|
} from "@/features/dashboard/bills/bills-helpers";
|
||||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
||||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
@@ -82,8 +82,8 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
|
|||||||
onClick={() => onPay(bill.id)}
|
onClick={() => onPay(bill.id)}
|
||||||
>
|
>
|
||||||
{bill.isSettled ? (
|
{bill.isSettled ? (
|
||||||
<span className="flex items-center gap-1 text-success">
|
<span className="flex items-center gap-0.5 text-success">
|
||||||
<RiCheckboxCircleFill className="size-4" /> Pago
|
<RiCheckboxCircleFill className="size-3.5" /> Pago
|
||||||
</span>
|
</span>
|
||||||
) : overdue ? (
|
) : overdue ? (
|
||||||
<span className="overdue-blink">
|
<span className="overdue-blink">
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
type BillDialogState,
|
type BillDialogState,
|
||||||
formatBillDateLabel,
|
formatBillDateLabel,
|
||||||
getBillStatusBadgeVariant,
|
getBillStatusBadgeVariant,
|
||||||
} from "@/features/dashboard/bills-helpers";
|
} from "@/features/dashboard/bills/bills-helpers";
|
||||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { PaymentSuccess } from "@/shared/components/payment-success";
|
import { PaymentSuccess } from "@/shared/components/payment-success";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { RiBarcodeFill } from "@remixicon/react";
|
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 { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||||
import { BillListItem } from "./bill-list-item";
|
import { BillListItem } from "./bill-list-item";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { BillDialogState } from "@/features/dashboard/bills-helpers";
|
import type { BillDialogState } from "@/features/dashboard/bills/bills-helpers";
|
||||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
||||||
import { BillPaymentDialog } from "./bill-payment-dialog";
|
import { BillPaymentDialog } from "./bill-payment-dialog";
|
||||||
import { BillsList } from "./bills-list";
|
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";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RiArrowDownSFill,
|
|
||||||
RiArrowUpSFill,
|
|
||||||
RiExternalLinkLine,
|
|
||||||
RiListUnordered,
|
RiListUnordered,
|
||||||
RiPieChart2Line,
|
RiPieChart2Line,
|
||||||
RiPieChartLine,
|
RiPieChartLine,
|
||||||
RiWallet3Line,
|
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import Link from "next/link";
|
import { useState } from "react";
|
||||||
import { useMemo, useState } from "react";
|
import type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown-helpers";
|
||||||
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 {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
@@ -23,9 +14,9 @@ import {
|
|||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@/shared/components/ui/tabs";
|
} from "@/shared/components/ui/tabs";
|
||||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
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 { formatPeriodForUrl } from "@/shared/utils/period";
|
||||||
|
import { CategoryBreakdownChart } from "./category-breakdown-chart";
|
||||||
|
import { CategoryBreakdownList } from "./category-breakdown-list";
|
||||||
|
|
||||||
type CategoryBreakdownVariant = "income" | "expense";
|
type CategoryBreakdownVariant = "income" | "expense";
|
||||||
|
|
||||||
@@ -35,16 +26,6 @@ type CategoryBreakdownWidgetViewProps = {
|
|||||||
variant: CategoryBreakdownVariant;
|
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 = {
|
const VARIANT_CONFIG = {
|
||||||
income: {
|
income: {
|
||||||
emptyTitle: "Nenhuma receita encontrada",
|
emptyTitle: "Nenhuma receita encontrada",
|
||||||
@@ -52,10 +33,7 @@ const VARIANT_CONFIG = {
|
|||||||
"Quando houver receitas registradas, elas aparecerão aqui.",
|
"Quando houver receitas registradas, elas aparecerão aqui.",
|
||||||
shareLabel: "receita total",
|
shareLabel: "receita total",
|
||||||
percentageDigits: 1,
|
percentageDigits: 1,
|
||||||
changeClassName: {
|
positiveTrend: "up",
|
||||||
increase: "text-success",
|
|
||||||
decrease: "text-destructive",
|
|
||||||
},
|
|
||||||
includeBudgetAmount: true,
|
includeBudgetAmount: true,
|
||||||
},
|
},
|
||||||
expense: {
|
expense: {
|
||||||
@@ -64,21 +42,11 @@ const VARIANT_CONFIG = {
|
|||||||
"Quando houver despesas registradas, elas aparecerão aqui.",
|
"Quando houver despesas registradas, elas aparecerão aqui.",
|
||||||
shareLabel: "despesa total",
|
shareLabel: "despesa total",
|
||||||
percentageDigits: 0,
|
percentageDigits: 0,
|
||||||
changeClassName: {
|
positiveTrend: "down",
|
||||||
increase: "text-destructive",
|
|
||||||
decrease: "text-success",
|
|
||||||
},
|
|
||||||
includeBudgetAmount: false,
|
includeBudgetAmount: false,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const formatPercentage = (value: number, digits: number) =>
|
|
||||||
formatPercentageValue(value, {
|
|
||||||
minimumFractionDigits: digits,
|
|
||||||
maximumFractionDigits: digits,
|
|
||||||
absolute: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function CategoryBreakdownWidgetView({
|
export function CategoryBreakdownWidgetView({
|
||||||
data,
|
data,
|
||||||
period,
|
period,
|
||||||
@@ -88,78 +56,6 @@ export function CategoryBreakdownWidgetView({
|
|||||||
const periodParam = formatPeriodForUrl(period);
|
const periodParam = formatPeriodForUrl(period);
|
||||||
const config = VARIANT_CONFIG[variant];
|
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) {
|
if (data.categories.length === 0) {
|
||||||
return (
|
return (
|
||||||
<WidgetEmptyState
|
<WidgetEmptyState
|
||||||
@@ -178,11 +74,17 @@ export function CategoryBreakdownWidgetView({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<TabsList className="grid grid-cols-2">
|
<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" />
|
<RiListUnordered className="mr-1 size-3.5" />
|
||||||
Lista
|
Lista
|
||||||
</TabsTrigger>
|
</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" />
|
<RiPieChart2Line className="mr-1 size-3.5" />
|
||||||
Gráfico
|
Gráfico
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -190,195 +92,18 @@ export function CategoryBreakdownWidgetView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="list" className="mt-0">
|
<TabsContent value="list" className="mt-0">
|
||||||
<div>
|
<CategoryBreakdownList
|
||||||
{data.categories.map((category, index) => {
|
categories={data.categories}
|
||||||
const hasIncrease =
|
periodParam={periodParam}
|
||||||
category.percentageChange !== null &&
|
config={config}
|
||||||
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>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="chart" className="mt-0">
|
<TabsContent value="chart" className="mt-0">
|
||||||
<div className="flex items-center gap-4">
|
<CategoryBreakdownChart
|
||||||
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
|
categories={data.categories}
|
||||||
<PieChart>
|
percentageDigits={config.percentageDigits}
|
||||||
<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>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,19 +25,19 @@ import {
|
|||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { SortableWidget } from "@/features/dashboard/components/sortable-widget";
|
import { SortableWidget } from "@/features/dashboard/components/widgets/sortable-widget";
|
||||||
import { WidgetSettingsDialog } from "@/features/dashboard/components/widget-settings-dialog";
|
import { WidgetSettingsDialog } from "@/features/dashboard/components/widgets/widget-settings-dialog";
|
||||||
import type { DashboardData } from "@/features/dashboard/fetch-dashboard-data";
|
import type { DashboardData } from "@/features/dashboard/fetch-dashboard-data";
|
||||||
import {
|
import {
|
||||||
resetWidgetPreferences,
|
resetWidgetPreferences,
|
||||||
updateWidgetPreferences,
|
updateWidgetPreferences,
|
||||||
type WidgetPreferences,
|
type WidgetPreferences,
|
||||||
} from "@/features/dashboard/widgets/actions";
|
} from "@/features/dashboard/widget-registry/widget-actions";
|
||||||
import {
|
import {
|
||||||
type DashboardWidgetQuickActionOptions,
|
type DashboardWidgetQuickActionOptions,
|
||||||
type WidgetConfig,
|
type WidgetConfig,
|
||||||
widgetsConfig,
|
widgetsConfig,
|
||||||
} from "@/features/dashboard/widgets/widgets-config";
|
} from "@/features/dashboard/widget-registry/widget-config";
|
||||||
import { NoteDialog } from "@/features/notes/components/note-dialog";
|
import { NoteDialog } from "@/features/notes/components/note-dialog";
|
||||||
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
||||||
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
|
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
RiArrowDownLine,
|
RiArrowLeftRightLine,
|
||||||
RiArrowDownSFill,
|
RiArrowRightDownLine,
|
||||||
RiArrowUpLine,
|
RiArrowRightUpLine,
|
||||||
RiArrowUpSFill,
|
RiCalendar2Line,
|
||||||
RiCalendarCheckLine,
|
|
||||||
RiScalesLine,
|
|
||||||
RiSubtractLine,
|
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-card-info-button";
|
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 MoneyValues from "@/shared/components/money-values";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -34,7 +32,7 @@ const CARDS = [
|
|||||||
label: "Receitas",
|
label: "Receitas",
|
||||||
subtitle: "Entradas do período",
|
subtitle: "Entradas do período",
|
||||||
key: "receitas",
|
key: "receitas",
|
||||||
icon: RiArrowDownLine,
|
icon: RiArrowRightDownLine,
|
||||||
invertTrend: false,
|
invertTrend: false,
|
||||||
iconClass: "text-success",
|
iconClass: "text-success",
|
||||||
helpTitle: "Como calculamos receitas",
|
helpTitle: "Como calculamos receitas",
|
||||||
@@ -50,7 +48,7 @@ const CARDS = [
|
|||||||
label: "Despesas",
|
label: "Despesas",
|
||||||
subtitle: "Saídas do período",
|
subtitle: "Saídas do período",
|
||||||
key: "despesas",
|
key: "despesas",
|
||||||
icon: RiArrowUpLine,
|
icon: RiArrowRightUpLine,
|
||||||
invertTrend: true,
|
invertTrend: true,
|
||||||
iconClass: "text-destructive",
|
iconClass: "text-destructive",
|
||||||
helpTitle: "Como calculamos despesas",
|
helpTitle: "Como calculamos despesas",
|
||||||
@@ -66,7 +64,7 @@ const CARDS = [
|
|||||||
label: "Balanço",
|
label: "Balanço",
|
||||||
subtitle: "Receitas, despesas e ajustes entre contas",
|
subtitle: "Receitas, despesas e ajustes entre contas",
|
||||||
key: "balanco",
|
key: "balanco",
|
||||||
icon: RiScalesLine,
|
icon: RiArrowLeftRightLine,
|
||||||
invertTrend: false,
|
invertTrend: false,
|
||||||
iconClass: "text-warning",
|
iconClass: "text-warning",
|
||||||
helpTitle: "Como calculamos o balanço",
|
helpTitle: "Como calculamos o balanço",
|
||||||
@@ -81,7 +79,7 @@ const CARDS = [
|
|||||||
label: "Previsto",
|
label: "Previsto",
|
||||||
subtitle: "Saldo acumulado projetado",
|
subtitle: "Saldo acumulado projetado",
|
||||||
key: "previsto",
|
key: "previsto",
|
||||||
icon: RiCalendarCheckLine,
|
icon: RiCalendar2Line,
|
||||||
invertTrend: false,
|
invertTrend: false,
|
||||||
iconClass: "text-cyan-600",
|
iconClass: "text-cyan-600",
|
||||||
helpTitle: "Como calculamos o previsto",
|
helpTitle: "Como calculamos o previsto",
|
||||||
@@ -94,12 +92,6 @@ const CARDS = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const TREND_ICONS = {
|
|
||||||
up: RiArrowUpSFill,
|
|
||||||
down: RiArrowDownSFill,
|
|
||||||
flat: RiSubtractLine,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const getTrend = (current: number, previous: number): Trend => {
|
const getTrend = (current: number, previous: number): Trend => {
|
||||||
const diff = current - previous;
|
const diff = current - previous;
|
||||||
if (diff > TREND_THRESHOLD) return "up";
|
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) {
|
export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
<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 metric = metrics[key];
|
||||||
const trend = getTrend(metric.current, metric.previous);
|
const trend = getTrend(metric.current, metric.previous);
|
||||||
const TrendIcon = TREND_ICONS[trend];
|
|
||||||
const trendBadgeClass = getTrendBadgeClass(trend, invertTrend);
|
|
||||||
const percentChange = getPercentChange(
|
const percentChange = getPercentChange(
|
||||||
metric.current,
|
metric.current,
|
||||||
metric.previous,
|
metric.previous,
|
||||||
@@ -157,23 +141,19 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={label} className="gap-2 overflow-hidden">
|
<Card key={label} className="gap-2 overflow-hidden">
|
||||||
<CardHeader>
|
<CardHeader className="gap-1">
|
||||||
<div className="flex items-start justify-between">
|
<CardTitle className="flex items-center gap-1">
|
||||||
<div>
|
<Icon className={cn("size-4", iconClass)} aria-hidden />
|
||||||
<CardTitle className="flex items-center gap-1.5 ">
|
{label}
|
||||||
<Icon className={cn("size-4", iconClass)} aria-hidden />
|
<MetricsCardInfoButton
|
||||||
{label}
|
label={label}
|
||||||
<MetricsCardInfoButton
|
helpTitle={helpTitle}
|
||||||
label={label}
|
helpLines={helpLines}
|
||||||
helpTitle={helpTitle}
|
/>
|
||||||
helpLines={helpLines}
|
</CardTitle>
|
||||||
/>
|
<CardDescription className="mt-1 tracking-tight">
|
||||||
</CardTitle>
|
{subtitle}
|
||||||
<CardDescription className="mt-1.5 tracking-tight">
|
</CardDescription>
|
||||||
{subtitle}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator className="mt-1" />
|
<Separator className="mt-1" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
@@ -183,15 +163,14 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
|||||||
className="text-2xl leading-none font-medium"
|
className="text-2xl leading-none font-medium"
|
||||||
amount={metric.current}
|
amount={metric.current}
|
||||||
/>
|
/>
|
||||||
<div
|
<PercentageChangeIndicator
|
||||||
className={cn(
|
trend={trend}
|
||||||
"inline-flex items-center gap-1 text-xs font-medium",
|
label={percentChange}
|
||||||
trendBadgeClass,
|
positiveTrend={invertTrend ? "down" : "up"}
|
||||||
)}
|
showFlatIcon
|
||||||
>
|
className="gap-1"
|
||||||
<TrendIcon className="size-3.5" aria-hidden />
|
iconClassName="size-3.5"
|
||||||
<span>{percentChange}</span>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground">
|
<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 = {
|
type DashboardWelcomeProps = {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
@@ -10,13 +10,11 @@ export function DashboardWelcome({ name }: DashboardWelcomeProps) {
|
|||||||
const greeting = getGreeting();
|
const greeting = getGreeting();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-4">
|
<section className="py-4 space-y-1">
|
||||||
<div>
|
<h1 className="text-xl tracking-tight">
|
||||||
<h1 className="text-xl tracking-tight">
|
<span className="text-muted-foreground">{greeting},</span> {displayName}
|
||||||
{greeting}, {displayName}
|
</h1>
|
||||||
</h1>
|
<h2 className="text-sm text-muted-foreground">{formattedDate}</h2>
|
||||||
<h2 className="mt-1 text-sm text-muted-foreground">{formattedDate}</h2>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { RiPencilLine } from "@remixicon/react";
|
import { RiPencilLine } from "@remixicon/react";
|
||||||
|
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||||
import {
|
import {
|
||||||
clampGoalProgress,
|
clampGoalProgress,
|
||||||
formatGoalProgressPercentage,
|
formatGoalProgressPercentage,
|
||||||
} from "@/features/dashboard/goals-progress-helpers";
|
} from "@/features/dashboard/goals-progress/goals-progress-helpers";
|
||||||
import type { GoalProgressItem as GoalProgressItemData } from "@/features/dashboard/goals-progress-queries";
|
import type { GoalProgressItem as GoalProgressItemData } from "@/features/dashboard/goals-progress/goals-progress-queries";
|
||||||
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
|
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
@@ -22,12 +23,6 @@ export function GoalProgressItem({
|
|||||||
}: GoalProgressItemProps) {
|
}: GoalProgressItemProps) {
|
||||||
const progressValue = clampGoalProgress(item.usedPercentage, 0, 100);
|
const progressValue = clampGoalProgress(item.usedPercentage, 0, 100);
|
||||||
const percentageDelta = item.usedPercentage - 100;
|
const percentageDelta = item.usedPercentage - 100;
|
||||||
const deltaColor =
|
|
||||||
percentageDelta > 0
|
|
||||||
? "text-destructive"
|
|
||||||
: percentageDelta < 0
|
|
||||||
? "text-success"
|
|
||||||
: "text-muted-foreground";
|
|
||||||
const isExceeded = item.status === "exceeded";
|
const isExceeded = item.status === "exceeded";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,9 +42,12 @@ export function GoalProgressItem({
|
|||||||
<MoneyValues className="font-medium" amount={item.spentAmount} />{" "}
|
<MoneyValues className="font-medium" amount={item.spentAmount} />{" "}
|
||||||
de{" "}
|
de{" "}
|
||||||
<MoneyValues className="font-medium" amount={item.budgetAmount} />
|
<MoneyValues className="font-medium" amount={item.budgetAmount} />
|
||||||
<span className={`ml-1.5 font-medium ${deltaColor}`}>
|
<PercentageChangeIndicator
|
||||||
{formatGoalProgressPercentage(percentageDelta, true)}
|
value={percentageDelta}
|
||||||
</span>
|
label={formatGoalProgressPercentage(percentageDelta, true)}
|
||||||
|
positiveTrend="down"
|
||||||
|
className="ml-1.5 align-middle"
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { RiFundsLine } from "@remixicon/react";
|
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 { 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 = {
|
type GoalsProgressListProps = {
|
||||||
items: GoalProgressItem[];
|
items: GoalProgressItem[];
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
GoalProgressItem,
|
GoalProgressItem,
|
||||||
GoalsProgressData,
|
GoalsProgressData,
|
||||||
} from "@/features/dashboard/goals-progress-queries";
|
} from "@/features/dashboard/goals-progress/goals-progress-queries";
|
||||||
import { GoalsProgressList } from "./goals-progress-list";
|
import { GoalsProgressList } from "./goals-progress-list";
|
||||||
import { GoalsProgressWidgetDialogs } from "./goals-progress-widget-dialogs";
|
import { GoalsProgressWidgetDialogs } from "./goals-progress-widget-dialogs";
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export function InstallmentAnalysisPage({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Card de resumo principal */}
|
{/* 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">
|
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Se você pagar tudo que está selecionado:
|
Se você pagar tudo que está selecionado:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
|
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 { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { Progress } from "@/shared/components/ui/progress";
|
import { Progress } from "@/shared/components/ui/progress";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react";
|
import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||||
import {
|
import {
|
||||||
buildInvoiceDetailsHref,
|
buildInvoiceDetailsHref,
|
||||||
buildInvoiceInitials,
|
buildInvoiceInitials,
|
||||||
@@ -8,8 +9,8 @@ import {
|
|||||||
getInvoiceShareLabel,
|
getInvoiceShareLabel,
|
||||||
parseInvoiceDueDate,
|
parseInvoiceDueDate,
|
||||||
parseInvoiceWidgetDueDate,
|
parseInvoiceWidgetDueDate,
|
||||||
} from "@/features/dashboard/invoices-helpers";
|
} from "@/features/dashboard/invoices/invoices-helpers";
|
||||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -83,7 +84,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
|||||||
{hasBreakdown ? (
|
{hasBreakdown ? (
|
||||||
<HoverCard openDelay={150}>
|
<HoverCard openDelay={150}>
|
||||||
<HoverCardTrigger asChild>{linkNode}</HoverCardTrigger>
|
<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">
|
<p className="text-xs text-muted-foreground">
|
||||||
Distribuição por pagador
|
Distribuição por pagador
|
||||||
</p>
|
</p>
|
||||||
@@ -115,11 +116,14 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<MoneyValues
|
||||||
className="font-medium"
|
className="font-medium"
|
||||||
amount={share.amount}
|
amount={share.amount}
|
||||||
/>
|
/>
|
||||||
|
<PercentageChangeIndicator
|
||||||
|
value={share.percentageChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -179,8 +183,8 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
|||||||
onClick={() => onPay(invoice.id)}
|
onClick={() => onPay(invoice.id)}
|
||||||
>
|
>
|
||||||
{isPaid ? (
|
{isPaid ? (
|
||||||
<span className="flex items-center gap-1 text-success">
|
<span className="flex items-center gap-0.5 text-success">
|
||||||
<RiCheckboxCircleFill className="size-4" /> Pago
|
<RiCheckboxCircleFill className="size-3.5" /> Pago
|
||||||
</span>
|
</span>
|
||||||
) : isOverdue ? (
|
) : isOverdue ? (
|
||||||
<span className="overdue-blink">
|
<span className="overdue-blink">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Image from "next/image";
|
|||||||
import {
|
import {
|
||||||
buildInvoiceInitials,
|
buildInvoiceInitials,
|
||||||
type InvoiceLogoTone,
|
type InvoiceLogoTone,
|
||||||
} from "@/features/dashboard/invoices-helpers";
|
} from "@/features/dashboard/invoices/invoices-helpers";
|
||||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
getInvoiceStatusBadgeVariant,
|
getInvoiceStatusBadgeVariant,
|
||||||
type InvoiceDialogState,
|
type InvoiceDialogState,
|
||||||
parseInvoiceDueDate,
|
parseInvoiceDueDate,
|
||||||
} from "@/features/dashboard/invoices-helpers";
|
} from "@/features/dashboard/invoices/invoices-helpers";
|
||||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { PaymentSuccess } from "@/shared/components/payment-success";
|
import { PaymentSuccess } from "@/shared/components/payment-success";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { RiBillLine } from "@remixicon/react";
|
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 { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||||
import { InvoiceListItem } from "./invoice-list-item";
|
import { InvoiceListItem } from "./invoice-list-item";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { InvoiceDialogState } from "@/features/dashboard/invoices-helpers";
|
import type { InvoiceDialogState } from "@/features/dashboard/invoices/invoices-helpers";
|
||||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
||||||
import { InvoicePaymentDialog } from "./invoice-payment-dialog";
|
import { InvoicePaymentDialog } from "./invoice-payment-dialog";
|
||||||
import { InvoicesList } from "./invoices-list";
|
import { InvoicesList } from "./invoices-list";
|
||||||
|
|
||||||
|
|||||||
@@ -29,14 +29,12 @@ export function NoteListItem({
|
|||||||
{displayTitle}
|
{displayTitle}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<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)}
|
{getNoteTasksSummary(note)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{createdAtLabel ? (
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
{createdAtLabel}
|
||||||
{createdAtLabel}
|
</p>
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
|
|||||||
import {
|
import {
|
||||||
formatPaymentBreakdownPercentage,
|
formatPaymentBreakdownPercentage,
|
||||||
formatPaymentBreakdownTransactionsLabel,
|
formatPaymentBreakdownTransactionsLabel,
|
||||||
} from "@/features/dashboard/payment-breakdown-formatters";
|
} from "@/features/dashboard/payments/payment-breakdown-formatters";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { Progress } from "@/shared/components/ui/progress";
|
import { Progress } from "@/shared/components/ui/progress";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
|
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 { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||||
import {
|
import {
|
||||||
@@ -31,11 +31,17 @@ export function PaymentOverviewWidgetView({
|
|||||||
return (
|
return (
|
||||||
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
|
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
|
||||||
<TabsList className="grid grid-cols-2">
|
<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" />
|
<RiSlideshowLine className="mr-1 size-3.5" />
|
||||||
Condições
|
Condições
|
||||||
</TabsTrigger>
|
</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" />
|
<RiMoneyDollarCircleLine className="mr-1 size-3.5" />
|
||||||
Formas
|
Formas
|
||||||
</TabsTrigger>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
||||||
import { useBillWidgetController } from "@/features/dashboard/use-bill-widget-controller";
|
import { useBillWidgetController } from "@/features/dashboard/bills/use-bill-widget-controller";
|
||||||
import { BillsWidgetView } from "./bills/bills-widget-view";
|
import { BillsWidgetView } from "../bills/bills-widget-view";
|
||||||
|
|
||||||
type BillWidgetProps = {
|
type BillWidgetProps = {
|
||||||
bills?: DashboardBill[];
|
bills?: DashboardBill[];
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { RiLineChartLine } from "@remixicon/react";
|
||||||
RiArrowDownSFill,
|
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
|
||||||
RiArrowUpSFill,
|
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||||
RiLineChartLine,
|
|
||||||
} from "@remixicon/react";
|
|
||||||
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown";
|
|
||||||
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
|
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { formatPercentage } from "@/shared/utils/percentage";
|
||||||
|
|
||||||
type CategoryTrendsWidgetProps = {
|
type CategoryTrendsWidgetProps = {
|
||||||
categories: DashboardCategoryBreakdownItem[];
|
categories: DashboardCategoryBreakdownItem[];
|
||||||
@@ -40,7 +37,6 @@ export function CategoryTrendsWidget({
|
|||||||
<ul className="flex flex-col space-y-1">
|
<ul className="flex flex-col space-y-1">
|
||||||
{trending.map((category) => {
|
{trending.map((category) => {
|
||||||
const change = category.percentageChange ?? 0;
|
const change = category.percentageChange ?? 0;
|
||||||
const isUp = change > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={category.categoryId}>
|
<li key={category.categoryId}>
|
||||||
@@ -62,19 +58,17 @@ export function CategoryTrendsWidget({
|
|||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<PercentageChangeIndicator
|
||||||
className={cn(
|
value={change}
|
||||||
"inline-flex shrink-0 items-center gap-0.5 font-semibold text-sm",
|
label={formatPercentage(change, {
|
||||||
isUp ? " text-destructive" : " text-success",
|
absolute: true,
|
||||||
)}
|
minimumFractionDigits: 0,
|
||||||
>
|
maximumFractionDigits: 0,
|
||||||
{isUp ? (
|
})}
|
||||||
<RiArrowUpSFill className="size-3.5" />
|
positiveTrend="down"
|
||||||
) : (
|
className="shrink-0 text-sm font-semibold"
|
||||||
<RiArrowDownSFill className="size-3.5" />
|
iconClassName="size-3.5"
|
||||||
)}
|
/>
|
||||||
{Math.abs(change).toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ExpensesByCategoryData } from "@/features/dashboard/categories/expenses-by-category-queries";
|
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 = {
|
type ExpensesByCategoryWidgetWithChartProps = {
|
||||||
data: ExpensesByCategoryData;
|
data: ExpensesByCategoryData;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { GoalsProgressData } from "@/features/dashboard/goals-progress-queries";
|
import type { GoalsProgressData } from "@/features/dashboard/goals-progress/goals-progress-queries";
|
||||||
import { useGoalsProgressWidgetController } from "@/features/dashboard/use-goals-progress-widget-controller";
|
import { useGoalsProgressWidgetController } from "@/features/dashboard/goals-progress/use-goals-progress-widget-controller";
|
||||||
import { GoalsProgressWidgetView } from "./goals-progress/goals-progress-widget-view";
|
import { GoalsProgressWidgetView } from "../goals-progress/goals-progress-widget-view";
|
||||||
|
|
||||||
type GoalsProgressWidgetProps = {
|
type GoalsProgressWidgetProps = {
|
||||||
data: GoalsProgressData;
|
data: GoalsProgressData;
|
||||||
@@ -10,7 +10,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { DashboardInboxSnapshot } from "@/features/dashboard/inbox-snapshot-queries";
|
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 {
|
import {
|
||||||
discardInboxItemAction,
|
discardInboxItemAction,
|
||||||
markInboxAsProcessedAction,
|
markInboxAsProcessedAction,
|
||||||
@@ -178,7 +178,7 @@ export function InboxWidget({
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex items-center justify-between py-1.5"
|
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
|
<Image
|
||||||
src={displayLogo}
|
src={displayLogo}
|
||||||
alt={item.sourceAppName ?? ""}
|
alt={item.sourceAppName ?? ""}
|
||||||
@@ -188,9 +188,11 @@ export function InboxWidget({
|
|||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div>
|
||||||
<p className="truncate text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
{displayName}
|
{displayName.length > 30
|
||||||
|
? `${displayName.slice(0, 30)}...`
|
||||||
|
: displayName}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
{item.sourceAppName && <span>{item.sourceAppName}</span>}
|
{item.sourceAppName && <span>{item.sourceAppName}</span>}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { IncomeByCategoryData } from "@/features/dashboard/categories/income-by-category-queries";
|
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 = {
|
type IncomeByCategoryWidgetWithChartProps = {
|
||||||
data: IncomeByCategoryData;
|
data: IncomeByCategoryData;
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { RiLineChartLine } from "@remixicon/react";
|
import { RiLineChartLine } from "@remixicon/react";
|
||||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
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 { CardContent } from "@/shared/components/ui/card";
|
||||||
import {
|
import {
|
||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
@@ -19,15 +19,15 @@ type IncomeExpenseBalanceWidgetProps = {
|
|||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
receita: {
|
receita: {
|
||||||
label: "Receita",
|
label: "Receita",
|
||||||
color: "var(--data-9)",
|
color: "var(--success)",
|
||||||
},
|
},
|
||||||
despesa: {
|
despesa: {
|
||||||
label: "Despesa",
|
label: "Despesa",
|
||||||
color: "var(--data-1)",
|
color: "var(--destructive)",
|
||||||
},
|
},
|
||||||
balanco: {
|
balanco: {
|
||||||
label: "Balanço",
|
label: "Balanço",
|
||||||
color: "var(--data-4)",
|
color: "var(--warning)",
|
||||||
},
|
},
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries";
|
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 = {
|
type InstallmentExpensesWidgetProps = {
|
||||||
data: InstallmentExpensesData;
|
data: InstallmentExpensesData;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
||||||
import { useInvoicesWidgetController } from "@/features/dashboard/use-invoices-widget-controller";
|
import { useInvoicesWidgetController } from "@/features/dashboard/invoices/use-invoices-widget-controller";
|
||||||
import { InvoicesWidgetView } from "./invoices/invoices-widget-view";
|
import { InvoicesWidgetView } from "../invoices/invoices-widget-view";
|
||||||
|
|
||||||
type InvoicesWidgetProps = {
|
type InvoicesWidgetProps = {
|
||||||
invoices: DashboardInvoice[];
|
invoices: DashboardInvoice[];
|
||||||
@@ -11,7 +11,7 @@ import Link from "next/link";
|
|||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { DashboardAccount } from "@/features/dashboard/accounts-queries";
|
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 MoneyValues from "@/shared/components/money-values";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { DashboardNote } from "@/features/dashboard/notes-queries";
|
import type { DashboardNote } from "@/features/dashboard/notes/notes-queries";
|
||||||
import { useNotesWidgetController } from "@/features/dashboard/use-notes-widget-controller";
|
import { useNotesWidgetController } from "@/features/dashboard/notes/use-notes-widget-controller";
|
||||||
import { NotesWidgetView } from "./notes/notes-widget-view";
|
import { NotesWidgetView } from "../notes/notes-widget-view";
|
||||||
|
|
||||||
type NotesWidgetProps = {
|
type NotesWidgetProps = {
|
||||||
notes: DashboardNote[];
|
notes: DashboardNote[];
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RiArrowDownSFill,
|
|
||||||
RiArrowUpSFill,
|
|
||||||
RiExternalLinkLine,
|
RiExternalLinkLine,
|
||||||
RiGroupLine,
|
RiGroupLine,
|
||||||
RiVerifiedBadgeFill,
|
RiVerifiedBadgeFill,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||||
import type { DashboardPagador } from "@/features/dashboard/payers-queries";
|
import type { DashboardPagador } from "@/features/dashboard/payers-queries";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +17,6 @@ import {
|
|||||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||||
import { buildInitials } from "@/shared/utils/initials";
|
import { buildInitials } from "@/shared/utils/initials";
|
||||||
import { formatPercentage } from "@/shared/utils/percentage";
|
|
||||||
|
|
||||||
type PayersWidgetProps = {
|
type PayersWidgetProps = {
|
||||||
payers: DashboardPagador[];
|
payers: DashboardPagador[];
|
||||||
@@ -87,25 +85,7 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
|
|||||||
className="font-medium"
|
className="font-medium"
|
||||||
amount={payer.totalExpenses}
|
amount={payer.totalExpenses}
|
||||||
/>
|
/>
|
||||||
{percentageChange !== null && (
|
<PercentageChangeIndicator value={percentageChange} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||||
import { usePaymentOverviewWidgetController } from "@/features/dashboard/use-payment-overview-widget-controller";
|
import { usePaymentOverviewWidgetController } from "@/features/dashboard/payments/use-payment-overview-widget-controller";
|
||||||
import { PaymentOverviewWidgetView } from "./payment-overview/payment-overview-widget-view";
|
import { PaymentOverviewWidgetView } from "../payment-overview/payment-overview-widget-view";
|
||||||
|
|
||||||
type PaymentOverviewWidgetProps = {
|
type PaymentOverviewWidgetProps = {
|
||||||
paymentConditionsData: PaymentConditionsData;
|
paymentConditionsData: PaymentConditionsData;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
|
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 = {
|
type PaymentStatusWidgetProps = {
|
||||||
data: PaymentStatusData;
|
data: PaymentStatusData;
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { RiArrowDownSFill, RiStore3Line } from "@remixicon/react";
|
import { RiArrowDownSFill, RiStore3Line } from "@remixicon/react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "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 { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import {
|
import {
|
||||||
@@ -37,11 +37,17 @@ export function SpendingOverviewWidget({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<TabsList className="grid grid-cols-2">
|
<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" />
|
<RiArrowUpDoubleLine className="mr-1 size-3.5" />
|
||||||
Top gastos
|
Top gastos
|
||||||
</TabsTrigger>
|
</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" />
|
<RiStore2Line className="mr-1 size-3.5" />
|
||||||
Estabelecimentos
|
Estabelecimentos
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { RiRefreshLine, RiSettings4Line } from "@remixicon/react";
|
import { RiRefreshLine, RiSettings4Line } from "@remixicon/react";
|
||||||
import { useState } from "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 { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
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 = {
|
export type InstallmentExpense = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,78 +13,3 @@ export type InstallmentExpense = {
|
|||||||
export type InstallmentExpensesData = {
|
export type InstallmentExpensesData = {
|
||||||
expenses: InstallmentExpense[];
|
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 = {
|
export type RecurringExpense = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -19,54 +9,3 @@ export type RecurringExpense = {
|
|||||||
export type RecurringExpensesData = {
|
export type RecurringExpensesData = {
|
||||||
expenses: RecurringExpense[];
|
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 = {
|
export type TopExpense = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -20,66 +10,3 @@ export type TopExpense = {
|
|||||||
export type TopExpensesData = {
|
export type TopExpensesData = {
|
||||||
expenses: TopExpense[];
|
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 { cacheLife, cacheTag } from "next/cache";
|
||||||
import { fetchAttachmentsForPeriod } from "@/features/attachments/queries";
|
import { fetchAttachmentsForPeriod } from "@/features/attachments/queries";
|
||||||
import { fetchDashboardAccounts } from "./accounts-queries";
|
import { fetchDashboardAccounts } from "./accounts-queries";
|
||||||
import { fetchDashboardCategoryOverview } from "./category-overview-queries";
|
import { fetchDashboardCategoryOverview } from "./categories/category-overview-queries";
|
||||||
import { fetchDashboardCurrentPeriodOverview } from "./current-period-overview-queries";
|
|
||||||
import { fetchDashboardInboxSnapshot } from "./inbox-snapshot-queries";
|
import { fetchDashboardInboxSnapshot } from "./inbox-snapshot-queries";
|
||||||
import { fetchDashboardInvoices } from "./invoices-queries";
|
import { fetchDashboardInvoices } from "./invoices/invoices-queries";
|
||||||
import { fetchDashboardNotes } from "./notes-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 { fetchDashboardPayers } from "./payers-queries";
|
||||||
import { fetchDashboardPeriodOverview } from "./period-overview-queries";
|
|
||||||
|
|
||||||
async function fetchDashboardDataInternal(userId: string, period: string) {
|
async function fetchDashboardDataInternal(userId: string, period: string) {
|
||||||
const [
|
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,
|
GoalProgressCategory,
|
||||||
GoalProgressItem,
|
GoalProgressItem,
|
||||||
GoalProgressStatus,
|
GoalProgressStatus,
|
||||||
} from "@/features/dashboard/goals-progress-queries";
|
} from "@/features/dashboard/goals-progress/goals-progress-queries";
|
||||||
import { formatPercentage } from "@/shared/utils/percentage";
|
import { formatPercentage } from "@/shared/utils/percentage";
|
||||||
|
|
||||||
export const clampGoalProgress = (value: number, min: number, max: number) =>
|
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 {
|
import {
|
||||||
mapGoalProgressCategoriesToBudgetCategories,
|
mapGoalProgressCategoriesToBudgetCategories,
|
||||||
mapGoalProgressItemToBudget,
|
mapGoalProgressItemToBudget,
|
||||||
} from "@/features/dashboard/goals-progress-helpers";
|
} from "@/features/dashboard/goals-progress/goals-progress-helpers";
|
||||||
import type {
|
import type {
|
||||||
GoalProgressItem,
|
GoalProgressItem,
|
||||||
GoalsProgressData,
|
GoalsProgressData,
|
||||||
} from "@/features/dashboard/goals-progress-queries";
|
} from "@/features/dashboard/goals-progress/goals-progress-queries";
|
||||||
|
|
||||||
export type GoalsProgressWidgetController = {
|
type GoalsProgressWidgetController = {
|
||||||
selectedBudget: Budget | null;
|
selectedBudget: Budget | null;
|
||||||
editOpen: boolean;
|
editOpen: boolean;
|
||||||
categories: BudgetCategory[];
|
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 { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
||||||
import type { PaymentDialogState } from "@/features/dashboard/use-payment-dialog-controller";
|
import type { PaymentDialogState } from "@/features/dashboard/payments/use-payment-dialog-controller";
|
||||||
import {
|
import {
|
||||||
INVOICE_PAYMENT_STATUS,
|
INVOICE_PAYMENT_STATUS,
|
||||||
type InvoicePaymentStatus,
|
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 { cards, invoices, payers, transactions } from "@/db/schema";
|
||||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
@@ -14,7 +14,9 @@ import {
|
|||||||
isDateOnlyPast,
|
isDateOnlyPast,
|
||||||
toDateOnlyString,
|
toDateOnlyString,
|
||||||
} from "@/shared/utils/date";
|
} from "@/shared/utils/date";
|
||||||
|
import { calculatePercentageChange } from "@/shared/utils/math";
|
||||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||||
|
import { getPreviousPeriod } from "@/shared/utils/period";
|
||||||
|
|
||||||
type RawDashboardInvoice = {
|
type RawDashboardInvoice = {
|
||||||
invoiceId: string | null;
|
invoiceId: string | null;
|
||||||
@@ -45,6 +47,7 @@ export type InvoicePagadorBreakdown = {
|
|||||||
pagadorName: string;
|
pagadorName: string;
|
||||||
pagadorAvatar: string | null;
|
pagadorAvatar: string | null;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
percentageChange: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardInvoice = {
|
export type DashboardInvoice = {
|
||||||
@@ -62,7 +65,7 @@ export type DashboardInvoice = {
|
|||||||
pagadorBreakdown: InvoicePagadorBreakdown[];
|
pagadorBreakdown: InvoicePagadorBreakdown[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardInvoicesSnapshot = {
|
type DashboardInvoicesSnapshot = {
|
||||||
invoices: DashboardInvoice[];
|
invoices: DashboardInvoice[];
|
||||||
totalPending: number;
|
totalPending: number;
|
||||||
};
|
};
|
||||||
@@ -99,6 +102,7 @@ export async function fetchDashboardInvoices(
|
|||||||
period: string,
|
period: string,
|
||||||
): Promise<DashboardInvoicesSnapshot> {
|
): Promise<DashboardInvoicesSnapshot> {
|
||||||
const today = getBusinessDateString();
|
const today = getBusinessDateString();
|
||||||
|
const previousPeriod = getPreviousPeriod(period);
|
||||||
const paymentRows = await db
|
const paymentRows = await db
|
||||||
.select({
|
.select({
|
||||||
note: transactions.note,
|
note: transactions.note,
|
||||||
@@ -203,7 +207,7 @@ export async function fetchDashboardInvoices(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
eq(transactions.period, period),
|
inArray(transactions.period, [period, previousPeriod]),
|
||||||
isNotNull(transactions.cardId),
|
isNotNull(transactions.cardId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -216,23 +220,74 @@ export async function fetchDashboardInvoices(
|
|||||||
),
|
),
|
||||||
])) as [RawDashboardInvoice[], RawInvoiceBreakdownRow[]];
|
])) 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) {
|
for (const row of breakdownRows) {
|
||||||
if (!row.cardId) {
|
if (!row.cardId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedPeriod = row.period ?? period;
|
const resolvedPeriod = row.period ?? period;
|
||||||
const amount = Math.abs(toNumber(row.amount));
|
const amount = Math.abs(toNumber(row.amount));
|
||||||
if (amount <= 0) {
|
if (amount <= 0) {
|
||||||
continue;
|
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) ?? [];
|
const current = breakdownMap.get(key) ?? [];
|
||||||
current.push({
|
current.push({
|
||||||
payerId: row.payerId ?? null,
|
payerId: share.payerId,
|
||||||
pagadorName: row.pagadorName?.trim() || "Sem pagador",
|
pagadorName: share.pagadorName,
|
||||||
pagadorAvatar: row.pagadorAvatar ?? null,
|
pagadorAvatar: share.pagadorAvatar,
|
||||||
amount,
|
amount: share.currentAmount,
|
||||||
|
percentageChange: calculatePercentageChange(
|
||||||
|
share.currentAmount,
|
||||||
|
share.previousAmount,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
breakdownMap.set(key, current);
|
breakdownMap.set(key, current);
|
||||||
}
|
}
|
||||||
@@ -5,16 +5,16 @@ import {
|
|||||||
type InvoiceDialogState,
|
type InvoiceDialogState,
|
||||||
isInvoicePaid,
|
isInvoicePaid,
|
||||||
markInvoiceAsPaid,
|
markInvoiceAsPaid,
|
||||||
} from "@/features/dashboard/invoices-helpers";
|
} from "@/features/dashboard/invoices/invoices-helpers";
|
||||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
||||||
import {
|
import {
|
||||||
type PaymentDialogController,
|
type PaymentDialogController,
|
||||||
usePaymentDialogController,
|
usePaymentDialogController,
|
||||||
} from "@/features/dashboard/use-payment-dialog-controller";
|
} from "@/features/dashboard/payments/use-payment-dialog-controller";
|
||||||
import { updateInvoicePaymentStatusAction } from "@/features/invoices/actions";
|
import { updateInvoicePaymentStatusAction } from "@/features/invoices/actions";
|
||||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||||
|
|
||||||
export type InvoicesWidgetController = Omit<
|
type InvoicesWidgetController = Omit<
|
||||||
PaymentDialogController<DashboardInvoice>,
|
PaymentDialogController<DashboardInvoice>,
|
||||||
"selectedItem"
|
"selectedItem"
|
||||||
> & {
|
> & {
|
||||||
@@ -8,9 +8,9 @@ import { getBusinessDateString } from "@/shared/utils/date";
|
|||||||
import {
|
import {
|
||||||
type DashboardNotificationsSnapshot,
|
type DashboardNotificationsSnapshot,
|
||||||
fetchDashboardNotifications,
|
fetchDashboardNotifications,
|
||||||
} from "./notifications-queries";
|
} from "./notifications/notifications-queries";
|
||||||
|
|
||||||
export type DashboardNavbarData = {
|
type DashboardNavbarData = {
|
||||||
pagadorAvatarUrl: string | null;
|
pagadorAvatarUrl: string | null;
|
||||||
preLancamentosCount: number;
|
preLancamentosCount: number;
|
||||||
notificationsSnapshot: DashboardNotificationsSnapshot;
|
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";
|
import type { Note } from "@/features/notes/components/types";
|
||||||
|
|
||||||
export const mapDashboardNoteToNote = (note: DashboardNote): Note => ({
|
export const mapDashboardNoteToNote = (note: DashboardNote): Note => ({
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { mapDashboardNotesToNotes } from "@/features/dashboard/notes-mappers";
|
import { mapDashboardNotesToNotes } from "@/features/dashboard/notes/notes-mappers";
|
||||||
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";
|
import type { Note } from "@/features/notes/components/types";
|
||||||
|
|
||||||
export type NotesWidgetController = {
|
type NotesWidgetController = {
|
||||||
mappedNotes: Note[];
|
mappedNotes: Note[];
|
||||||
noteToEdit: Note | null;
|
noteToEdit: Note | null;
|
||||||
isEditOpen: boolean;
|
isEditOpen: boolean;
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
invoices,
|
invoices,
|
||||||
transactions,
|
transactions,
|
||||||
} from "@/db/schema";
|
} 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 { db } from "@/shared/lib/db";
|
||||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||||
import { isNotificationStatesTableMissing } from "@/shared/lib/notifications/is-table-missing";
|
import { isNotificationStatesTableMissing } from "@/shared/lib/notifications/is-table-missing";
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
financialAccounts,
|
financialAccounts,
|
||||||
transactions,
|
transactions,
|
||||||
} from "@/db/schema";
|
} 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 { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||||
import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries";
|
import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries";
|
||||||
import type {
|
import type {
|
||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||||
import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-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 type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
|
||||||
import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/transaction-filters";
|
import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/transaction-filters";
|
||||||
import {
|
import {
|
||||||
@@ -67,7 +67,7 @@ type CategoryOption = PurchasesByCategoryData["categories"][number];
|
|||||||
type CategoryTransaction =
|
type CategoryTransaction =
|
||||||
PurchasesByCategoryData["transactionsByCategory"][string][number];
|
PurchasesByCategoryData["transactionsByCategory"][string][number];
|
||||||
|
|
||||||
export type DashboardCurrentPeriodOverview = {
|
type DashboardCurrentPeriodOverview = {
|
||||||
billsSnapshot: DashboardBillsSnapshot;
|
billsSnapshot: DashboardBillsSnapshot;
|
||||||
paymentStatusData: PaymentStatusData;
|
paymentStatusData: PaymentStatusData;
|
||||||
paymentConditionsData: PaymentConditionsData;
|
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 { and, asc, eq, gte, inArray, lte, sum } from "drizzle-orm";
|
||||||
import { financialAccounts, transactions } from "@/db/schema";
|
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 {
|
import type {
|
||||||
IncomeExpenseBalanceData,
|
IncomeExpenseBalanceData,
|
||||||
MonthData,
|
MonthData,
|
||||||
} from "@/features/dashboard/income-expense-balance-queries";
|
} from "@/features/dashboard/overview/income-expense-balance-queries";
|
||||||
import {
|
import {
|
||||||
buildDashboardAdminFilters,
|
buildDashboardAdminFilters,
|
||||||
excludeAutoInvoiceEntries,
|
excludeAutoInvoiceEntries,
|
||||||
@@ -42,7 +42,7 @@ type PeriodSummaryRow = {
|
|||||||
accountExcludeFromBalance: boolean | null;
|
accountExcludeFromBalance: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardPeriodOverview = {
|
type DashboardPeriodOverview = {
|
||||||
metrics: DashboardCardMetrics;
|
metrics: DashboardCardMetrics;
|
||||||
incomeExpenseBalanceData: IncomeExpenseBalanceData;
|
incomeExpenseBalanceData: IncomeExpenseBalanceData;
|
||||||
};
|
};
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
fetchTransactionFilterSources,
|
fetchTransactionFilterSources,
|
||||||
} from "@/features/transactions/queries";
|
} from "@/features/transactions/queries";
|
||||||
|
|
||||||
export type DashboardQuickActionOptions = {
|
type DashboardQuickActionOptions = {
|
||||||
payerOptions: ReturnType<typeof buildOptionSets>["payerOptions"];
|
payerOptions: ReturnType<typeof buildOptionSets>["payerOptions"];
|
||||||
splitPayerOptions: ReturnType<typeof buildOptionSets>["splitPayerOptions"];
|
splitPayerOptions: ReturnType<typeof buildOptionSets>["splitPayerOptions"];
|
||||||
defaultPayerId: string | null;
|
defaultPayerId: string | null;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export type DashboardPagador = {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardPayersSnapshot = {
|
type DashboardPayersSnapshot = {
|
||||||
payers: DashboardPagador[];
|
payers: DashboardPagador[];
|
||||||
totalExpenses: number;
|
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 = {
|
export type PaymentConditionSummary = {
|
||||||
condition: string;
|
condition: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -18,68 +8,3 @@ export type PaymentConditionSummary = {
|
|||||||
export type PaymentConditionsData = {
|
export type PaymentConditionsData = {
|
||||||
conditions: PaymentConditionSummary[];
|
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 = {
|
export type PaymentMethodSummary = {
|
||||||
paymentMethod: string;
|
paymentMethod: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -18,68 +8,3 @@ export type PaymentMethodSummary = {
|
|||||||
export type PaymentMethodsData = {
|
export type PaymentMethodsData = {
|
||||||
methods: PaymentMethodSummary[];
|
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 = {
|
export type PaymentStatusCategory = {
|
||||||
total: number;
|
total: number;
|
||||||
confirmed: number;
|
confirmed: number;
|
||||||
@@ -20,76 +8,3 @@ export type PaymentStatusData = {
|
|||||||
income: PaymentStatusCategory;
|
income: PaymentStatusCategory;
|
||||||
expenses: 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,
|
DEFAULT_PAYMENT_OVERVIEW_TAB,
|
||||||
type PaymentOverviewTab,
|
type PaymentOverviewTab,
|
||||||
parsePaymentOverviewTab,
|
parsePaymentOverviewTab,
|
||||||
} from "@/features/dashboard/payment-overview-tabs";
|
} from "@/features/dashboard/payments/payment-overview-tabs";
|
||||||
|
|
||||||
export type PaymentOverviewWidgetController = {
|
type PaymentOverviewWidgetController = {
|
||||||
activeTab: PaymentOverviewTab;
|
activeTab: PaymentOverviewTab;
|
||||||
handleTabChange: (value: string) => void;
|
handleTabChange: (value: string) => void;
|
||||||
};
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { cacheLife, cacheTag } from "next/cache";
|
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";
|
import { db, schema } from "@/shared/lib/db";
|
||||||
|
|
||||||
export interface UserDashboardPreferences {
|
interface UserDashboardPreferences {
|
||||||
dashboardWidgets: WidgetPreferences | null;
|
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 = {
|
export type TopEstablishment = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -19,78 +9,3 @@ export type TopEstablishment = {
|
|||||||
export type TopEstablishmentsData = {
|
export type TopEstablishmentsData = {
|
||||||
establishments: TopEstablishment[];
|
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 { financialAccounts, transactions } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||||
@@ -12,10 +12,6 @@ type DashboardAdminFiltersParams = {
|
|||||||
adminPayerId: string;
|
adminPayerId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DashboardAdminPeriodFiltersParams = DashboardAdminFiltersParams & {
|
|
||||||
period: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildDashboardAdminFilters = ({
|
export const buildDashboardAdminFilters = ({
|
||||||
userId,
|
userId,
|
||||||
adminPayerId,
|
adminPayerId,
|
||||||
@@ -25,31 +21,12 @@ export const buildDashboardAdminFilters = ({
|
|||||||
eq(transactions.payerId, adminPayerId),
|
eq(transactions.payerId, adminPayerId),
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const buildDashboardAdminPeriodFilters = ({
|
|
||||||
userId,
|
|
||||||
period,
|
|
||||||
adminPayerId,
|
|
||||||
}: DashboardAdminPeriodFiltersParams) =>
|
|
||||||
[
|
|
||||||
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
|
||||||
eq(transactions.period, period),
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const excludeAutoInvoiceEntries = () =>
|
export const excludeAutoInvoiceEntries = () =>
|
||||||
or(
|
or(
|
||||||
isNull(transactions.note),
|
isNull(transactions.note),
|
||||||
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
|
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 = () =>
|
export const excludeInitialBalanceWhenConfigured = () =>
|
||||||
or(
|
or(
|
||||||
isNull(transactions.note),
|
isNull(transactions.note),
|
||||||
|
|||||||
@@ -18,25 +18,25 @@ import {
|
|||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AttachmentsWidget } from "@/features/dashboard/components/attachments-widget";
|
import { AttachmentsWidget } from "@/features/dashboard/components/widgets/attachments-widget";
|
||||||
import { BillWidget } from "@/features/dashboard/components/bill-widget";
|
import { BillWidget } from "@/features/dashboard/components/widgets/bill-widget";
|
||||||
import { CategoryTrendsWidget } from "@/features/dashboard/components/category-trends-widget";
|
import { CategoryTrendsWidget } from "@/features/dashboard/components/widgets/category-trends-widget";
|
||||||
import { ExpensesByCategoryWidgetWithChart } from "@/features/dashboard/components/expenses-by-category-widget-with-chart";
|
import { ExpensesByCategoryWidgetWithChart } from "@/features/dashboard/components/widgets/expenses-by-category-widget-with-chart";
|
||||||
import { GoalsProgressWidget } from "@/features/dashboard/components/goals-progress-widget";
|
import { GoalsProgressWidget } from "@/features/dashboard/components/widgets/goals-progress-widget";
|
||||||
import { InboxWidget } from "@/features/dashboard/components/inbox-widget";
|
import { InboxWidget } from "@/features/dashboard/components/widgets/inbox-widget";
|
||||||
import { IncomeByCategoryWidgetWithChart } from "@/features/dashboard/components/income-by-category-widget-with-chart";
|
import { IncomeByCategoryWidgetWithChart } from "@/features/dashboard/components/widgets/income-by-category-widget-with-chart";
|
||||||
import { IncomeExpenseBalanceWidget } from "@/features/dashboard/components/income-expense-balance-widget";
|
import { IncomeExpenseBalanceWidget } from "@/features/dashboard/components/widgets/income-expense-balance-widget";
|
||||||
import { InstallmentExpensesWidget } from "@/features/dashboard/components/installment-expenses-widget";
|
import { InstallmentExpensesWidget } from "@/features/dashboard/components/widgets/installment-expenses-widget";
|
||||||
import { InvoicesWidget } from "@/features/dashboard/components/invoices-widget";
|
import { InvoicesWidget } from "@/features/dashboard/components/widgets/invoices-widget";
|
||||||
import { MyAccountsWidget } from "@/features/dashboard/components/my-accounts-widget";
|
import { MyAccountsWidget } from "@/features/dashboard/components/widgets/my-accounts-widget";
|
||||||
import { NotesWidget } from "@/features/dashboard/components/notes-widget";
|
import { NotesWidget } from "@/features/dashboard/components/widgets/notes-widget";
|
||||||
import { PayersWidget } from "@/features/dashboard/components/payers-widget";
|
import { PayersWidget } from "@/features/dashboard/components/widgets/payers-widget";
|
||||||
import { PaymentOverviewWidget } from "@/features/dashboard/components/payment-overview-widget";
|
import { PaymentOverviewWidget } from "@/features/dashboard/components/widgets/payment-overview-widget";
|
||||||
import { PaymentStatusWidget } from "@/features/dashboard/components/payment-status-widget";
|
import { PaymentStatusWidget } from "@/features/dashboard/components/widgets/payment-status-widget";
|
||||||
import { PurchasesByCategoryWidget } from "@/features/dashboard/components/purchases-by-category-widget";
|
import { PurchasesByCategoryWidget } from "@/features/dashboard/components/widgets/purchases-by-category-widget";
|
||||||
import { RecurringExpensesWidget } from "@/features/dashboard/components/recurring-expenses-widget";
|
import { RecurringExpensesWidget } from "@/features/dashboard/components/widgets/recurring-expenses-widget";
|
||||||
import { SpendingOverviewWidget } from "@/features/dashboard/components/spending-overview-widget";
|
import { SpendingOverviewWidget } from "@/features/dashboard/components/widgets/spending-overview-widget";
|
||||||
import type { WidgetPreferences } from "@/features/dashboard/widgets/actions";
|
import type { WidgetPreferences } from "@/features/dashboard/widget-registry/widget-actions";
|
||||||
import type { SelectOption } from "@/features/transactions/components/types";
|
import type { SelectOption } from "@/features/transactions/components/types";
|
||||||
import type { DashboardData } from "../fetch-dashboard-data";
|
import type { DashboardData } from "../fetch-dashboard-data";
|
||||||
|
|
||||||
Reference in New Issue
Block a user