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,126 @@
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
import type { PaymentDialogState } from "@/features/dashboard/payments/use-payment-dialog-controller";
import {
INVOICE_PAYMENT_STATUS,
type InvoicePaymentStatus,
} from "@/shared/lib/invoices";
import { getBusinessDateString } from "@/shared/utils/date";
import {
buildDueDateInfoFromPeriodDay,
buildRelativeDueDateInfoFromPeriodDay,
formatFinancialDateLabel,
formatRelativeFinancialDateLabel,
} from "@/shared/utils/financial-dates";
import { formatPercentage } from "@/shared/utils/percentage";
import { formatPeriodForUrl } from "@/shared/utils/period";
export type InvoiceDialogState = PaymentDialogState;
export type InvoiceLogoTone = "muted" | "accent";
type InvoicePaymentDateInfo = {
label: string;
};
type InvoiceDueDateInfo = {
label: string;
date: string | null;
};
export const buildInvoiceInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "CC";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CC";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CC";
};
export const parseInvoiceDueDate = (
period: string,
dueDay: string,
): InvoiceDueDateInfo => {
return buildDueDateInfoFromPeriodDay(period, dueDay);
};
export const parseInvoiceWidgetDueDate = (
period: string,
dueDay: string,
): InvoiceDueDateInfo => {
return buildRelativeDueDateInfoFromPeriodDay(period, dueDay);
};
export const formatInvoicePaymentDate = (
value: string | null,
): InvoicePaymentDateInfo | null => {
const label = formatFinancialDateLabel(value, "Pago em");
if (!label) {
return null;
}
return {
label,
};
};
export const formatInvoiceWidgetPaymentDate = (
value: string | null,
): InvoicePaymentDateInfo | null => {
const label = formatRelativeFinancialDateLabel(value, "paid");
if (!label) {
return null;
}
return {
label,
};
};
export const getCurrentDateString = () => getBusinessDateString();
const formatInvoiceSharePercentage = (value: number) => {
if (!Number.isFinite(value) || value <= 0) {
return "0%";
}
const digits = value >= 10 ? 0 : value >= 1 ? 1 : 2;
return formatPercentage(value, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
export const getInvoiceShareLabel = (amount: number, total: number) => {
if (total <= 0) {
return "0% do total";
}
const percentage = (amount / total) * 100;
return `${formatInvoiceSharePercentage(percentage)} do total`;
};
export const getInvoiceStatusBadgeVariant = (
statusLabel: string,
): "success" | "info" => {
if (statusLabel.toLowerCase() === "em aberto") {
return "info";
}
return "success";
};
export const buildInvoiceDetailsHref = (cardId: string, period: string) =>
`/cards/${cardId}/invoice?periodo=${formatPeriodForUrl(period)}`;
export const markInvoiceAsPaid = (
invoice: DashboardInvoice,
paidAt: string,
): DashboardInvoice => ({
...invoice,
paymentStatus: INVOICE_PAYMENT_STATUS.PAID,
paidAt,
});
export const isInvoicePaid = (status: InvoicePaymentStatus) =>
status === INVOICE_PAYMENT_STATUS.PAID;

View File

@@ -0,0 +1,403 @@
import { and, eq, ilike, inArray, isNotNull, sql } from "drizzle-orm";
import { cards, invoices, payers, transactions } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_VALUES,
type InvoicePaymentStatus,
} from "@/shared/lib/invoices";
import {
buildDateOnlyStringFromPeriodDay,
compareDateOnly,
getBusinessDateString,
isDateOnlyPast,
toDateOnlyString,
} from "@/shared/utils/date";
import { calculatePercentageChange } from "@/shared/utils/math";
import { safeToNumber as toNumber } from "@/shared/utils/number";
import { getPreviousPeriod } from "@/shared/utils/period";
type RawDashboardInvoice = {
invoiceId: string | null;
cardId: string;
cardName: string;
cardBrand: string | null;
cardStatus: string | null;
logo: string | null;
dueDay: string;
period: string | null;
paymentStatus: string | null;
totalAmount: string | number | null;
transactionCount: string | number | null;
invoiceCreatedAt: Date | null;
};
type RawInvoiceBreakdownRow = {
cardId: string | null;
period: string | null;
payerId: string | null;
pagadorName: string | null;
pagadorAvatar: string | null;
amount: number | string | null;
};
export type InvoicePagadorBreakdown = {
payerId: string | null;
pagadorName: string;
pagadorAvatar: string | null;
amount: number;
percentageChange: number | null;
};
export type DashboardInvoice = {
id: string;
cardId: string;
cardName: string;
cardBrand: string | null;
cardStatus: string | null;
logo: string | null;
dueDay: string;
period: string;
paymentStatus: InvoicePaymentStatus;
totalAmount: number;
paidAt: string | null;
pagadorBreakdown: InvoicePagadorBreakdown[];
};
type DashboardInvoicesSnapshot = {
invoices: DashboardInvoice[];
totalPending: number;
};
const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
typeof value === "string" &&
(INVOICE_STATUS_VALUES as string[]).includes(value);
const buildFallbackId = (cardId: string, period: string) =>
`${cardId}:${period}`;
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 fetchDashboardInvoices(
userId: string,
period: string,
): Promise<DashboardInvoicesSnapshot> {
const today = getBusinessDateString();
const previousPeriod = getPreviousPeriod(period);
const paymentRows = await db
.select({
note: transactions.note,
purchaseDate: transactions.purchaseDate,
createdAt: transactions.createdAt,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`),
),
);
const paymentMap = new Map<string, string>();
for (const row of paymentRows) {
const note = row.note;
if (!note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
continue;
}
const parts = note.split(":");
if (parts.length < 3) {
continue;
}
const cardIdPart = parts[1];
const periodPart = parts[2];
if (!cardIdPart || !periodPart) {
continue;
}
const key = `${cardIdPart}:${periodPart}`;
const resolvedDate =
row.purchaseDate instanceof Date &&
!Number.isNaN(row.purchaseDate.valueOf())
? row.purchaseDate
: row.createdAt;
const isoDate = toDateOnlyString(resolvedDate);
if (!isoDate) {
continue;
}
const existing = paymentMap.get(key);
if (!existing || existing < isoDate) {
paymentMap.set(key, isoDate);
}
}
const [rows, breakdownRows] = (await Promise.all([
db
.select({
invoiceId: invoices.id,
cardId: cards.id,
cardName: cards.name,
logo: cards.logo,
dueDay: cards.dueDay,
period: invoices.period,
paymentStatus: invoices.paymentStatus,
invoiceCreatedAt: invoices.createdAt,
totalAmount: sql<number | null>`
COALESCE(SUM(${transactions.amount}), 0)
`,
transactionCount: sql<number | null>`COUNT(${transactions.id})`,
})
.from(cards)
.leftJoin(
invoices,
and(
eq(invoices.cardId, cards.id),
eq(invoices.userId, userId),
eq(invoices.period, period),
),
)
.leftJoin(
transactions,
and(
eq(transactions.cardId, cards.id),
eq(transactions.userId, userId),
eq(transactions.period, period),
),
)
.where(eq(cards.userId, userId))
.groupBy(
invoices.id,
cards.id,
cards.name,
cards.brand,
cards.status,
cards.logo,
cards.dueDay,
invoices.period,
invoices.paymentStatus,
),
db
.select({
cardId: transactions.cardId,
period: transactions.period,
payerId: transactions.payerId,
pagadorName: payers.name,
pagadorAvatar: payers.avatarUrl,
amount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.leftJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
inArray(transactions.period, [period, previousPeriod]),
isNotNull(transactions.cardId),
),
)
.groupBy(
transactions.cardId,
transactions.period,
transactions.payerId,
payers.name,
payers.avatarUrl,
),
])) as [RawDashboardInvoice[], RawInvoiceBreakdownRow[]];
const groupedBreakdown = new Map<
string,
{
cardId: string;
payerId: string | null;
pagadorName: string;
pagadorAvatar: string | null;
currentAmount: number;
previousAmount: number;
}
>();
for (const row of breakdownRows) {
if (!row.cardId) {
continue;
}
const resolvedPeriod = row.period ?? period;
const amount = Math.abs(toNumber(row.amount));
if (amount <= 0) {
continue;
}
const payerId = row.payerId ?? null;
const pagadorName = row.pagadorName?.trim() || "Sem pagador";
const pagadorAvatar = row.pagadorAvatar ?? null;
const payerKey = payerId ?? "__without-payer__";
const key = `${row.cardId}:${payerKey}`;
const current = groupedBreakdown.get(key) ?? {
cardId: row.cardId,
payerId,
pagadorName,
pagadorAvatar,
currentAmount: 0,
previousAmount: 0,
};
if (resolvedPeriod === period) {
current.payerId = payerId;
current.pagadorName = pagadorName;
current.pagadorAvatar = pagadorAvatar;
current.currentAmount = amount;
}
if (resolvedPeriod === previousPeriod) {
current.previousAmount = amount;
}
groupedBreakdown.set(key, current);
}
const breakdownMap = new Map<string, InvoicePagadorBreakdown[]>();
for (const share of groupedBreakdown.values()) {
if (share.currentAmount <= 0) {
continue;
}
const key = `${share.cardId}:${period}`;
const current = breakdownMap.get(key) ?? [];
current.push({
payerId: share.payerId,
pagadorName: share.pagadorName,
pagadorAvatar: share.pagadorAvatar,
amount: share.currentAmount,
percentageChange: calculatePercentageChange(
share.currentAmount,
share.previousAmount,
),
});
breakdownMap.set(key, current);
}
const invoiceList: DashboardInvoice[] = [];
for (const row of rows) {
if (!row) {
continue;
}
const totalAmount = toNumber(row.totalAmount);
const transactionCount = toNumber(row.transactionCount);
const paymentStatus = isInvoiceStatus(row.paymentStatus)
? row.paymentStatus
: INVOICE_PAYMENT_STATUS.PENDING;
const shouldInclude =
transactionCount > 0 ||
Math.abs(totalAmount) > 0 ||
row.invoiceId !== null;
if (!shouldInclude) {
continue;
}
const resolvedPeriod = row.period ?? period;
const paymentKey = `${row.cardId}:${resolvedPeriod}`;
const paidAt =
paymentStatus === INVOICE_PAYMENT_STATUS.PAID
? (paymentMap.get(paymentKey) ?? toDateOnlyString(row.invoiceCreatedAt))
: null;
invoiceList.push({
id: row.invoiceId ?? buildFallbackId(row.cardId, period),
cardId: row.cardId,
cardName: row.cardName,
cardBrand: row.cardBrand,
cardStatus: row.cardStatus,
logo: row.logo,
dueDay: row.dueDay,
period: resolvedPeriod,
paymentStatus,
totalAmount,
paidAt,
pagadorBreakdown: (
breakdownMap.get(`${row.cardId}:${resolvedPeriod}`) ?? []
).sort((a, b) => b.amount - a.amount),
});
}
invoiceList.sort((a, b) => {
const aIsPending = a.paymentStatus === INVOICE_PAYMENT_STATUS.PENDING;
const bIsPending = b.paymentStatus === INVOICE_PAYMENT_STATUS.PENDING;
if (aIsPending !== bIsPending) {
return aIsPending ? -1 : 1;
}
if (aIsPending && bIsPending) {
const aDueDate = buildDateOnlyStringFromPeriodDay(a.period, a.dueDay);
const bDueDate = buildDateOnlyStringFromPeriodDay(b.period, b.dueDay);
const aIsOverdue = aDueDate ? isDateOnlyPast(aDueDate, today) : false;
const bIsOverdue = bDueDate ? isDateOnlyPast(bDueDate, today) : false;
if (aIsOverdue !== bIsOverdue) {
return aIsOverdue ? -1 : 1;
}
const dueDateDiff = compareDateOnlyAscWithNullsLast(aDueDate, bDueDate);
if (dueDateDiff !== 0) {
return dueDateDiff;
}
const amountDiff = Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
if (amountDiff !== 0) {
return amountDiff;
}
}
if (!aIsPending && !bIsPending) {
const paidAtDiff = compareDateOnlyDescWithNullsLast(a.paidAt, b.paidAt);
if (paidAtDiff !== 0) {
return paidAtDiff;
}
const amountDiff = Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
if (amountDiff !== 0) {
return amountDiff;
}
}
const nameDiff = a.cardName.localeCompare(b.cardName, "pt-BR", {
sensitivity: "base",
});
if (nameDiff !== 0) {
return nameDiff;
}
return a.id.localeCompare(b.id);
});
const totalPending = invoiceList.reduce((total, invoice) => {
if (invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PENDING) {
return total;
}
return total + invoice.totalAmount;
}, 0);
return {
invoices: invoiceList,
totalPending,
};
}

View File

@@ -0,0 +1,46 @@
"use client";
import {
getCurrentDateString,
type InvoiceDialogState,
isInvoicePaid,
markInvoiceAsPaid,
} from "@/features/dashboard/invoices/invoices-helpers";
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
import {
type PaymentDialogController,
usePaymentDialogController,
} from "@/features/dashboard/payments/use-payment-dialog-controller";
import { updateInvoicePaymentStatusAction } from "@/features/invoices/actions";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
type InvoicesWidgetController = Omit<
PaymentDialogController<DashboardInvoice>,
"selectedItem"
> & {
selectedInvoice: DashboardInvoice | null;
modalState: InvoiceDialogState;
};
export function useInvoicesWidgetController(
invoices: DashboardInvoice[],
): InvoicesWidgetController {
const controller = usePaymentDialogController({
items: invoices,
getItemId: (invoice) => invoice.id,
isItemConfirmed: (invoice) => isInvoicePaid(invoice.paymentStatus),
executeConfirm: (invoice) =>
updateInvoicePaymentStatusAction({
cardId: invoice.cardId,
period: invoice.period,
status: INVOICE_PAYMENT_STATUS.PAID,
}),
applyConfirmedState: (invoice) =>
markInvoiceAsPaid(invoice, getCurrentDateString()),
});
return {
...controller,
selectedInvoice: controller.selectedItem,
};
}