diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 9335cef..21c0f7d 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -1,17 +1,8 @@ import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable"; import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards"; import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome"; -import { fetchDashboardData } from "@/features/dashboard/fetch-dashboard-data"; -import { fetchUserDashboardPreferences } from "@/features/dashboard/preferences-queries"; -import { - buildOptionSets, - buildSluggedFilters, - getSingleParam, -} from "@/features/transactions/page-helpers"; -import { - fetchRecentEstablishments, - fetchTransactionFilterSources, -} from "@/features/transactions/queries"; +import { fetchDashboardPageData } from "@/features/dashboard/page-data-queries"; +import { getSingleParam } from "@/features/transactions/page-helpers"; import MonthNavigation from "@/shared/components/month-picker/month-navigation"; import { getUser } from "@/shared/lib/auth/server"; import { parsePeriodParam } from "@/shared/utils/period"; @@ -28,26 +19,9 @@ export default async function Page({ searchParams }: PageProps) { const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const { period: selectedPeriod } = parsePeriodParam(periodoParam); - const [dashboardData, preferences, filterSources, estabelecimentos] = - await Promise.all([ - fetchDashboardData(user.id, selectedPeriod), - fetchUserDashboardPreferences(user.id), - fetchTransactionFilterSources(user.id), - fetchRecentEstablishments(user.id), - ]); + const { dashboardData, preferences, quickActionOptions } = + await fetchDashboardPageData(user.id, selectedPeriod); const { dashboardWidgets } = preferences; - const sluggedFilters = buildSluggedFilters(filterSources); - const { - payerOptions, - splitPayerOptions, - defaultPayerId, - accountOptions, - cardOptions, - categoryOptions, - } = buildOptionSets({ - ...sluggedFilters, - payerRows: filterSources.payerRows, - }); return (
@@ -58,15 +32,7 @@ export default async function Page({ searchParams }: PageProps) { data={dashboardData} period={selectedPeriod} initialPreferences={dashboardWidgets} - quickActionOptions={{ - payerOptions, - splitPayerOptions, - defaultPayerId, - accountOptions, - cardOptions, - categoryOptions, - estabelecimentos, - }} + quickActionOptions={quickActionOptions} />
); diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index d7f0cfd..c177745 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,11 +1,8 @@ -import { fetchDashboardNotifications } from "@/features/dashboard/notifications-queries"; -import { fetchPendingInboxCount } from "@/features/inbox/queries"; +import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries"; import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar"; import { PrivacyProvider } from "@/shared/components/providers/privacy-provider"; import { DotPattern } from "@/shared/components/ui/dot-pattern"; import { getUserSession } from "@/shared/lib/auth/server"; -import { fetchPayersWithAccess } from "@/shared/lib/payers/access"; -import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants"; import { parsePeriodParam } from "@/shared/utils/period"; export default async function DashboardLayout({ @@ -16,12 +13,6 @@ export default async function DashboardLayout({ searchParams?: Promise>; }>) { const session = await getUserSession(); - const payerList = await fetchPayersWithAccess(session.user.id); - - // Encontrar o pagador admin do usuário - const adminPagador = payerList.find( - (p) => p.role === PAYER_ROLE_ADMIN && p.userId === session.user.id, - ); // Buscar notificações para o período atual const resolvedSearchParams = searchParams ? await searchParams : undefined; @@ -35,18 +26,18 @@ export default async function DashboardLayout({ const { period: currentPeriod } = parsePeriodParam( singlePeriodoParam ?? null, ); - const [notificationsSnapshot, preLancamentosCount] = await Promise.all([ - fetchDashboardNotifications(session.user.id, currentPeriod), - fetchPendingInboxCount(session.user.id), - ]); + const navbarData = await fetchDashboardNavbarData( + session.user.id, + currentPeriod, + ); return (
diff --git a/src/features/accounts/queries.ts b/src/features/accounts/queries.ts index c601bd3..af88cb2 100644 --- a/src/features/accounts/queries.ts +++ b/src/features/accounts/queries.ts @@ -1,9 +1,9 @@ import { and, eq, ilike, not, sql } from "drizzle-orm"; -import { financialAccounts, payers, transactions } from "@/db/schema"; +import { financialAccounts, transactions } from "@/db/schema"; import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants"; import { db } from "@/shared/lib/db"; import { loadLogoOptions } from "@/shared/lib/logo/options"; -import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants"; +import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; export type AccountData = { id: string; @@ -18,9 +18,12 @@ export type AccountData = { excludeInitialBalanceFromIncome: boolean; }; -export async function fetchAccountsForUser( +async function fetchAccountsByStatus( userId: string, + archived: boolean, ): Promise<{ accounts: AccountData[]; logoOptions: string[] }> { + const adminPayerId = await getAdminPayerId(userId); + const [accountRows, logoOptions] = await Promise.all([ db .select({ @@ -53,14 +56,15 @@ export async function fetchAccountsForUser( eq(transactions.accountId, financialAccounts.id), eq(transactions.userId, userId), eq(transactions.isSettled, true), + adminPayerId ? eq(transactions.payerId, adminPayerId) : sql`false`, ), ) - .leftJoin(payers, eq(transactions.payerId, payers.id)) .where( and( eq(financialAccounts.userId, userId), - not(ilike(financialAccounts.status, "inativa")), - sql`(${transactions.id} IS NULL OR ${payers.role} = ${PAYER_ROLE_ADMIN})`, + archived + ? ilike(financialAccounts.status, "inativa") + : not(ilike(financialAccounts.status, "inativa")), ), ) .groupBy( @@ -95,81 +99,16 @@ export async function fetchAccountsForUser( return { accounts, logoOptions }; } +export async function fetchAccountsForUser( + userId: string, +): Promise<{ accounts: AccountData[]; logoOptions: string[] }> { + return fetchAccountsByStatus(userId, false); +} + export async function fetchInactiveForUser( userId: string, ): Promise<{ accounts: AccountData[]; logoOptions: string[] }> { - const [accountRows, logoOptions] = await Promise.all([ - db - .select({ - id: financialAccounts.id, - name: financialAccounts.name, - accountType: financialAccounts.accountType, - status: financialAccounts.status, - note: financialAccounts.note, - logo: financialAccounts.logo, - initialBalance: financialAccounts.initialBalance, - excludeFromBalance: financialAccounts.excludeFromBalance, - excludeInitialBalanceFromIncome: - financialAccounts.excludeInitialBalanceFromIncome, - balanceMovements: sql` - coalesce( - sum( - case - when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0 - else ${transactions.amount} - end - ), - 0 - ) - `, - }) - .from(financialAccounts) - .leftJoin( - transactions, - and( - eq(transactions.accountId, financialAccounts.id), - eq(transactions.userId, userId), - eq(transactions.isSettled, true), - ), - ) - .leftJoin(payers, eq(transactions.payerId, payers.id)) - .where( - and( - eq(financialAccounts.userId, userId), - ilike(financialAccounts.status, "inativa"), - sql`(${transactions.id} IS NULL OR ${payers.role} = ${PAYER_ROLE_ADMIN})`, - ), - ) - .groupBy( - financialAccounts.id, - financialAccounts.name, - financialAccounts.accountType, - financialAccounts.status, - financialAccounts.note, - financialAccounts.logo, - financialAccounts.initialBalance, - financialAccounts.excludeFromBalance, - financialAccounts.excludeInitialBalanceFromIncome, - ), - loadLogoOptions(), - ]); - - const accounts = accountRows.map((account) => ({ - id: account.id, - name: account.name, - accountType: account.accountType, - status: account.status, - note: account.note, - logo: account.logo, - initialBalance: Number(account.initialBalance ?? 0), - balance: - Number(account.initialBalance ?? 0) + - Number(account.balanceMovements ?? 0), - excludeFromBalance: account.excludeFromBalance, - excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome, - })); - - return { accounts, logoOptions }; + return fetchAccountsByStatus(userId, true); } export async function fetchAllAccountsForUser(userId: string): Promise<{ diff --git a/src/features/budgets/queries.ts b/src/features/budgets/queries.ts index 7ff9915..b350018 100644 --- a/src/features/budgets/queries.ts +++ b/src/features/budgets/queries.ts @@ -1,8 +1,8 @@ import { and, asc, eq, inArray, isNull, or, sql, sum } from "drizzle-orm"; -import { budgets, categories, payers, transactions } from "@/db/schema"; +import { budgets, categories, transactions } from "@/db/schema"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants"; import { db } from "@/shared/lib/db"; -import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants"; +import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; const toNumber = (value: string | number | null | undefined) => { if (typeof value === "number") return value; @@ -39,6 +39,8 @@ export async function fetchBudgetsForUser( budgets: BudgetData[]; categoriesOptions: CategoryOption[]; }> { + const adminPayerId = await getAdminPayerId(userId); + const [budgetRows, categoryRows] = await Promise.all([ db.query.budgets.findMany({ where: and( @@ -66,20 +68,19 @@ export async function fetchBudgetsForUser( let totalsByCategory = new Map(); - if (categoryIds.length > 0) { + if (categoryIds.length > 0 && adminPayerId) { const totals = await db .select({ categoryId: transactions.categoryId, totalAmount: sum(transactions.amount).as("totalAmount"), }) .from(transactions) - .innerJoin(payers, eq(transactions.payerId, payers.id)) .where( and( eq(transactions.userId, userId), eq(transactions.period, selectedPeriod), eq(transactions.transactionType, "Despesa"), - eq(payers.role, PAYER_ROLE_ADMIN), + eq(transactions.payerId, adminPayerId), inArray(transactions.categoryId, categoryIds), or( isNull(transactions.note), diff --git a/src/features/calendar/queries.ts b/src/features/calendar/queries.ts index 7e8d210..02f831a 100644 --- a/src/features/calendar/queries.ts +++ b/src/features/calendar/queries.ts @@ -1,4 +1,4 @@ -import { and, eq, gte, lte, ne, or } from "drizzle-orm"; +import { and, eq, gte, lte, ne, or, sql } from "drizzle-orm"; import { cards, transactions } from "@/db/schema"; import { buildOptionSets, @@ -10,7 +10,7 @@ import { fetchTransactionFilterSources, } from "@/features/transactions/queries"; import { db } from "@/shared/lib/db"; -import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants"; +import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import type { CalendarData, CalendarEvent } from "@/shared/lib/types/calendar"; import { formatDateKey } from "@/shared/utils/calendar"; import { parsePeriod } from "@/shared/utils/period"; @@ -45,11 +45,13 @@ export const fetchCalendarData = async ({ const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0)); const rangeStartKey = formatDateKey(rangeStart); const rangeEndKey = formatDateKey(rangeEnd); + const adminPayerId = await getAdminPayerId(userId); const [transactionRows, cardRows, filterSources] = await Promise.all([ db.query.transactions.findMany({ where: and( eq(transactions.userId, userId), + adminPayerId ? eq(transactions.payerId, adminPayerId) : sql`false`, ne(transactions.transactionType, TRANSACTION_TYPE_TRANSFERENCIA), or( // Lançamentos cuja data de compra esteja no período do calendário @@ -88,11 +90,7 @@ export const fetchCalendarData = async ({ const cardTotals = new Map(); for (const item of transactionData) { - if ( - !item.cardId || - item.period !== period || - item.pagadorRole !== PAYER_ROLE_ADMIN - ) { + if (!item.cardId || item.period !== period) { continue; } const amount = Math.abs(item.amount ?? 0); @@ -101,12 +99,10 @@ export const fetchCalendarData = async ({ for (const item of transactionData) { const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO; - const isAdminPagador = item.pagadorRole === PAYER_ROLE_ADMIN; - // Para boletos, exibir apenas na data de vencimento e apenas se for pagador admin + // Para boletos, exibir apenas na data de vencimento if (isBoleto) { if ( - isAdminPagador && item.dueDate && isWithinRange(item.dueDate, rangeStartKey, rangeEndKey) ) { @@ -119,9 +115,6 @@ export const fetchCalendarData = async ({ } } else { // Para outros tipos de lançamento, exibir na data de compra - if (!isAdminPagador) { - continue; - } const purchaseDateKey = item.purchaseDate.slice(0, 10); if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) { events.push({ @@ -134,7 +127,7 @@ export const fetchCalendarData = async ({ } } - // Exibir vencimentos apenas de cartões com lançamentos do pagador admin + // Exibir vencimentos apenas de cartões com lançamentos do período for (const card of cardRows) { if (!cardTotals.has(card.id)) { continue; diff --git a/src/features/cards/queries.ts b/src/features/cards/queries.ts index 25d5819..d94ce9b 100644 --- a/src/features/cards/queries.ts +++ b/src/features/cards/queries.ts @@ -25,7 +25,10 @@ export type AccountSimple = { logo: string | null; }; -export async function fetchCardsForUser(userId: string): Promise<{ +async function fetchCardsByStatus( + userId: string, + archived: boolean, +): Promise<{ cards: CardData[]; accounts: AccountSimple[]; logoOptions: string[]; @@ -33,7 +36,12 @@ export async function fetchCardsForUser(userId: string): Promise<{ const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([ db.query.cards.findMany({ orderBy: (table, { desc }) => [desc(table.name)], - where: and(eq(cards.userId, userId), not(ilike(cards.status, "inativo"))), + where: and( + eq(cards.userId, userId), + archived + ? ilike(cards.status, "inativo") + : not(ilike(cards.status, "inativo")), + ), with: { financialAccount: { columns: { @@ -116,95 +124,20 @@ export async function fetchCardsForUser(userId: string): Promise<{ return { cards: cardList, accounts, logoOptions }; } +export async function fetchCardsForUser(userId: string): Promise<{ + cards: CardData[]; + accounts: AccountSimple[]; + logoOptions: string[]; +}> { + return fetchCardsByStatus(userId, false); +} + export async function fetchInactiveForUser(userId: string): Promise<{ cards: CardData[]; accounts: AccountSimple[]; logoOptions: string[]; }> { - const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([ - db.query.cards.findMany({ - orderBy: (table, { desc }) => [desc(table.name)], - where: and(eq(cards.userId, userId), ilike(cards.status, "inativo")), - with: { - financialAccount: { - columns: { - id: true, - name: true, - }, - }, - }, - }), - db.query.financialAccounts.findMany({ - orderBy: (table, { desc }) => [desc(table.name)], - where: eq(financialAccounts.userId, userId), - columns: { - id: true, - name: true, - logo: true, - }, - }), - loadLogoOptions(), - db - .select({ - cardId: transactions.cardId, - total: sql`coalesce(sum(${transactions.amount}), 0)`, - }) - .from(transactions) - .where( - and( - eq(transactions.userId, userId), - or(isNull(transactions.isSettled), eq(transactions.isSettled, false)), - // Recorrente no cartão: só consome limite quando a data da ocorrência já passou - or( - ne(transactions.condition, "Recorrente"), - sql`${transactions.purchaseDate} <= current_date`, - ), - ), - ) - .groupBy(transactions.cardId), - ]); - - const usageMap = new Map(); - usageRows.forEach((row: { cardId: string | null; total: number | null }) => { - if (!row.cardId) return; - usageMap.set(row.cardId, Number(row.total ?? 0)); - }); - - const cardList = cardRows.map((card) => ({ - id: card.id, - name: card.name, - brand: card.brand ?? "", - status: card.status ?? "", - closingDay: card.closingDay, - dueDay: card.dueDay, - note: card.note, - logo: card.logo, - limit: card.limit ? Number(card.limit) : null, - limitInUse: (() => { - const total = usageMap.get(card.id) ?? 0; - return total < 0 ? Math.abs(total) : 0; - })(), - limitAvailable: (() => { - if (!card.limit) { - return null; - } - const total = usageMap.get(card.id) ?? 0; - const inUse = total < 0 ? Math.abs(total) : 0; - return Math.max(Number(card.limit) - inUse, 0); - })(), - accountId: card.accountId, - accountName: - (card.financialAccount as { name?: string } | null)?.name ?? - "Conta não encontrada", - })); - - const accounts = accountRows.map((account) => ({ - id: account.id, - name: account.name, - logo: account.logo, - })); - - return { cards: cardList, accounts, logoOptions }; + return fetchCardsByStatus(userId, true); } export async function fetchAllCardsForUser(userId: string): Promise<{ diff --git a/src/features/dashboard/accounts-queries.ts b/src/features/dashboard/accounts-queries.ts index f9bb28e..624be57 100644 --- a/src/features/dashboard/accounts-queries.ts +++ b/src/features/dashboard/accounts-queries.ts @@ -64,9 +64,7 @@ export async function fetchDashboardAccounts( eq(transactions.accountId, financialAccounts.id), eq(transactions.userId, userId), eq(transactions.isSettled, true), - adminPayerId - ? eq(transactions.payerId, adminPayerId) - : sql`false`, + adminPayerId ? eq(transactions.payerId, adminPayerId) : sql`false`, ), ) .where(eq(financialAccounts.userId, userId)) diff --git a/src/features/dashboard/categories/category-details-queries.ts b/src/features/dashboard/categories/category-details-queries.ts index d7093a6..dcca7d8 100644 --- a/src/features/dashboard/categories/category-details-queries.ts +++ b/src/features/dashboard/categories/category-details-queries.ts @@ -1,10 +1,5 @@ import { and, desc, eq, isNull, ne, or, sql } from "drizzle-orm"; -import { - categories, - financialAccounts, - payers, - transactions, -} from "@/db/schema"; +import { categories, financialAccounts, transactions } from "@/db/schema"; import { mapTransactionsData } from "@/features/transactions/page-helpers"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, @@ -12,7 +7,7 @@ import { } from "@/shared/lib/accounts/constants"; import type { CategoryType } from "@/shared/lib/categories/constants"; import { db } from "@/shared/lib/db"; -import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants"; +import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { calculatePercentageChange } from "@/shared/utils/math"; import { safeToNumber as toNumber } from "@/shared/utils/number"; import { getPreviousPeriod } from "@/shared/utils/period"; @@ -49,34 +44,37 @@ export async function fetchCategoryDetails( const previousPeriod = getPreviousPeriod(period); const transactionType = category.type === "receita" ? "Receita" : "Despesa"; + const adminPayerId = await getAdminPayerId(userId); const sanitizedNote = or( isNull(transactions.note), sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, ); - const currentRows = await db.query.transactions.findMany({ - where: and( - eq(transactions.userId, userId), - eq(transactions.categoryId, categoryId), - eq(transactions.transactionType, transactionType), - eq(transactions.period, period), - sanitizedNote, - ), - with: { - payer: true, - financialAccount: true, - card: true, - category: true, - }, - orderBy: [desc(transactions.purchaseDate), desc(transactions.createdAt)], - }); + const currentRows = adminPayerId + ? await db.query.transactions.findMany({ + where: and( + eq(transactions.userId, userId), + eq(transactions.categoryId, categoryId), + eq(transactions.transactionType, transactionType), + eq(transactions.period, period), + eq(transactions.payerId, adminPayerId), + sanitizedNote, + ), + with: { + payer: true, + financialAccount: true, + card: true, + category: true, + }, + orderBy: [ + desc(transactions.purchaseDate), + desc(transactions.createdAt), + ], + }) + : []; const filteredRows = currentRows.filter((row) => { - // Filtrar apenas payers admin - if (row.payer?.role !== PAYER_ROLE_ADMIN) return false; - - // Excluir saldos iniciais se a conta tiver o flag ativo if ( row.note === INITIAL_BALANCE_NOTE && row.financialAccount?.excludeInitialBalanceFromIncome @@ -94,32 +92,32 @@ export async function fetchCategoryDetails( 0, ); - const [previousTotalRow] = await db - .select({ - total: sql`coalesce(sum(${transactions.amount}), 0)`, - }) - .from(transactions) - .innerJoin(payers, eq(transactions.payerId, payers.id)) - .leftJoin( - financialAccounts, - eq(transactions.accountId, financialAccounts.id), - ) - .where( - and( - eq(transactions.userId, userId), - eq(transactions.categoryId, categoryId), - eq(transactions.transactionType, transactionType), - eq(payers.role, PAYER_ROLE_ADMIN), - sanitizedNote, - eq(transactions.period, previousPeriod), - // Excluir saldos iniciais se a conta tiver o flag ativo - or( - ne(transactions.note, INITIAL_BALANCE_NOTE), - isNull(financialAccounts.excludeInitialBalanceFromIncome), - eq(financialAccounts.excludeInitialBalanceFromIncome, false), - ), - ), - ); + const [previousTotalRow] = adminPayerId + ? await db + .select({ + total: sql`coalesce(sum(${transactions.amount}), 0)`, + }) + .from(transactions) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) + .where( + and( + eq(transactions.userId, userId), + eq(transactions.categoryId, categoryId), + eq(transactions.transactionType, transactionType), + eq(transactions.payerId, adminPayerId), + sanitizedNote, + eq(transactions.period, previousPeriod), + or( + ne(transactions.note, INITIAL_BALANCE_NOTE), + isNull(financialAccounts.excludeInitialBalanceFromIncome), + eq(financialAccounts.excludeInitialBalanceFromIncome, false), + ), + ), + ) + : [{ total: 0 }]; const previousTotal = Math.abs(toNumber(previousTotalRow?.total ?? 0)); const percentageChange = calculatePercentageChange( diff --git a/src/features/dashboard/category-overview-queries.ts b/src/features/dashboard/category-overview-queries.ts new file mode 100644 index 0000000..3c06258 --- /dev/null +++ b/src/features/dashboard/category-overview-queries.ts @@ -0,0 +1,287 @@ +import { and, eq, inArray, or, sql } from "drizzle-orm"; +import { + budgets, + categories, + financialAccounts, + transactions, +} from "@/db/schema"; +import { + buildCategoryBreakdownData, + type DashboardCategoryBreakdownData, +} from "@/features/dashboard/categories/category-breakdown"; +import type { ExpensesByCategoryData } from "@/features/dashboard/categories/expenses-by-category-queries"; +import type { IncomeByCategoryData } from "@/features/dashboard/categories/income-by-category-queries"; +import type { + GoalProgressCategory, + GoalProgressItem, + GoalsProgressData, +} from "@/features/dashboard/goals-progress-queries"; +import { + buildDashboardAdminFilters, + excludeAutoInvoiceEntries, + excludeInitialBalanceWhenConfigured, +} 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 { getPreviousPeriod } from "@/shared/utils/period"; + +const BUDGET_CRITICAL_THRESHOLD = 80; + +type CategorySnapshotRow = { + categoryId: string; + categoryName: string; + categoryIcon: string | null; + categoryType: string | null; + period: string | null; + condition: string; + total: number; + absoluteTotal: number; +}; + +type BudgetSnapshotRow = { + budgetId: string; + categoryId: string | null; + categoryName: string; + categoryIcon: string | null; + period: string; + createdAt: Date; + amount: string | number | null; +}; + +export type DashboardCategoryOverview = { + goalsProgressData: GoalsProgressData; + incomeByCategoryData: IncomeByCategoryData; + expensesByCategoryData: ExpensesByCategoryData; +}; + +const resolveStatus = (usedPercentage: number): GoalProgressItem["status"] => { + if (usedPercentage >= 100) { + return "exceeded"; + } + + if (usedPercentage >= BUDGET_CRITICAL_THRESHOLD) { + return "critical"; + } + + return "on-track"; +}; + +const emptyOverview = (): DashboardCategoryOverview => ({ + goalsProgressData: { + items: [], + categories: [], + totalBudgets: 0, + exceededCount: 0, + criticalCount: 0, + }, + incomeByCategoryData: { + categories: [], + currentTotal: 0, + previousTotal: 0, + }, + expensesByCategoryData: { + categories: [], + currentTotal: 0, + previousTotal: 0, + }, +}); + +const aggregateCategoryRows = ( + rows: CategorySnapshotRow[], + categoryType: "receita" | "despesa", +) => { + const grouped = new Map< + string, + { + categoryId: string; + categoryName: string; + categoryIcon: string | null; + period: string | null; + total: number; + } + >(); + + for (const row of rows) { + if (row.categoryType !== categoryType) { + continue; + } + + const key = `${row.categoryId}:${row.period ?? "sem-periodo"}`; + const current = grouped.get(key) ?? { + categoryId: row.categoryId, + categoryName: row.categoryName, + categoryIcon: row.categoryIcon, + period: row.period, + total: 0, + }; + + current.total += toNumber(row.total); + grouped.set(key, current); + } + + return Array.from(grouped.values()); +}; + +export async function fetchDashboardCategoryOverview( + userId: string, + period: string, +): Promise { + const adminPayerId = await getAdminPayerId(userId); + if (!adminPayerId) { + return emptyOverview(); + } + + const previousPeriod = getPreviousPeriod(period); + + const [transactionRows, budgetRows, categoryRows] = await Promise.all([ + db + .select({ + categoryId: categories.id, + categoryName: categories.name, + categoryIcon: categories.icon, + categoryType: categories.type, + period: transactions.period, + condition: transactions.condition, + total: sql`coalesce(sum(${transactions.amount}), 0)`, + absoluteTotal: sql`coalesce(sum(abs(${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]), + or( + and( + eq(transactions.transactionType, "Despesa"), + eq(categories.type, "despesa"), + excludeAutoInvoiceEntries(), + ), + and( + eq(transactions.transactionType, "Receita"), + eq(categories.type, "receita"), + excludeAutoInvoiceEntries(), + excludeInitialBalanceWhenConfigured(), + ), + ), + ), + ) + .groupBy( + categories.id, + categories.name, + categories.icon, + categories.type, + transactions.period, + transactions.condition, + ), + db + .select({ + budgetId: budgets.id, + categoryId: budgets.categoryId, + categoryName: categories.name, + categoryIcon: categories.icon, + period: budgets.period, + createdAt: budgets.createdAt, + amount: budgets.amount, + }) + .from(budgets) + .innerJoin(categories, eq(budgets.categoryId, categories.id)) + .where(and(eq(budgets.userId, userId), eq(budgets.period, period))), + db.query.categories.findMany({ + where: and(eq(categories.userId, userId), eq(categories.type, "despesa")), + orderBy: (category, { asc }) => [asc(category.name)], + }), + ]); + + const snapshotRows = transactionRows as CategorySnapshotRow[]; + const incomeRows = aggregateCategoryRows(snapshotRows, "receita"); + const expenseRows = aggregateCategoryRows(snapshotRows, "despesa"); + const budgetAmountRows = (budgetRows as BudgetSnapshotRow[]).map((row) => ({ + categoryId: row.categoryId, + amount: row.amount, + })); + + const incomeByCategoryData: DashboardCategoryBreakdownData = + buildCategoryBreakdownData({ + rows: incomeRows, + budgetRows: budgetAmountRows, + period, + }); + const expensesByCategoryData: DashboardCategoryBreakdownData = + buildCategoryBreakdownData({ + rows: expenseRows, + budgetRows: budgetAmountRows, + period, + }); + + const currentExpenseMap = new Map(); + for (const row of snapshotRows) { + if ( + row.categoryType === "despesa" && + row.period === period && + row.condition !== "cancelado" + ) { + currentExpenseMap.set( + row.categoryId, + (currentExpenseMap.get(row.categoryId) ?? 0) + + toNumber(row.absoluteTotal), + ); + } + } + + const goalsCategories: GoalProgressCategory[] = categoryRows.map( + (category) => ({ + id: category.id, + name: category.name, + icon: category.icon, + }), + ); + + const goalItems: GoalProgressItem[] = (budgetRows as BudgetSnapshotRow[]) + .map((row) => { + const budgetAmount = toNumber(row.amount); + const spentAmount = row.categoryId + ? (currentExpenseMap.get(row.categoryId) ?? 0) + : 0; + const usedPercentage = + budgetAmount > 0 ? (spentAmount / budgetAmount) * 100 : 0; + + return { + id: row.budgetId, + 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 = goalItems.filter( + (item) => item.status === "exceeded", + ).length; + const criticalCount = goalItems.filter( + (item) => item.status === "critical", + ).length; + + return { + goalsProgressData: { + items: goalItems, + categories: goalsCategories, + totalBudgets: goalItems.length, + exceededCount, + criticalCount, + }, + incomeByCategoryData, + expensesByCategoryData, + }; +} diff --git a/src/features/dashboard/components/dashboard-metrics-cards.tsx b/src/features/dashboard/components/dashboard-metrics-cards.tsx index ecff3e6..2a901f0 100644 --- a/src/features/dashboard/components/dashboard-metrics-cards.tsx +++ b/src/features/dashboard/components/dashboard-metrics-cards.tsx @@ -95,7 +95,7 @@ const getPercentChange = (current: number, previous: number): string => { }; const getTrendBadgeClass = (trend: Trend, invertTrend: boolean): string => { - if (trend === "flat") return "bg-muted text-muted-foreground"; + if (trend === "flat") return "text-muted-foreground"; const isPositive = invertTrend ? trend === "down" : trend === "up"; return isPositive ? "text-success" : "text-destructive"; }; @@ -120,10 +120,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
- + {label} diff --git a/src/features/dashboard/components/installment-analysis/installment-analysis-page.tsx b/src/features/dashboard/components/installment-analysis/installment-analysis-page.tsx index c505756..da2c281 100644 --- a/src/features/dashboard/components/installment-analysis/installment-analysis-page.tsx +++ b/src/features/dashboard/components/installment-analysis/installment-analysis-page.tsx @@ -38,7 +38,7 @@ export function InstallmentAnalysisPage({ return allInstallmentsSelected && data.installmentGroups.length > 0; }, [selectedInstallments, data]); - // Função para selecionar/desselecionar tudo + // Função para selecionar/desmarcar tudo const toggleSelectAll = () => { if (isAllSelected) { // Desmarcar tudo @@ -59,7 +59,7 @@ export function InstallmentAnalysisPage({ } }; - // Função para selecionar/desselecionar um grupo de parcelas + // Função para selecionar/desmarcar um grupo de parcelas const toggleGroupSelection = (seriesId: string, installmentIds: string[]) => { const newMap = new Map(selectedInstallments); const current = newMap.get(seriesId) || new Set(); @@ -75,7 +75,7 @@ export function InstallmentAnalysisPage({ setSelectedInstallments(newMap); }; - // Função para selecionar/desselecionar parcela individual + // Função para selecionar/desmarcar parcela individual const toggleInstallmentSelection = ( seriesId: string, installmentId: string, diff --git a/src/features/dashboard/components/invoices/invoice-list-item.tsx b/src/features/dashboard/components/invoices/invoice-list-item.tsx index acd72c5..6abadb6 100644 --- a/src/features/dashboard/components/invoices/invoice-list-item.tsx +++ b/src/features/dashboard/components/invoices/invoice-list-item.tsx @@ -69,7 +69,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) { {linkNode} -

+

Distribuição por pagador

    diff --git a/src/features/dashboard/current-period-overview-queries.ts b/src/features/dashboard/current-period-overview-queries.ts new file mode 100644 index 0000000..6bca371 --- /dev/null +++ b/src/features/dashboard/current-period-overview-queries.ts @@ -0,0 +1,531 @@ +import { and, desc, eq } from "drizzle-orm"; +import { + cards, + categories, + financialAccounts, + transactions, +} from "@/db/schema"; +import type { DashboardBillsSnapshot } from "@/features/dashboard/bills-queries"; +import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries"; +import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries"; +import type { + TopExpense, + TopExpensesData, +} from "@/features/dashboard/expenses/top-expenses-queries"; +import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries"; +import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries"; +import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries"; +import type { PurchasesByCategoryData } from "@/features/dashboard/purchases-by-category-queries"; +import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries"; +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"; + +const PAYMENT_METHOD_BOLETO = "Boleto"; +const PAYMENT_METHOD_CARD = "Cartão de Crédito"; +const TRANSACTION_TYPE_EXPENSE = "Despesa"; +const TRANSACTION_TYPE_INCOME = "Receita"; +const CONDITION_RECURRING = "Recorrente"; +const CONDITION_INSTALLMENT = "Parcelado"; + +type CurrentPeriodTransactionRow = { + id: string; + name: string; + amount: string | number | null; + period: string; + purchaseDate: Date; + paymentMethod: string; + condition: string; + currentInstallment: number | null; + installmentCount: number | null; + recurrenceCount: number | null; + dueDate: Date | null; + boletoPaymentDate: Date | null; + isSettled: boolean | null; + transactionType: string; + note: string | null; + isAnticipated: boolean; + categoryId: string | null; + categoryName: string | null; + categoryType: string | null; + cardLogo: string | null; + accountLogo: string | null; +}; + +type CategoryOption = PurchasesByCategoryData["categories"][number]; +type CategoryTransaction = + PurchasesByCategoryData["transactionsByCategory"][string][number]; + +export type DashboardCurrentPeriodOverview = { + billsSnapshot: DashboardBillsSnapshot; + paymentStatusData: PaymentStatusData; + paymentConditionsData: PaymentConditionsData; + paymentMethodsData: PaymentMethodsData; + recurringExpensesData: RecurringExpensesData; + installmentExpensesData: InstallmentExpensesData; + topEstablishmentsData: TopEstablishmentsData; + topExpensesAll: TopExpensesData; + topExpensesCardOnly: TopExpensesData; + purchasesByCategoryData: PurchasesByCategoryData; +}; + +const emptyOverview = (): DashboardCurrentPeriodOverview => ({ + billsSnapshot: { + bills: [], + totalPendingAmount: 0, + pendingCount: 0, + }, + paymentStatusData: { + income: { total: 0, confirmed: 0, pending: 0 }, + expenses: { total: 0, confirmed: 0, pending: 0 }, + }, + paymentConditionsData: { conditions: [] }, + paymentMethodsData: { methods: [] }, + recurringExpensesData: { expenses: [] }, + installmentExpensesData: { expenses: [] }, + topEstablishmentsData: { establishments: [] }, + topExpensesAll: { expenses: [] }, + topExpensesCardOnly: { expenses: [] }, + purchasesByCategoryData: { + categories: [], + transactionsByCategory: {}, + }, +}); + +const normalizeNote = (note: string | null | undefined) => note?.trim() ?? ""; + +const isAutoInvoiceNote = (note: string | null | undefined) => + normalizeNote(note) + .toLowerCase() + .startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX.toLowerCase()); + +const isInitialBalanceNote = (note: string | null | undefined) => + normalizeNote(note) === INITIAL_BALANCE_NOTE; + +const shouldIncludeWithoutAutoInvoice = (note: string | null | undefined) => + !isAutoInvoiceNote(note); + +const shouldIncludeWithoutAutoGenerated = (note: string | null | undefined) => + !isInitialBalanceNote(note) && !isAutoInvoiceNote(note); + +const shouldIncludeNamedItem = (name: string) => { + const normalized = name.trim().toLowerCase(); + + if (normalized === "saldo inicial") { + return false; + } + + if (normalized.includes("fatura")) { + return false; + } + + return true; +}; + +const buildBillsSnapshot = ( + rows: CurrentPeriodTransactionRow[], +): DashboardBillsSnapshot => { + const bills = rows + .filter((row) => row.paymentMethod === PAYMENT_METHOD_BOLETO) + .map((row) => ({ + id: row.id, + name: row.name, + amount: Math.abs(toNumber(row.amount)), + dueDate: row.dueDate ? row.dueDate.toISOString().slice(0, 10) : null, + boletoPaymentDate: row.boletoPaymentDate + ? row.boletoPaymentDate.toISOString().slice(0, 10) + : null, + isSettled: Boolean(row.isSettled), + })) + .sort((a, b) => { + if (a.isSettled !== b.isSettled) { + return Number(a.isSettled) - Number(b.isSettled); + } + + const dueA = a.dueDate + ? new Date(a.dueDate).getTime() + : Number.POSITIVE_INFINITY; + const dueB = b.dueDate + ? new Date(b.dueDate).getTime() + : Number.POSITIVE_INFINITY; + if (dueA !== dueB) { + return dueA - dueB; + } + + return a.name.localeCompare(b.name, "pt-BR"); + }); + + const pendingBills = bills.filter((bill) => !bill.isSettled); + + return { + bills, + totalPendingAmount: pendingBills.reduce( + (total, bill) => total + bill.amount, + 0, + ), + pendingCount: pendingBills.length, + }; +}; + +const buildPaymentStatusData = ( + rows: CurrentPeriodTransactionRow[], +): PaymentStatusData => { + const result: PaymentStatusData = { + income: { total: 0, confirmed: 0, pending: 0 }, + expenses: { total: 0, confirmed: 0, pending: 0 }, + }; + + for (const row of rows) { + if ( + !shouldIncludeWithoutAutoInvoice(row.note) || + (row.transactionType !== TRANSACTION_TYPE_INCOME && + row.transactionType !== TRANSACTION_TYPE_EXPENSE) + ) { + continue; + } + + const amount = toNumber(row.amount); + const target = + row.transactionType === TRANSACTION_TYPE_INCOME + ? result.income + : result.expenses; + + if (row.isSettled === true) { + target.confirmed += amount; + } else { + target.pending += amount; + } + } + + result.income.total = result.income.confirmed + result.income.pending; + result.expenses.total = result.expenses.confirmed + result.expenses.pending; + + return result; +}; + +const buildExpenseBreakdown = ( + rows: CurrentPeriodTransactionRow[], + field: "condition" | "paymentMethod", +) => { + const groups = new Map< + string, + { + amount: number; + transactions: number; + } + >(); + + for (const row of rows) { + if ( + row.transactionType !== TRANSACTION_TYPE_EXPENSE || + !shouldIncludeWithoutAutoGenerated(row.note) + ) { + continue; + } + + const key = row[field]; + const current = groups.get(key) ?? { amount: 0, transactions: 0 }; + current.amount += Math.abs(toNumber(row.amount)); + current.transactions += 1; + groups.set(key, current); + } + + const entries = Array.from(groups.entries()).map(([key, value]) => ({ + key, + amount: value.amount, + transactions: value.transactions, + })); + const overallTotal = entries.reduce( + (total, entry) => total + entry.amount, + 0, + ); + + return entries + .map((entry) => ({ + key: entry.key, + amount: entry.amount, + transactions: entry.transactions, + percentage: + overallTotal > 0 + ? Number(((entry.amount / overallTotal) * 100).toFixed(2)) + : 0, + })) + .sort((a, b) => b.amount - a.amount); +}; + +const buildPaymentConditionsData = ( + rows: CurrentPeriodTransactionRow[], +): PaymentConditionsData => ({ + conditions: buildExpenseBreakdown(rows, "condition").map((entry) => ({ + condition: entry.key, + amount: entry.amount, + transactions: entry.transactions, + percentage: entry.percentage, + })), +}); + +const buildPaymentMethodsData = ( + rows: CurrentPeriodTransactionRow[], +): PaymentMethodsData => ({ + methods: buildExpenseBreakdown(rows, "paymentMethod").map((entry) => ({ + paymentMethod: entry.key, + amount: entry.amount, + transactions: entry.transactions, + percentage: entry.percentage, + })), +}); + +const buildRecurringExpensesData = ( + rows: CurrentPeriodTransactionRow[], +): RecurringExpensesData => ({ + expenses: rows + .filter( + (row) => + row.transactionType === TRANSACTION_TYPE_EXPENSE && + row.condition === CONDITION_RECURRING && + shouldIncludeWithoutAutoGenerated(row.note), + ) + .map((row) => ({ + id: row.id, + name: row.name, + amount: Math.abs(toNumber(row.amount)), + paymentMethod: row.paymentMethod, + recurrenceCount: row.recurrenceCount, + })), +}); + +const buildInstallmentExpensesData = ( + rows: CurrentPeriodTransactionRow[], +): InstallmentExpensesData => ({ + expenses: rows + .filter( + (row) => + row.transactionType === TRANSACTION_TYPE_EXPENSE && + row.condition === CONDITION_INSTALLMENT && + row.isAnticipated === false && + shouldIncludeWithoutAutoGenerated(row.note), + ) + .map((row) => ({ + id: row.id, + name: row.name, + amount: Math.abs(toNumber(row.amount)), + paymentMethod: row.paymentMethod, + currentInstallment: row.currentInstallment, + installmentCount: row.installmentCount, + dueDate: row.dueDate, + purchaseDate: row.purchaseDate, + period: row.period, + })) + .sort((a, b) => { + const remainingA = + a.installmentCount && a.currentInstallment + ? a.installmentCount - a.currentInstallment + : 0; + const remainingB = + b.installmentCount && b.currentInstallment + ? b.installmentCount - b.currentInstallment + : 0; + + return remainingA - remainingB; + }), +}); + +const mapTopExpense = (row: CurrentPeriodTransactionRow): 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, +}); + +const buildTopExpensesData = ( + rows: CurrentPeriodTransactionRow[], + cardOnly: boolean, +): TopExpensesData => ({ + expenses: rows + .filter( + (row) => + row.transactionType === TRANSACTION_TYPE_EXPENSE && + shouldIncludeWithoutAutoGenerated(row.note) && + (!cardOnly || row.paymentMethod === PAYMENT_METHOD_CARD), + ) + .sort((a, b) => toNumber(a.amount) - toNumber(b.amount)) + .slice(0, 10) + .map(mapTopExpense), +}); + +const buildTopEstablishmentsData = ( + rows: CurrentPeriodTransactionRow[], +): TopEstablishmentsData => { + const groups = new Map< + string, + { + amount: number; + occurrences: number; + logo: string | null; + } + >(); + + for (const row of rows) { + if ( + row.transactionType !== TRANSACTION_TYPE_EXPENSE || + !shouldIncludeWithoutAutoGenerated(row.note) + ) { + continue; + } + + const current = groups.get(row.name) ?? { + amount: 0, + occurrences: 0, + logo: row.cardLogo ?? row.accountLogo ?? null, + }; + + current.amount += toNumber(row.amount); + current.occurrences += 1; + current.logo = current.logo ?? row.cardLogo ?? row.accountLogo ?? null; + groups.set(row.name, current); + } + + const topRows = Array.from(groups.entries()) + .map(([name, value]) => ({ + id: name, + name, + amount: Math.abs(value.amount), + occurrences: value.occurrences, + logo: value.logo, + })) + .sort((a, b) => { + if (a.occurrences !== b.occurrences) { + return b.occurrences - a.occurrences; + } + + return b.amount - a.amount; + }) + .slice(0, 10); + + return { + establishments: topRows.filter((row) => shouldIncludeNamedItem(row.name)), + }; +}; + +const buildPurchasesByCategoryData = ( + rows: CurrentPeriodTransactionRow[], +): PurchasesByCategoryData => { + const categoriesMap = new Map(); + const transactionsByCategory: Record = {}; + + for (const row of rows) { + if ( + !row.categoryId || + !row.categoryName || + !row.categoryType || + !["despesa", "receita"].includes(row.categoryType) || + !shouldIncludeWithoutAutoGenerated(row.note) || + !shouldIncludeNamedItem(row.name) + ) { + continue; + } + + if (!categoriesMap.has(row.categoryId)) { + categoriesMap.set(row.categoryId, { + id: row.categoryId, + name: row.categoryName, + type: row.categoryType, + }); + } + + const transactionList = transactionsByCategory[row.categoryId] ?? []; + if (transactionList.length < 10) { + transactionList.push({ + id: row.id, + name: row.name, + amount: Math.abs(toNumber(row.amount)), + purchaseDate: row.purchaseDate, + logo: row.cardLogo ?? row.accountLogo ?? null, + }); + transactionsByCategory[row.categoryId] = transactionList; + } + } + + return { + categories: Array.from(categoriesMap.values()).sort((a, b) => { + if (a.type !== b.type) { + return a.type === "receita" ? -1 : 1; + } + + return a.name.localeCompare(b.name, "pt-BR"); + }), + transactionsByCategory, + }; +}; + +export async function fetchDashboardCurrentPeriodOverview( + userId: string, + period: string, +): Promise { + const adminPayerId = await getAdminPayerId(userId); + if (!adminPayerId) { + return emptyOverview(); + } + + const rows = (await db + .select({ + id: transactions.id, + name: transactions.name, + amount: transactions.amount, + period: transactions.period, + purchaseDate: transactions.purchaseDate, + paymentMethod: transactions.paymentMethod, + condition: transactions.condition, + currentInstallment: transactions.currentInstallment, + installmentCount: transactions.installmentCount, + recurrenceCount: transactions.recurrenceCount, + dueDate: transactions.dueDate, + boletoPaymentDate: transactions.boletoPaymentDate, + isSettled: transactions.isSettled, + transactionType: transactions.transactionType, + note: transactions.note, + isAnticipated: transactions.isAnticipated, + categoryId: transactions.categoryId, + categoryName: categories.name, + categoryType: categories.type, + cardLogo: cards.logo, + accountLogo: financialAccounts.logo, + }) + .from(transactions) + .leftJoin(cards, eq(transactions.cardId, cards.id)) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) + .leftJoin(categories, eq(transactions.categoryId, categories.id)) + .where( + and( + eq(transactions.userId, userId), + eq(transactions.period, period), + eq(transactions.payerId, adminPayerId), + ), + ) + .orderBy( + desc(transactions.purchaseDate), + desc(transactions.createdAt), + )) as CurrentPeriodTransactionRow[]; + + return { + billsSnapshot: buildBillsSnapshot(rows), + paymentStatusData: buildPaymentStatusData(rows), + paymentConditionsData: buildPaymentConditionsData(rows), + paymentMethodsData: buildPaymentMethodsData(rows), + recurringExpensesData: buildRecurringExpensesData(rows), + installmentExpensesData: buildInstallmentExpensesData(rows), + topEstablishmentsData: buildTopEstablishmentsData(rows), + topExpensesAll: buildTopExpensesData(rows, false), + topExpensesCardOnly: buildTopExpensesData(rows, true), + purchasesByCategoryData: buildPurchasesByCategoryData(rows), + }; +} diff --git a/src/features/dashboard/dashboard-metrics-queries.ts b/src/features/dashboard/dashboard-metrics-queries.ts index bc2cffa..bd509f2 100644 --- a/src/features/dashboard/dashboard-metrics-queries.ts +++ b/src/features/dashboard/dashboard-metrics-queries.ts @@ -63,7 +63,6 @@ const ensurePeriodTotals = ( }; // Re-export for backward compatibility -export { getPreviousPeriod }; export async function fetchDashboardCardMetrics( userId: string, diff --git a/src/features/dashboard/fetch-dashboard-data.ts b/src/features/dashboard/fetch-dashboard-data.ts index bd3696b..b0f0ec5 100644 --- a/src/features/dashboard/fetch-dashboard-data.ts +++ b/src/features/dashboard/fetch-dashboard-data.ts @@ -1,100 +1,65 @@ import { unstable_cache } from "next/cache"; import { fetchDashboardAccounts } from "./accounts-queries"; -import { fetchDashboardBills } from "./bills-queries"; -import { fetchExpensesByCategory } from "./categories/expenses-by-category-queries"; -import { fetchIncomeByCategory } from "./categories/income-by-category-queries"; -import { fetchDashboardCardMetrics } from "./dashboard-metrics-queries"; -import { fetchInstallmentExpenses } from "./expenses/installment-expenses-queries"; -import { fetchRecurringExpenses } from "./expenses/recurring-expenses-queries"; -import { fetchTopExpenses } from "./expenses/top-expenses-queries"; -import { fetchGoalsProgressData } from "./goals-progress-queries"; -import { fetchIncomeExpenseBalance } from "./income-expense-balance-queries"; +import { fetchDashboardCategoryOverview } from "./category-overview-queries"; +import { fetchDashboardCurrentPeriodOverview } from "./current-period-overview-queries"; import { fetchDashboardInvoices } from "./invoices-queries"; import { fetchDashboardNotes } from "./notes-queries"; import { fetchDashboardPayers } from "./payers-queries"; -import { fetchPaymentConditions } from "./payments/payment-conditions-queries"; -import { fetchPaymentMethods } from "./payments/payment-methods-queries"; -import { fetchPaymentStatus } from "./payments/payment-status-queries"; -import { fetchPurchasesByCategory } from "./purchases-by-category-queries"; -import { fetchTopEstablishments } from "./top-establishments-queries"; +import { fetchDashboardPeriodOverview } from "./period-overview-queries"; async function fetchDashboardDataInternal(userId: string, period: string) { const [ - metrics, + periodOverview, accountsSnapshot, invoicesSnapshot, - billsSnapshot, - goalsProgressData, - paymentStatusData, - incomeExpenseBalanceData, + currentPeriodOverview, + categoryOverview, pagadoresSnapshot, notesData, - paymentConditionsData, - paymentMethodsData, - recurringExpensesData, - installmentExpensesData, - topEstablishmentsData, - topExpensesAll, - topExpensesCardOnly, - purchasesByCategoryData, - incomeByCategoryData, - expensesByCategoryData, ] = await Promise.all([ - fetchDashboardCardMetrics(userId, period), + fetchDashboardPeriodOverview(userId, period), fetchDashboardAccounts(userId), fetchDashboardInvoices(userId, period), - fetchDashboardBills(userId, period), - fetchGoalsProgressData(userId, period), - fetchPaymentStatus(userId, period), - fetchIncomeExpenseBalance(userId, period), + fetchDashboardCurrentPeriodOverview(userId, period), + fetchDashboardCategoryOverview(userId, period), fetchDashboardPayers(userId, period), fetchDashboardNotes(userId), - fetchPaymentConditions(userId, period), - fetchPaymentMethods(userId, period), - fetchRecurringExpenses(userId, period), - fetchInstallmentExpenses(userId, period), - fetchTopEstablishments(userId, period), - fetchTopExpenses(userId, period, false), - fetchTopExpenses(userId, period, true), - fetchPurchasesByCategory(userId, period), - fetchIncomeByCategory(userId, period), - fetchExpensesByCategory(userId, period), ]); return { - metrics, + metrics: periodOverview.metrics, accountsSnapshot, invoicesSnapshot, - billsSnapshot, - goalsProgressData, - paymentStatusData, - incomeExpenseBalanceData, + billsSnapshot: currentPeriodOverview.billsSnapshot, + goalsProgressData: categoryOverview.goalsProgressData, + paymentStatusData: currentPeriodOverview.paymentStatusData, + incomeExpenseBalanceData: periodOverview.incomeExpenseBalanceData, pagadoresSnapshot, notesData, - paymentConditionsData, - paymentMethodsData, - recurringExpensesData, - installmentExpensesData, - topEstablishmentsData, - topExpensesAll, - topExpensesCardOnly, - purchasesByCategoryData, - incomeByCategoryData, - expensesByCategoryData, + paymentConditionsData: currentPeriodOverview.paymentConditionsData, + paymentMethodsData: currentPeriodOverview.paymentMethodsData, + recurringExpensesData: currentPeriodOverview.recurringExpensesData, + installmentExpensesData: currentPeriodOverview.installmentExpensesData, + topEstablishmentsData: currentPeriodOverview.topEstablishmentsData, + topExpensesAll: currentPeriodOverview.topExpensesAll, + topExpensesCardOnly: currentPeriodOverview.topExpensesCardOnly, + purchasesByCategoryData: currentPeriodOverview.purchasesByCategoryData, + incomeByCategoryData: categoryOverview.incomeByCategoryData, + expensesByCategoryData: categoryOverview.expensesByCategoryData, }; } /** * Cached dashboard data fetcher. * Uses unstable_cache with tags for revalidation on mutations. - * Cache is keyed by userId + period, and invalidated via "dashboard" tag. + * Cache is keyed by userId + period, and invalidated via user-scoped tags. */ export function fetchDashboardData(userId: string, period: string) { return unstable_cache( () => fetchDashboardDataInternal(userId, period), [`dashboard-${userId}-${period}`], { - tags: ["dashboard", `dashboard-${userId}`], + tags: [`dashboard-${userId}`], revalidate: 60, }, )(); diff --git a/src/features/dashboard/navbar-queries.ts b/src/features/dashboard/navbar-queries.ts new file mode 100644 index 0000000..3e38a11 --- /dev/null +++ b/src/features/dashboard/navbar-queries.ts @@ -0,0 +1,67 @@ +import { eq } from "drizzle-orm"; +import { unstable_cache } from "next/cache"; +import { payers } from "@/db/schema"; +import { fetchPendingInboxCount } from "@/features/inbox/queries"; +import { db } from "@/shared/lib/db"; +import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; +import { + type DashboardNotificationsSnapshot, + fetchDashboardNotifications, +} from "./notifications-queries"; + +export type DashboardNavbarData = { + pagadorAvatarUrl: string | null; + preLancamentosCount: number; + notificationsSnapshot: DashboardNotificationsSnapshot; +}; + +async function fetchAdminPayerAvatarUrl( + userId: string, +): Promise { + const adminPayerId = await getAdminPayerId(userId); + + if (!adminPayerId) { + return null; + } + + const payer = await db.query.payers.findFirst({ + columns: { + avatarUrl: true, + }, + where: eq(payers.id, adminPayerId), + }); + + return payer?.avatarUrl ?? null; +} + +async function fetchDashboardNavbarDataInternal( + userId: string, + currentPeriod: string, +): Promise { + const [pagadorAvatarUrl, notificationsSnapshot, preLancamentosCount] = + await Promise.all([ + fetchAdminPayerAvatarUrl(userId), + fetchDashboardNotifications(userId, currentPeriod), + fetchPendingInboxCount(userId), + ]); + + return { + pagadorAvatarUrl, + preLancamentosCount, + notificationsSnapshot, + }; +} + +export function fetchDashboardNavbarData( + userId: string, + currentPeriod: string, +) { + return unstable_cache( + () => fetchDashboardNavbarDataInternal(userId, currentPeriod), + [`dashboard-navbar-${userId}-${currentPeriod}`], + { + tags: [`dashboard-${userId}`], + revalidate: 60, + }, + )(); +} diff --git a/src/features/dashboard/notes-queries.ts b/src/features/dashboard/notes-queries.ts index 0cc3fda..7c4b93e 100644 --- a/src/features/dashboard/notes-queries.ts +++ b/src/features/dashboard/notes-queries.ts @@ -18,39 +18,18 @@ export type DashboardNote = { createdAt: string; }; -const parseTasks = (value: string | null): DashboardTask[] | undefined => { +function parseTasks(value: string | null): DashboardTask[] | undefined { if (!value) { return undefined; } try { const parsed = JSON.parse(value); - if (!Array.isArray(parsed)) { - return undefined; - } - - return parsed - .filter((item): item is DashboardTask => { - if (!item || typeof item !== "object") { - return false; - } - const candidate = item as Partial; - return ( - typeof candidate.id === "string" && - typeof candidate.text === "string" && - typeof candidate.completed === "boolean" - ); - }) - .map((task) => ({ - id: task.id, - text: task.text, - completed: task.completed, - })); - } catch (error) { - console.error("Failed to parse dashboard note tasks", error); + return Array.isArray(parsed) ? parsed : undefined; + } catch { return undefined; } -}; +} export async function fetchDashboardNotes( userId: string, diff --git a/src/features/dashboard/notifications-queries.ts b/src/features/dashboard/notifications-queries.ts index fb04c36..2034bd3 100644 --- a/src/features/dashboard/notifications-queries.ts +++ b/src/features/dashboard/notifications-queries.ts @@ -69,80 +69,7 @@ export async function fetchDashboardNotifications( const adminPayerId = await getAdminPayerId(userId); - // --- Faturas atrasadas (períodos anteriores) --- - const overdueInvoices = await db - .select({ - invoiceId: invoices.id, - cardId: cards.id, - cardName: cards.name, - cardLogo: cards.logo, - dueDay: cards.dueDay, - period: invoices.period, - totalAmount: sql` - COALESCE( - (SELECT SUM(${transactions.amount}) - FROM ${transactions} - WHERE ${transactions.cardId} = ${cards.id} - AND ${transactions.period} = ${invoices.period} - AND ${transactions.userId} = ${invoices.userId}), - 0 - ) - `, - }) - .from(invoices) - .innerJoin(cards, eq(invoices.cardId, cards.id)) - .where( - and( - eq(invoices.userId, userId), - eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING), - lt(invoices.period, currentPeriod), - ), - ); - - // --- Faturas do período atual --- - const currentInvoices = await db - .select({ - invoiceId: invoices.id, - cardId: cards.id, - cardName: cards.name, - cardLogo: cards.logo, - dueDay: cards.dueDay, - period: sql`COALESCE(${invoices.period}, ${currentPeriod})`, - paymentStatus: invoices.paymentStatus, - totalAmount: sql` - COALESCE(SUM(${transactions.amount}), 0) - `, - transactionCount: sql`COUNT(${transactions.id})`, - }) - .from(cards) - .leftJoin( - invoices, - and( - eq(invoices.cardId, cards.id), - eq(invoices.userId, userId), - eq(invoices.period, currentPeriod), - ), - ) - .leftJoin( - transactions, - and( - eq(transactions.cardId, cards.id), - eq(transactions.userId, userId), - eq(transactions.period, currentPeriod), - ), - ) - .where(eq(cards.userId, userId)) - .groupBy( - invoices.id, - cards.id, - cards.name, - cards.logo, - cards.dueDay, - invoices.period, - invoices.paymentStatus, - ); - - // --- Boletos não pagos --- + // --- Build conditions that depend on adminPayerId --- const boletosConditions = [ eq(transactions.userId, userId), eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO), @@ -152,18 +79,6 @@ export async function fetchDashboardNotifications( boletosConditions.push(eq(transactions.payerId, adminPayerId)); } - const boletosRows = await db - .select({ - id: transactions.id, - name: transactions.name, - amount: transactions.amount, - dueDate: transactions.dueDate, - period: transactions.period, - }) - .from(transactions) - .where(and(...boletosConditions)); - - // --- Orçamentos do período atual --- const budgetJoinConditions = [ eq(transactions.categoryId, budgets.categoryId), eq(transactions.userId, budgets.userId), @@ -175,18 +90,116 @@ export async function fetchDashboardNotifications( budgetJoinConditions.push(eq(transactions.payerId, adminPayerId)); } - const budgetRows = await db - .select({ - orcamentoId: budgets.id, - budgetAmount: budgets.amount, - categoriaName: categories.name, - spentAmount: sql`COALESCE(SUM(ABS(${transactions.amount})), 0)`, - }) - .from(budgets) - .innerJoin(categories, eq(budgets.categoryId, categories.id)) - .leftJoin(transactions, and(...budgetJoinConditions)) - .where(and(eq(budgets.userId, userId), eq(budgets.period, currentPeriod))) - .groupBy(budgets.id, budgets.amount, categories.name); + // --- All 4 queries are independent — run in parallel --- + const [overdueInvoices, currentInvoices, boletosRows, budgetRows] = + await Promise.all([ + // Faturas atrasadas (períodos anteriores) + db + .select({ + invoiceId: invoices.id, + cardId: cards.id, + cardName: cards.name, + cardLogo: cards.logo, + dueDay: cards.dueDay, + period: invoices.period, + totalAmount: sql< + number | null + >`COALESCE(SUM(${transactions.amount}), 0)`, + }) + .from(invoices) + .innerJoin(cards, eq(invoices.cardId, cards.id)) + .leftJoin( + transactions, + and( + eq(transactions.cardId, invoices.cardId), + eq(transactions.period, invoices.period), + eq(transactions.userId, invoices.userId), + ), + ) + .where( + and( + eq(invoices.userId, userId), + eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING), + lt(invoices.period, currentPeriod), + ), + ) + .groupBy( + invoices.id, + cards.id, + cards.name, + cards.logo, + cards.dueDay, + invoices.period, + ), + // Faturas do período atual + db + .select({ + invoiceId: invoices.id, + cardId: cards.id, + cardName: cards.name, + cardLogo: cards.logo, + dueDay: cards.dueDay, + period: sql`COALESCE(${invoices.period}, ${currentPeriod})`, + paymentStatus: invoices.paymentStatus, + totalAmount: sql` + COALESCE(SUM(${transactions.amount}), 0) + `, + transactionCount: sql`COUNT(${transactions.id})`, + }) + .from(cards) + .leftJoin( + invoices, + and( + eq(invoices.cardId, cards.id), + eq(invoices.userId, userId), + eq(invoices.period, currentPeriod), + ), + ) + .leftJoin( + transactions, + and( + eq(transactions.cardId, cards.id), + eq(transactions.userId, userId), + eq(transactions.period, currentPeriod), + ), + ) + .where(eq(cards.userId, userId)) + .groupBy( + invoices.id, + cards.id, + cards.name, + cards.logo, + cards.dueDay, + invoices.period, + invoices.paymentStatus, + ), + // Boletos não pagos + db + .select({ + id: transactions.id, + name: transactions.name, + amount: transactions.amount, + dueDate: transactions.dueDate, + period: transactions.period, + }) + .from(transactions) + .where(and(...boletosConditions)), + // Orçamentos do período atual + db + .select({ + orcamentoId: budgets.id, + budgetAmount: budgets.amount, + categoriaName: categories.name, + spentAmount: sql`COALESCE(SUM(ABS(${transactions.amount})), 0)`, + }) + .from(budgets) + .innerJoin(categories, eq(budgets.categoryId, categories.id)) + .leftJoin(transactions, and(...budgetJoinConditions)) + .where( + and(eq(budgets.userId, userId), eq(budgets.period, currentPeriod)), + ) + .groupBy(budgets.id, budgets.amount, categories.name), + ]); // ===================== // Processar notificações diff --git a/src/features/dashboard/page-data-queries.ts b/src/features/dashboard/page-data-queries.ts new file mode 100644 index 0000000..883db8e --- /dev/null +++ b/src/features/dashboard/page-data-queries.ts @@ -0,0 +1,78 @@ +import { unstable_cache } from "next/cache"; +import { fetchDashboardData } from "@/features/dashboard/fetch-dashboard-data"; +import { fetchUserDashboardPreferences } from "@/features/dashboard/preferences-queries"; +import { + buildOptionSets, + buildSluggedFilters, +} from "@/features/transactions/page-helpers"; +import { + fetchRecentEstablishments, + fetchTransactionFilterSources, +} from "@/features/transactions/queries"; + +export type DashboardQuickActionOptions = { + payerOptions: ReturnType["payerOptions"]; + splitPayerOptions: ReturnType["splitPayerOptions"]; + defaultPayerId: string | null; + accountOptions: ReturnType["accountOptions"]; + cardOptions: ReturnType["cardOptions"]; + categoryOptions: ReturnType["categoryOptions"]; + estabelecimentos: string[]; +}; + +async function fetchDashboardQuickActionOptionsInternal( + userId: string, +): Promise { + const [filterSources, estabelecimentos] = await Promise.all([ + fetchTransactionFilterSources(userId), + fetchRecentEstablishments(userId), + ]); + + const sluggedFilters = buildSluggedFilters(filterSources); + const { + payerOptions, + splitPayerOptions, + defaultPayerId, + accountOptions, + cardOptions, + categoryOptions, + } = buildOptionSets({ + ...sluggedFilters, + payerRows: filterSources.payerRows, + }); + + return { + payerOptions, + splitPayerOptions, + defaultPayerId, + accountOptions, + cardOptions, + categoryOptions, + estabelecimentos, + }; +} + +export function fetchDashboardQuickActionOptions(userId: string) { + return unstable_cache( + () => fetchDashboardQuickActionOptionsInternal(userId), + [`dashboard-quick-actions-${userId}`], + { + tags: [`dashboard-${userId}`], + revalidate: 60, + }, + )(); +} + +export async function fetchDashboardPageData(userId: string, period: string) { + const [dashboardData, preferences, quickActionOptions] = await Promise.all([ + fetchDashboardData(userId, period), + fetchUserDashboardPreferences(userId), + fetchDashboardQuickActionOptions(userId), + ]); + + return { + dashboardData, + preferences, + quickActionOptions, + }; +} diff --git a/src/features/dashboard/period-overview-queries.ts b/src/features/dashboard/period-overview-queries.ts new file mode 100644 index 0000000..fe2359c --- /dev/null +++ b/src/features/dashboard/period-overview-queries.ts @@ -0,0 +1,209 @@ +import { and, asc, eq, gte, inArray, lte, ne, sum } from "drizzle-orm"; +import { financialAccounts, transactions } from "@/db/schema"; +import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries"; +import type { + IncomeExpenseBalanceData, + MonthData, +} from "@/features/dashboard/income-expense-balance-queries"; +import { + buildDashboardAdminFilters, + excludeAutoInvoiceEntries, + excludeInitialBalanceWhenConfigured, +} 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, + buildPeriodWindow, + comparePeriods, + formatPeriodMonthShort, + getCurrentPeriod, + getPreviousPeriod, +} from "@/shared/utils/period"; + +const TRANSACTION_TYPE_INCOME = "Receita"; +const TRANSACTION_TYPE_EXPENSE = "Despesa"; +const TRANSACTION_TYPE_TRANSFER = "Transferência"; + +type PeriodTotals = { + receitas: number; + despesas: number; + balanco: number; +}; + +type PeriodSummaryRow = { + period: string | null; + transactionType: string; + totalAmount: string | number | null; +}; + +export type DashboardPeriodOverview = { + metrics: DashboardCardMetrics; + incomeExpenseBalanceData: IncomeExpenseBalanceData; +}; + +const createEmptyTotals = (): PeriodTotals => ({ + receitas: 0, + despesas: 0, + balanco: 0, +}); + +const ensurePeriodTotals = ( + store: Map, + period: string, +): PeriodTotals => { + const existing = store.get(period); + if (existing) { + return existing; + } + + const totals = createEmptyTotals(); + store.set(period, totals); + return totals; +}; + +const generateLast6Months = (currentPeriod: string): string[] => { + try { + return buildPeriodWindow(currentPeriod, 6); + } catch { + return buildPeriodWindow(getCurrentPeriod(), 6); + } +}; + +const emptyOverview = (period: string): DashboardPeriodOverview => { + const previousPeriod = getPreviousPeriod(period); + + return { + metrics: { + period, + previousPeriod, + receitas: { current: 0, previous: 0 }, + despesas: { current: 0, previous: 0 }, + balanco: { current: 0, previous: 0 }, + previsto: { current: 0, previous: 0 }, + }, + incomeExpenseBalanceData: { months: [] }, + }; +}; + +export async function fetchDashboardPeriodOverview( + userId: string, + period: string, +): Promise { + const adminPayerId = await getAdminPayerId(userId); + if (!adminPayerId) { + return emptyOverview(period); + } + + const previousPeriod = getPreviousPeriod(period); + const chartPeriods = generateLast6Months(period); + const startPeriod = addMonthsToPeriod(period, -24); + + const rows = (await db + .select({ + period: transactions.period, + transactionType: transactions.transactionType, + totalAmount: sum(transactions.amount).as("total"), + }) + .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, [ + TRANSACTION_TYPE_INCOME, + TRANSACTION_TYPE_EXPENSE, + ]), + ne(transactions.transactionType, TRANSACTION_TYPE_TRANSFER), + excludeAutoInvoiceEntries(), + excludeInitialBalanceWhenConfigured(), + ), + ) + .groupBy(transactions.period, transactions.transactionType) + .orderBy( + asc(transactions.period), + asc(transactions.transactionType), + )) as PeriodSummaryRow[]; + + const periodTotals = new Map(); + + for (const row of rows) { + if (!row.period) { + continue; + } + + const totals = ensurePeriodTotals(periodTotals, row.period); + const total = safeToNumber(row.totalAmount); + + if (row.transactionType === TRANSACTION_TYPE_INCOME) { + totals.receitas += total; + } else if (row.transactionType === TRANSACTION_TYPE_EXPENSE) { + totals.despesas += Math.abs(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(); + let runningForecast = 0; + + for (const key of periodRange) { + const totals = ensurePeriodTotals(periodTotals, key); + totals.balanco = totals.receitas - totals.despesas; + runningForecast += totals.balanco; + forecastByPeriod.set(key, runningForecast); + } + + const currentTotals = ensurePeriodTotals(periodTotals, period); + const previousTotals = ensurePeriodTotals(periodTotals, previousPeriod); + const months: MonthData[] = chartPeriods.map((chartPeriod) => { + const entry = periodTotals.get(chartPeriod) ?? createEmptyTotals(); + + return { + month: chartPeriod, + monthLabel: formatPeriodMonthShort(chartPeriod).toLowerCase(), + income: entry.receitas, + expense: entry.despesas, + balance: entry.receitas - entry.despesas, + }; + }); + + return { + metrics: { + 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, + }, + }, + incomeExpenseBalanceData: { months }, + }; +} diff --git a/src/features/notes/queries.ts b/src/features/notes/queries.ts index 46ad810..e402e5a 100644 --- a/src/features/notes/queries.ts +++ b/src/features/notes/queries.ts @@ -18,21 +18,26 @@ export type NoteData = { createdAt: string; }; -function toNoteData(note: Note): NoteData { - let tasks: Task[] | undefined; - if (note.tasks) { - try { - tasks = JSON.parse(note.tasks); - } catch (error) { - console.error("Failed to parse tasks for note", note.id, error); - } +function parseTasks(value: string | null): Task[] | undefined { + if (!value) { + return undefined; } + + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function toNoteData(note: Note): NoteData { return { id: note.id, title: (note.title ?? "").trim(), description: (note.description ?? "").trim(), type: (note.type ?? "nota") as "nota" | "tarefa", - tasks, + tasks: parseTasks(note.tasks), archived: note.archived, createdAt: note.createdAt.toISOString(), }; diff --git a/src/features/reports/cards-report-queries.ts b/src/features/reports/cards-report-queries.ts index a3b690d..db1a2af 100644 --- a/src/features/reports/cards-report-queries.ts +++ b/src/features/reports/cards-report-queries.ts @@ -8,12 +8,11 @@ import { ne, not, or, - sql, sum, } from "drizzle-orm"; -import { cards, categories, invoices, payers, transactions } from "@/db/schema"; +import { cards, categories, invoices, transactions } from "@/db/schema"; import { db } from "@/shared/lib/db"; -import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants"; +import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { formatDateOnly } from "@/shared/utils/date"; import { safeToNumber } from "@/shared/utils/number"; import { @@ -150,48 +149,51 @@ export async function fetchCartoesReportData( } const cardIds = allCards.map((c) => c.id); + const adminPayerId = await getAdminPayerId(userId); // Fetch current period usage by card (recorrente só conta quando a data da ocorrência já passou) - const currentUsageData = (await db - .select({ - cardId: transactions.cardId, - totalAmount: sum(transactions.amount).as("total"), - }) - .from(transactions) - .innerJoin(payers, eq(transactions.payerId, payers.id)) - .where( - and( - eq(transactions.userId, userId), - eq(transactions.period, currentPeriod), - eq(payers.role, PAYER_ROLE_ADMIN), - eq(transactions.transactionType, DESPESA), - inArray(transactions.cardId, cardIds), - or( - ne(transactions.condition, "Recorrente"), - sql`${transactions.purchaseDate} <= current_date`, - ), - ), - ) - .groupBy(transactions.cardId)) as CardUsageRow[]; + const currentUsageData = adminPayerId + ? ((await db + .select({ + cardId: transactions.cardId, + totalAmount: sum(transactions.amount).as("total"), + }) + .from(transactions) + .where( + and( + eq(transactions.userId, userId), + eq(transactions.period, currentPeriod), + eq(transactions.payerId, adminPayerId), + eq(transactions.transactionType, DESPESA), + inArray(transactions.cardId, cardIds), + or( + ne(transactions.condition, "Recorrente"), + lte(transactions.purchaseDate, new Date()), + ), + ), + ) + .groupBy(transactions.cardId)) as CardUsageRow[]) + : []; // Fetch previous period usage by card - const previousUsageData = (await db - .select({ - cardId: transactions.cardId, - totalAmount: sum(transactions.amount).as("total"), - }) - .from(transactions) - .innerJoin(payers, eq(transactions.payerId, payers.id)) - .where( - and( - eq(transactions.userId, userId), - eq(transactions.period, previousPeriod), - eq(payers.role, PAYER_ROLE_ADMIN), - eq(transactions.transactionType, DESPESA), - inArray(transactions.cardId, cardIds), - ), - ) - .groupBy(transactions.cardId)) as CardUsageRow[]; + const previousUsageData = adminPayerId + ? ((await db + .select({ + cardId: transactions.cardId, + totalAmount: sum(transactions.amount).as("total"), + }) + .from(transactions) + .where( + and( + eq(transactions.userId, userId), + eq(transactions.period, previousPeriod), + eq(transactions.payerId, adminPayerId), + eq(transactions.transactionType, DESPESA), + inArray(transactions.cardId, cardIds), + ), + ) + .groupBy(transactions.cardId)) as CardUsageRow[]) + : []; const currentUsageMap = new Map(); for (const row of currentUsageData) { @@ -262,6 +264,7 @@ export async function fetchCartoesReportData( targetCardId, cardSummary, currentPeriod, + adminPayerId, ); } } @@ -280,6 +283,7 @@ async function fetchCardDetail( cardId: string, cardSummary: CardSummary, currentPeriod: string, + adminPayerId: string | null, ): Promise { // Build period range for last 12 months const periods = buildPeriodWindow(currentPeriod, 12); @@ -287,25 +291,26 @@ async function fetchCardDetail( const startPeriod = periods[0]; // Fetch monthly usage - const monthlyData = (await db - .select({ - period: transactions.period, - totalAmount: sum(transactions.amount).as("total"), - }) - .from(transactions) - .innerJoin(payers, eq(transactions.payerId, payers.id)) - .where( - and( - eq(transactions.userId, userId), - eq(transactions.cardId, cardId), - gte(transactions.period, startPeriod), - lte(transactions.period, currentPeriod), - eq(payers.role, PAYER_ROLE_ADMIN), - eq(transactions.transactionType, DESPESA), - ), - ) - .groupBy(transactions.period) - .orderBy(transactions.period)) as MonthlyUsageRow[]; + const monthlyData = adminPayerId + ? ((await db + .select({ + period: transactions.period, + totalAmount: sum(transactions.amount).as("total"), + }) + .from(transactions) + .where( + and( + eq(transactions.userId, userId), + eq(transactions.cardId, cardId), + gte(transactions.period, startPeriod), + lte(transactions.period, currentPeriod), + eq(transactions.payerId, adminPayerId), + eq(transactions.transactionType, DESPESA), + ), + ) + .groupBy(transactions.period) + .orderBy(transactions.period)) as MonthlyUsageRow[]) + : []; const monthlyUsage = periods.map((period) => { const data = monthlyData.find((d) => d.period === period); @@ -317,23 +322,24 @@ async function fetchCardDetail( }); // Fetch category breakdown for current period - const categoryData = (await db - .select({ - categoryId: transactions.categoryId, - totalAmount: sum(transactions.amount).as("total"), - }) - .from(transactions) - .innerJoin(payers, eq(transactions.payerId, payers.id)) - .where( - and( - eq(transactions.userId, userId), - eq(transactions.cardId, cardId), - eq(transactions.period, currentPeriod), - eq(payers.role, PAYER_ROLE_ADMIN), - eq(transactions.transactionType, DESPESA), - ), - ) - .groupBy(transactions.categoryId)) as CategoryAmountRow[]; + const categoryData = adminPayerId + ? ((await db + .select({ + categoryId: transactions.categoryId, + totalAmount: sum(transactions.amount).as("total"), + }) + .from(transactions) + .where( + and( + eq(transactions.userId, userId), + eq(transactions.cardId, cardId), + eq(transactions.period, currentPeriod), + eq(transactions.payerId, adminPayerId), + eq(transactions.transactionType, DESPESA), + ), + ) + .groupBy(transactions.categoryId)) as CategoryAmountRow[]) + : []; // Fetch category names const categoryIds = categoryData @@ -378,27 +384,28 @@ async function fetchCardDetail( .slice(0, 10); // Fetch top expenses for current period - const topExpensesData = (await db - .select({ - id: transactions.id, - name: transactions.name, - amount: transactions.amount, - purchaseDate: transactions.purchaseDate, - categoryId: transactions.categoryId, - }) - .from(transactions) - .innerJoin(payers, eq(transactions.payerId, payers.id)) - .where( - and( - eq(transactions.userId, userId), - eq(transactions.cardId, cardId), - eq(transactions.period, currentPeriod), - eq(payers.role, PAYER_ROLE_ADMIN), - eq(transactions.transactionType, DESPESA), - ), - ) - .orderBy(transactions.amount) - .limit(10)) as TopExpenseRow[]; + const topExpensesData = adminPayerId + ? ((await db + .select({ + id: transactions.id, + name: transactions.name, + amount: transactions.amount, + purchaseDate: transactions.purchaseDate, + categoryId: transactions.categoryId, + }) + .from(transactions) + .where( + and( + eq(transactions.userId, userId), + eq(transactions.cardId, cardId), + eq(transactions.period, currentPeriod), + eq(transactions.payerId, adminPayerId), + eq(transactions.transactionType, DESPESA), + ), + ) + .orderBy(transactions.amount) + .limit(10)) as TopExpenseRow[]) + : []; const topExpenses = topExpensesData.map((expense) => { const catInfo = expense.categoryId diff --git a/src/features/reports/establishments/queries.ts b/src/features/reports/establishments/queries.ts index fa660dc..7b6ff32 100644 --- a/src/features/reports/establishments/queries.ts +++ b/src/features/reports/establishments/queries.ts @@ -5,6 +5,7 @@ import { eq, gte, ilike, + inArray, isNull, lte, ne, @@ -13,23 +14,17 @@ import { sql, sum, } from "drizzle-orm"; -import { - categories, - financialAccounts, - payers, - transactions, -} from "@/db/schema"; +import { categories, financialAccounts, 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 { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants"; +import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { safeToNumber } from "@/shared/utils/number"; import { getPreviousPeriod } from "@/shared/utils/period"; const DESPESA = "Despesa"; -const TRANSFERENCIA = "Transferência"; export type EstablishmentData = { name: string; @@ -81,6 +76,48 @@ export async function fetchTopEstablishmentsData( const months = parseInt(periodFilter, 10); const periods = buildPeriodRange(currentPeriod, months); const startPeriod = periods[0]; + const adminPayerId = await getAdminPayerId(userId); + const periodLabel = + months === 3 + ? "Últimos 3 meses" + : months === 6 + ? "Últimos 6 meses" + : "Últimos 12 meses"; + + if (!adminPayerId) { + return { + establishments: [], + topCategories: [], + summary: { + totalEstablishments: 0, + totalTransactions: 0, + totalSpent: 0, + avgPerTransaction: 0, + mostFrequent: null, + highestSpending: null, + }, + periodLabel, + }; + } + + const baseExpenseConditions = [ + eq(transactions.userId, userId), + gte(transactions.period, startPeriod), + lte(transactions.period, currentPeriod), + eq(transactions.payerId, adminPayerId), + eq(transactions.transactionType, DESPESA), + ] as const; + const exclusionConditions = [ + or( + isNull(transactions.note), + not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)), + ), + or( + ne(transactions.note, INITIAL_BALANCE_NOTE), + isNull(financialAccounts.excludeInitialBalanceFromIncome), + eq(financialAccounts.excludeInitialBalanceFromIncome, false), + ), + ] as const; // Fetch establishments with transaction count and total amount const establishmentsData = await db @@ -90,57 +127,41 @@ export async function fetchTopEstablishmentsData( totalAmount: sum(transactions.amount).as("total"), }) .from(transactions) - .innerJoin(payers, eq(transactions.payerId, payers.id)) .leftJoin( financialAccounts, eq(transactions.accountId, financialAccounts.id), ) - .where( - and( - eq(transactions.userId, userId), - gte(transactions.period, startPeriod), - lte(transactions.period, currentPeriod), - eq(payers.role, PAYER_ROLE_ADMIN), - eq(transactions.transactionType, DESPESA), - ne(transactions.transactionType, TRANSFERENCIA), - or( - isNull(transactions.note), - not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)), - ), - or( - ne(transactions.note, INITIAL_BALANCE_NOTE), - isNull(financialAccounts.excludeInitialBalanceFromIncome), - eq(financialAccounts.excludeInitialBalanceFromIncome, false), - ), - ), - ) + .where(and(...baseExpenseConditions, ...exclusionConditions)) .groupBy(transactions.name) .orderBy(desc(sql`count`)) .limit(50); - // Fetch categories for each establishment - const _establishmentNames = establishmentsData.map( - (e: (typeof establishmentsData)[0]) => e.name, - ); + const establishmentNames = establishmentsData + .map((est) => est.name) + .filter((name): name is string => !!name); - const categoriesByEstablishment = await db - .select({ - establishmentName: transactions.name, - categoryId: transactions.categoryId, - count: count().as("count"), - }) - .from(transactions) - .innerJoin(payers, eq(transactions.payerId, payers.id)) - .where( - and( - eq(transactions.userId, userId), - gte(transactions.period, startPeriod), - lte(transactions.period, currentPeriod), - eq(payers.role, PAYER_ROLE_ADMIN), - eq(transactions.transactionType, DESPESA), - ), - ) - .groupBy(transactions.name, transactions.categoryId); + const categoriesByEstablishment = + establishmentNames.length > 0 + ? await db + .select({ + establishmentName: transactions.name, + categoryId: transactions.categoryId, + count: count().as("count"), + }) + .from(transactions) + .leftJoin( + financialAccounts, + eq(transactions.accountId, financialAccounts.id), + ) + .where( + and( + ...baseExpenseConditions, + ...exclusionConditions, + inArray(transactions.name, establishmentNames), + ), + ) + .groupBy(transactions.name, transactions.categoryId) + : []; // Fetch all category names const allCategories = await db @@ -159,23 +180,33 @@ export async function fetchTopEstablishmentsData( // Build establishment data with categories type EstablishmentRow = (typeof establishmentsData)[0]; - type CategoryByEstRow = (typeof categoriesByEstablishment)[0]; + const categoriesByEstablishmentMap = new Map< + string, + Array<{ name: string; count: number }> + >(); + + for (const categoryRow of categoriesByEstablishment) { + if (!categoryRow.establishmentName || !categoryRow.categoryId) { + continue; + } + + const current = + categoriesByEstablishmentMap.get(categoryRow.establishmentName) ?? []; + current.push({ + name: + categoryMap.get(categoryRow.categoryId as string)?.name || + "Sem categoria", + count: Number(categoryRow.count) || 0, + }); + categoriesByEstablishmentMap.set(categoryRow.establishmentName, current); + } const establishments: EstablishmentData[] = establishmentsData.map( (est: EstablishmentRow) => { const cnt = Number(est.count) || 0; const total = Math.abs(safeToNumber(est.totalAmount)); - const estCategories = categoriesByEstablishment - .filter( - (c: CategoryByEstRow) => - c.establishmentName === est.name && c.categoryId, - ) - .map((c: CategoryByEstRow) => ({ - name: - categoryMap.get(c.categoryId as string)?.name || "Sem categoria", - count: Number(c.count) || 0, - })) + const estCategories = (categoriesByEstablishmentMap.get(est.name) ?? []) .sort( ( a: { name: string; count: number }, @@ -202,29 +233,11 @@ export async function fetchTopEstablishmentsData( count: count().as("count"), }) .from(transactions) - .innerJoin(payers, eq(transactions.payerId, payers.id)) .leftJoin( financialAccounts, eq(transactions.accountId, financialAccounts.id), ) - .where( - and( - eq(transactions.userId, userId), - gte(transactions.period, startPeriod), - lte(transactions.period, currentPeriod), - eq(payers.role, PAYER_ROLE_ADMIN), - eq(transactions.transactionType, DESPESA), - or( - isNull(transactions.note), - not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)), - ), - or( - ne(transactions.note, INITIAL_BALANCE_NOTE), - isNull(financialAccounts.excludeInitialBalanceFromIncome), - eq(financialAccounts.excludeInitialBalanceFromIncome, false), - ), - ), - ) + .where(and(...baseExpenseConditions, ...exclusionConditions)) .groupBy(transactions.categoryId) .orderBy(sql`total ASC`) .limit(10); @@ -257,13 +270,6 @@ export async function fetchTopEstablishmentsData( const highestSpending = sortedBySpending.length > 0 ? sortedBySpending[0].name : null; - const periodLabel = - months === 3 - ? "Últimos 3 meses" - : months === 6 - ? "Últimos 6 meses" - : "Últimos 12 meses"; - return { establishments, topCategories, diff --git a/src/shared/components/navigation/navbar/app-navbar.tsx b/src/shared/components/navigation/navbar/app-navbar.tsx index 46c3047..ecd801f 100644 --- a/src/shared/components/navigation/navbar/app-navbar.tsx +++ b/src/shared/components/navigation/navbar/app-navbar.tsx @@ -7,9 +7,6 @@ import { RefreshPageButton } from "@/shared/components/refresh-page-button"; import { NavMenu } from "./nav-menu"; import { NavbarUser } from "./navbar-user"; -const navbarActionClassName = - "border-black/10 bg-transparent text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20 data-[state=open]:bg-black/10 data-[state=open]:text-black"; - type AppNavbarProps = { user: { id: string; @@ -22,6 +19,9 @@ type AppNavbarProps = { notificationsSnapshot: DashboardNotificationsSnapshot; }; +const navbarActionClassName = + "border-black/10 bg-transparent text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20 data-[state=open]:bg-black/10 data-[state=open]:text-black"; + export function AppNavbar({ user, pagadorAvatarUrl, @@ -30,10 +30,6 @@ export function AppNavbar({ }: AppNavbarProps) { return (
    -
    -
    -
    -