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

@@ -0,0 +1,10 @@
import { formatPercentage } from "@/shared/utils/percentage";
export const formatPaymentBreakdownPercentage = (value: number) =>
formatPercentage(value, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
export const formatPaymentBreakdownTransactionsLabel = (transactions: number) =>
`${transactions} ${transactions === 1 ? "lançamento" : "lançamentos"}`;

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 = {
condition: string;
amount: number;
@@ -18,68 +8,3 @@ export type PaymentConditionSummary = {
export type PaymentConditionsData = {
conditions: PaymentConditionSummary[];
};
export async function fetchPaymentConditions(
userId: string,
period: string,
): Promise<PaymentConditionsData> {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { conditions: [] };
}
const rows = await db
.select({
condition: transactions.condition,
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
transactions: sql<number>`count(${transactions.id})`,
})
.from(transactions)
.where(
and(
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPayerId,
}),
eq(transactions.transactionType, "Despesa"),
excludeAutoGeneratedEntryNotes(),
),
)
.groupBy(transactions.condition);
const summaries = rows.map((row: (typeof rows)[number]) => {
const totalAmount = Math.abs(toNumber(row.totalAmount));
const transactions = Number(row.transactions ?? 0);
return {
condition: row.condition,
amount: totalAmount,
transactions,
};
});
const overallTotal = summaries.reduce(
(acc: number, item: (typeof summaries)[number]) => acc + item.amount,
0,
);
const conditions = summaries
.map((item: (typeof summaries)[number]) => ({
condition: item.condition,
amount: item.amount,
transactions: item.transactions,
percentage:
overallTotal > 0
? Number(((item.amount / overallTotal) * 100).toFixed(2))
: 0,
}))
.sort(
(a: (typeof summaries)[number], b: (typeof summaries)[number]) =>
b.amount - a.amount,
);
return {
conditions,
};
}

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 = {
paymentMethod: string;
amount: number;
@@ -18,68 +8,3 @@ export type PaymentMethodSummary = {
export type PaymentMethodsData = {
methods: PaymentMethodSummary[];
};
export async function fetchPaymentMethods(
userId: string,
period: string,
): Promise<PaymentMethodsData> {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { methods: [] };
}
const rows = await db
.select({
paymentMethod: transactions.paymentMethod,
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
transactions: sql<number>`count(${transactions.id})`,
})
.from(transactions)
.where(
and(
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPayerId,
}),
eq(transactions.transactionType, "Despesa"),
excludeAutoGeneratedEntryNotes(),
),
)
.groupBy(transactions.paymentMethod);
const summaries = rows.map((row: (typeof rows)[number]) => {
const amount = Math.abs(toNumber(row.totalAmount));
const transactions = Number(row.transactions ?? 0);
return {
paymentMethod: row.paymentMethod,
amount,
transactions,
};
});
const overallTotal = summaries.reduce(
(acc: number, item: (typeof summaries)[number]) => acc + item.amount,
0,
);
const methods = summaries
.map((item: (typeof summaries)[number]) => ({
paymentMethod: item.paymentMethod,
amount: item.amount,
transactions: item.transactions,
percentage:
overallTotal > 0
? Number(((item.amount / overallTotal) * 100).toFixed(2))
: 0,
}))
.sort(
(a: (typeof summaries)[number], b: (typeof summaries)[number]) =>
b.amount - a.amount,
);
return {
methods,
};
}

View File

@@ -0,0 +1,11 @@
export type PaymentOverviewTab = "conditions" | "methods";
export const DEFAULT_PAYMENT_OVERVIEW_TAB: PaymentOverviewTab = "conditions";
export const parsePaymentOverviewTab = (value: string): PaymentOverviewTab => {
if (value === "methods") {
return "methods";
}
return DEFAULT_PAYMENT_OVERVIEW_TAB;
};

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 = {
total: number;
confirmed: number;
@@ -20,76 +8,3 @@ export type PaymentStatusData = {
income: PaymentStatusCategory;
expenses: PaymentStatusCategory;
};
const emptyCategory = (): PaymentStatusCategory => ({
total: 0,
confirmed: 0,
pending: 0,
});
export async function fetchPaymentStatus(
userId: string,
period: string,
): Promise<PaymentStatusData> {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { income: emptyCategory(), expenses: emptyCategory() };
}
// Single query: GROUP BY transactionType instead of 2 separate queries
const rows = await db
.select({
transactionType: transactions.transactionType,
confirmed: sql<number>`
coalesce(
sum(case when ${transactions.isSettled} = true then ${transactions.amount} else 0 end),
0
)
`,
pending: sql<number>`
coalesce(
sum(case when ${transactions.isSettled} = false or ${transactions.isSettled} is null then ${transactions.amount} else 0 end),
0
)
`,
})
.from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPayerId,
}),
inArray(transactions.transactionType, ["Receita", "Despesa"]),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
excludeTransactionsFromExcludedAccounts(),
),
)
.groupBy(transactions.transactionType);
const result = { income: emptyCategory(), expenses: emptyCategory() };
for (const row of rows) {
const confirmed = toNumber(row.confirmed);
const pending = toNumber(row.pending);
const category = {
total: confirmed + pending,
confirmed,
pending,
};
if (row.transactionType === "Receita") {
result.income = category;
} else if (row.transactionType === "Despesa") {
result.expenses = category;
}
}
return result;
}

View File

@@ -0,0 +1,110 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import type { ActionResult } from "@/shared/lib/types/actions";
export type PaymentDialogState = "idle" | "processing" | "success";
type UsePaymentDialogControllerOptions<TItem> = {
items: TItem[];
getItemId: (item: TItem) => string;
isItemConfirmed: (item: TItem) => boolean;
executeConfirm: (item: TItem) => Promise<ActionResult>;
applyConfirmedState: (item: TItem) => TItem;
};
export type PaymentDialogController<TItem> = {
items: TItem[];
selectedItem: TItem | null;
isModalOpen: boolean;
modalState: PaymentDialogState;
isPending: boolean;
openPaymentDialog: (itemId: string) => void;
closePaymentDialog: () => void;
confirmPayment: () => void;
};
export function usePaymentDialogController<TItem>({
items,
getItemId,
isItemConfirmed,
executeConfirm,
applyConfirmedState,
}: UsePaymentDialogControllerOptions<TItem>): PaymentDialogController<TItem> {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [localItems, setLocalItems] = useState(items);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalState, setModalState] = useState<PaymentDialogState>("idle");
useEffect(() => {
setLocalItems(items);
}, [items]);
const selectedItem = useMemo(
() => localItems.find((item) => getItemId(item) === selectedId) ?? null,
[localItems, selectedId, getItemId],
);
const openPaymentDialog = (itemId: string) => {
setSelectedId(itemId);
setModalState("idle");
setIsModalOpen(true);
};
const closePaymentDialog = () => {
setIsModalOpen(false);
setSelectedId(null);
setModalState("idle");
};
const confirmPayment = () => {
const itemToUpdate = selectedItem;
if (
!itemToUpdate ||
isItemConfirmed(itemToUpdate) ||
modalState === "processing" ||
isPending
) {
return;
}
const itemId = getItemId(itemToUpdate);
setModalState("processing");
startTransition(() => {
void (async () => {
const result = await executeConfirm(itemToUpdate);
if (!result.success) {
toast.error(result.error);
setModalState("idle");
return;
}
setLocalItems((previous) =>
previous.map((item) =>
getItemId(item) === itemId ? applyConfirmedState(item) : item,
),
);
toast.success(result.message);
router.refresh();
setModalState("success");
})();
});
};
return {
items: localItems,
selectedItem,
isModalOpen,
modalState,
isPending,
openPaymentDialog,
closePaymentDialog,
confirmPayment,
};
}

View File

@@ -0,0 +1,28 @@
"use client";
import { useState } from "react";
import {
DEFAULT_PAYMENT_OVERVIEW_TAB,
type PaymentOverviewTab,
parsePaymentOverviewTab,
} from "@/features/dashboard/payments/payment-overview-tabs";
type PaymentOverviewWidgetController = {
activeTab: PaymentOverviewTab;
handleTabChange: (value: string) => void;
};
export function usePaymentOverviewWidgetController(): PaymentOverviewWidgetController {
const [activeTab, setActiveTab] = useState<PaymentOverviewTab>(
DEFAULT_PAYMENT_OVERVIEW_TAB,
);
const handleTabChange = (value: string) => {
setActiveTab(parsePaymentOverviewTab(value));
};
return {
activeTab,
handleTabChange,
};
}