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:
Felipe Coutinho
2026-04-20 17:51:56 +00:00
parent 3e80d5995b
commit ba05985725
99 changed files with 784 additions and 2055 deletions

View File

@@ -26,7 +26,7 @@ export type DashboardAccount = {
excludeFromBalance: boolean; excludeFromBalance: boolean;
}; };
export type DashboardAccountsSnapshot = { type DashboardAccountsSnapshot = {
totalBalance: number; totalBalance: number;
accounts: DashboardAccount[]; accounts: DashboardAccount[];
}; };

View File

@@ -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,
};
}

View File

@@ -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,

View 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;
};

View File

@@ -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"
> & { > & {

View File

@@ -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

View File

@@ -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;

View File

@@ -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,
});
}

View File

@@ -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,
});
}

View File

@@ -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[]>;
};

View File

@@ -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">

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
); );

View File

@@ -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";

View File

@@ -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">

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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[];

View File

@@ -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";

View File

@@ -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:

View File

@@ -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";

View File

@@ -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">

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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[];

View File

@@ -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>
); );

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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[];

View File

@@ -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";

View File

@@ -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[];

View File

@@ -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>
); );

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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,

View File

@@ -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,
},
};
}

View File

@@ -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 };
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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 [

View File

@@ -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,
};
}

View File

@@ -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) =>

View File

@@ -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;
};

View File

@@ -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[];

View File

@@ -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 };
}

View File

@@ -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,

View File

@@ -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);
} }

View File

@@ -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"
> & { > & {

View File

@@ -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;

View File

@@ -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 => ({

View File

@@ -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;

View File

@@ -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";

View File

@@ -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;

View 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;
};

View File

@@ -0,0 +1,11 @@
export type MonthData = {
month: string;
monthLabel: string;
income: number;
expense: number;
balance: number;
};
export type IncomeExpenseBalanceData = {
months: MonthData[];
};

View File

@@ -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;
}; };

View File

@@ -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;

View File

@@ -19,7 +19,7 @@ export type DashboardPagador = {
isAdmin: boolean; isAdmin: boolean;
}; };
export type DashboardPayersSnapshot = { type DashboardPayersSnapshot = {
payers: DashboardPagador[]; payers: DashboardPagador[];
totalExpenses: number; totalExpenses: number;
}; };

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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;
}; };

View File

@@ -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;
} }

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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),

View File

@@ -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";