mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-11 03:31:47 +00:00
refactor(dashboard): reorganizar módulos em subdiretórios e nova arquitetura de widgets
Arquivos de queries, helpers e controllers dispersos na raiz de dashboard/ foram movidos para subdiretórios temáticos (bills/, invoices/, notes/, notifications/, overview/, payments/, goals-progress/, categories/). ~25 widgets monolíticos obsoletos removidos em favor de nova arquitetura baseada em widget-registry com components/widgets/. Novos componentes: category-breakdown-chart/list, goals-progress-item, percentage-change-indicator. Imports atualizados em fetch-dashboard-data e transaction-filters limpos. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"}`;
|
||||
@@ -1,13 +1,3 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { transactions } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminPeriodFilters,
|
||||
excludeAutoGeneratedEntryNotes,
|
||||
} from "@/features/dashboard/transaction-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type PaymentConditionSummary = {
|
||||
condition: string;
|
||||
amount: number;
|
||||
@@ -18,68 +8,3 @@ export type PaymentConditionSummary = {
|
||||
export type PaymentConditionsData = {
|
||||
conditions: PaymentConditionSummary[];
|
||||
};
|
||||
|
||||
export async function fetchPaymentConditions(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<PaymentConditionsData> {
|
||||
const adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) {
|
||||
return { conditions: [] };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
condition: transactions.condition,
|
||||
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||
transactions: sql<number>`count(${transactions.id})`,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminPeriodFilters({
|
||||
userId,
|
||||
period,
|
||||
adminPayerId,
|
||||
}),
|
||||
eq(transactions.transactionType, "Despesa"),
|
||||
excludeAutoGeneratedEntryNotes(),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.condition);
|
||||
|
||||
const summaries = rows.map((row: (typeof rows)[number]) => {
|
||||
const totalAmount = Math.abs(toNumber(row.totalAmount));
|
||||
const transactions = Number(row.transactions ?? 0);
|
||||
|
||||
return {
|
||||
condition: row.condition,
|
||||
amount: totalAmount,
|
||||
transactions,
|
||||
};
|
||||
});
|
||||
|
||||
const overallTotal = summaries.reduce(
|
||||
(acc: number, item: (typeof summaries)[number]) => acc + item.amount,
|
||||
0,
|
||||
);
|
||||
|
||||
const conditions = summaries
|
||||
.map((item: (typeof summaries)[number]) => ({
|
||||
condition: item.condition,
|
||||
amount: item.amount,
|
||||
transactions: item.transactions,
|
||||
percentage:
|
||||
overallTotal > 0
|
||||
? Number(((item.amount / overallTotal) * 100).toFixed(2))
|
||||
: 0,
|
||||
}))
|
||||
.sort(
|
||||
(a: (typeof summaries)[number], b: (typeof summaries)[number]) =>
|
||||
b.amount - a.amount,
|
||||
);
|
||||
|
||||
return {
|
||||
conditions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { transactions } from "@/db/schema";
|
||||
import {
|
||||
buildDashboardAdminPeriodFilters,
|
||||
excludeAutoGeneratedEntryNotes,
|
||||
} from "@/features/dashboard/transaction-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
export type PaymentMethodSummary = {
|
||||
paymentMethod: string;
|
||||
amount: number;
|
||||
@@ -18,68 +8,3 @@ export type PaymentMethodSummary = {
|
||||
export type PaymentMethodsData = {
|
||||
methods: PaymentMethodSummary[];
|
||||
};
|
||||
|
||||
export async function fetchPaymentMethods(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<PaymentMethodsData> {
|
||||
const adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) {
|
||||
return { methods: [] };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
paymentMethod: transactions.paymentMethod,
|
||||
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||
transactions: sql<number>`count(${transactions.id})`,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminPeriodFilters({
|
||||
userId,
|
||||
period,
|
||||
adminPayerId,
|
||||
}),
|
||||
eq(transactions.transactionType, "Despesa"),
|
||||
excludeAutoGeneratedEntryNotes(),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.paymentMethod);
|
||||
|
||||
const summaries = rows.map((row: (typeof rows)[number]) => {
|
||||
const amount = Math.abs(toNumber(row.totalAmount));
|
||||
const transactions = Number(row.transactions ?? 0);
|
||||
|
||||
return {
|
||||
paymentMethod: row.paymentMethod,
|
||||
amount,
|
||||
transactions,
|
||||
};
|
||||
});
|
||||
|
||||
const overallTotal = summaries.reduce(
|
||||
(acc: number, item: (typeof summaries)[number]) => acc + item.amount,
|
||||
0,
|
||||
);
|
||||
|
||||
const methods = summaries
|
||||
.map((item: (typeof summaries)[number]) => ({
|
||||
paymentMethod: item.paymentMethod,
|
||||
amount: item.amount,
|
||||
transactions: item.transactions,
|
||||
percentage:
|
||||
overallTotal > 0
|
||||
? Number(((item.amount / overallTotal) * 100).toFixed(2))
|
||||
: 0,
|
||||
}))
|
||||
.sort(
|
||||
(a: (typeof summaries)[number], b: (typeof summaries)[number]) =>
|
||||
b.amount - a.amount,
|
||||
);
|
||||
|
||||
return {
|
||||
methods,
|
||||
};
|
||||
}
|
||||
|
||||
11
src/features/dashboard/payments/payment-overview-tabs.ts
Normal file
11
src/features/dashboard/payments/payment-overview-tabs.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
110
src/features/dashboard/payments/use-payment-dialog-controller.ts
Normal file
110
src/features/dashboard/payments/use-payment-dialog-controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user