diff --git a/app/(dashboard)/top-estabelecimentos/loading.tsx b/app/(dashboard)/top-estabelecimentos/loading.tsx new file mode 100644 index 0000000..498e8f9 --- /dev/null +++ b/app/(dashboard)/top-estabelecimentos/loading.tsx @@ -0,0 +1,58 @@ +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+
+ + +
+ +
+ +
+ {[1, 2, 3, 4].map((i) => ( + + + + + + ))} +
+ +
+ + +
+ +
+
+ + + + + + {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => ( + + ))} + + +
+
+ + + + + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + +
+
+
+ ); +} diff --git a/app/(dashboard)/top-estabelecimentos/page.tsx b/app/(dashboard)/top-estabelecimentos/page.tsx new file mode 100644 index 0000000..0aa2651 --- /dev/null +++ b/app/(dashboard)/top-estabelecimentos/page.tsx @@ -0,0 +1,80 @@ +import { EstablishmentsList } from "@/components/top-estabelecimentos/establishments-list"; +import { HighlightsCards } from "@/components/top-estabelecimentos/highlights-cards"; +import { PeriodFilterButtons } from "@/components/top-estabelecimentos/period-filter"; +import { SummaryCards } from "@/components/top-estabelecimentos/summary-cards"; +import { TopCategories } from "@/components/top-estabelecimentos/top-categories"; +import { getUser } from "@/lib/auth/server"; +import { + fetchTopEstabelecimentosData, + type PeriodFilter, +} from "@/lib/top-estabelecimentos/fetch-data"; +import { parsePeriodParam } from "@/lib/utils/period"; + +type PageSearchParams = Promise>; + +type PageProps = { + searchParams?: PageSearchParams; +}; + +const getSingleParam = ( + params: Record | undefined, + key: string, +) => { + const value = params?.[key]; + if (!value) return null; + return Array.isArray(value) ? (value[0] ?? null) : value; +}; + +const validatePeriodFilter = (value: string | null): PeriodFilter => { + if (value === "3" || value === "6" || value === "12") { + return value; + } + return "6"; +}; + +export default async function TopEstabelecimentosPage({ + searchParams, +}: PageProps) { + const user = await getUser(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); + const mesesParam = getSingleParam(resolvedSearchParams, "meses"); + + const { period: currentPeriod } = parsePeriodParam(periodoParam); + const periodFilter = validatePeriodFilter(mesesParam); + + const data = await fetchTopEstabelecimentosData( + user.id, + currentPeriod, + periodFilter, + ); + + return ( +
+
+
+

+ Top Estabelecimentos +

+

+ Análise dos locais onde você mais compra • {data.periodLabel} +

+
+ +
+ + + + + +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/components/top-estabelecimentos/establishments-list.tsx b/components/top-estabelecimentos/establishments-list.tsx new file mode 100644 index 0000000..a2fd7a9 --- /dev/null +++ b/components/top-estabelecimentos/establishments-list.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import MoneyValues from "@/components/money-values"; +import { WidgetEmptyState } from "@/components/widget-empty-state"; +import type { TopEstabelecimentosData } from "@/lib/top-estabelecimentos/fetch-data"; +import { title_font } from "@/public/fonts/font_index"; +import { RiStore2Line } from "@remixicon/react"; + +type EstablishmentsListProps = { + establishments: TopEstabelecimentosData["establishments"]; +}; + +const buildInitials = (value: string) => { + const parts = value.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "ES"; + if (parts.length === 1) { + const firstPart = parts[0]; + return firstPart ? firstPart.slice(0, 2).toUpperCase() : "ES"; + } + const firstChar = parts[0]?.[0] ?? ""; + const secondChar = parts[1]?.[0] ?? ""; + return `${firstChar}${secondChar}`.toUpperCase() || "ES"; +}; + +export function EstablishmentsList({ + establishments, +}: EstablishmentsListProps) { + if (establishments.length === 0) { + return ( + + + + + Top Estabelecimentos + + + + } + title="Nenhum estabelecimento encontrado" + description="Quando houver compras registradas, elas aparecerão aqui." + /> + + + ); + } + + const maxCount = Math.max(...establishments.map((e) => e.count)); + + return ( + + + + + Top Estabelecimentos por Frequência + + + +
+ {establishments.map((establishment, index) => { + const initials = buildInitials(establishment.name); + + return ( +
+
+
+ {/* Rank number - same size as icon containers */} +
+ + {index + 1} + +
+ + {/* Name and categories */} +
+ + {establishment.name} + +
+ {establishment.categories + .slice(0, 2) + .map((cat, catIndex) => ( + + {cat.name} + + ))} +
+
+
+ + {/* Value and stats */} +
+ + + {establishment.count}x • Média:{" "} + + +
+
+ + {/* Progress bar */} +
+
+
+
+
+
+ ); + })} +
+ + + ); +} diff --git a/components/top-estabelecimentos/highlights-cards.tsx b/components/top-estabelecimentos/highlights-cards.tsx new file mode 100644 index 0000000..9e2a871 --- /dev/null +++ b/components/top-estabelecimentos/highlights-cards.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Card, CardContent } from "@/components/ui/card"; +import type { TopEstabelecimentosData } from "@/lib/top-estabelecimentos/fetch-data"; +import { RiTrophyLine, RiFireLine } from "@remixicon/react"; + +type HighlightsCardsProps = { + summary: TopEstabelecimentosData["summary"]; +}; + +export function HighlightsCards({ summary }: HighlightsCardsProps) { + return ( +
+ + +
+
+ +
+
+

+ Mais Frequente +

+

+ {summary.mostFrequent || "—"} +

+
+
+
+
+ + + +
+
+ +
+
+

+ Maior Gasto Total +

+

+ {summary.highestSpending || "—"} +

+
+
+
+
+
+ ); +} diff --git a/components/top-estabelecimentos/period-filter.tsx b/components/top-estabelecimentos/period-filter.tsx new file mode 100644 index 0000000..4c8f93d --- /dev/null +++ b/components/top-estabelecimentos/period-filter.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import type { PeriodFilter } from "@/lib/top-estabelecimentos/fetch-data"; +import { useRouter, useSearchParams } from "next/navigation"; +import { cn } from "@/lib/utils"; + +type PeriodFilterProps = { + currentFilter: PeriodFilter; +}; + +const filterOptions: { value: PeriodFilter; label: string }[] = [ + { value: "3", label: "3 meses" }, + { value: "6", label: "6 meses" }, + { value: "12", label: "12 meses" }, +]; + +export function PeriodFilterButtons({ currentFilter }: PeriodFilterProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const handleFilterChange = (filter: PeriodFilter) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("meses", filter); + router.push(`/top-estabelecimentos?${params.toString()}`); + }; + + return ( +
+ Período: +
+ {filterOptions.map((option) => ( + + ))} +
+
+ ); +} diff --git a/components/top-estabelecimentos/summary-cards.tsx b/components/top-estabelecimentos/summary-cards.tsx new file mode 100644 index 0000000..444e5e7 --- /dev/null +++ b/components/top-estabelecimentos/summary-cards.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Card, CardContent } from "@/components/ui/card"; +import MoneyValues from "@/components/money-values"; +import type { TopEstabelecimentosData } from "@/lib/top-estabelecimentos/fetch-data"; +import { + RiStore2Line, + RiExchangeLine, + RiMoneyDollarCircleLine, + RiRepeatLine, +} from "@remixicon/react"; + +type SummaryCardsProps = { + summary: TopEstabelecimentosData["summary"]; +}; + +export function SummaryCards({ summary }: SummaryCardsProps) { + const cards = [ + { + title: "Estabelecimentos", + value: summary.totalEstablishments, + isMoney: false, + icon: RiStore2Line, + description: "Locais diferentes", + }, + { + title: "Transações", + value: summary.totalTransactions, + isMoney: false, + icon: RiExchangeLine, + description: "Compras no período", + }, + { + title: "Total Gasto", + value: summary.totalSpent, + isMoney: true, + icon: RiMoneyDollarCircleLine, + description: "Soma de todas as compras", + }, + { + title: "Ticket Médio", + value: summary.avgPerTransaction, + isMoney: true, + icon: RiRepeatLine, + description: "Média por transação", + }, + ]; + + return ( +
+ {cards.map((card) => ( + + +
+
+

+ {card.title} +

+ {card.isMoney ? ( + + ) : ( +

{card.value}

+ )} +

+ {card.description} +

+
+ +
+
+
+ ))} +
+ ); +} diff --git a/lib/top-estabelecimentos/fetch-data.ts b/lib/top-estabelecimentos/fetch-data.ts new file mode 100644 index 0000000..10a1379 --- /dev/null +++ b/lib/top-estabelecimentos/fetch-data.ts @@ -0,0 +1,269 @@ +import { lancamentos, pagadores, categorias, contas } from "@/db/schema"; +import { db } from "@/lib/db"; +import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { + ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, + INITIAL_BALANCE_NOTE, +} from "@/lib/accounts/constants"; +import { getPreviousPeriod } from "@/lib/utils/period"; +import { safeToNumber } from "@/lib/utils/number"; +import { + and, + eq, + sum, + gte, + lte, + count, + desc, + sql, + or, + isNull, + not, + ilike, + ne, +} from "drizzle-orm"; + +const DESPESA = "Despesa"; +const TRANSFERENCIA = "Transferência"; + +export type EstablishmentData = { + name: string; + count: number; + totalAmount: number; + avgAmount: number; + categories: { name: string; count: number }[]; +}; + +export type TopCategoryData = { + id: string; + name: string; + icon: string | null; + totalAmount: number; + transactionCount: number; +}; + +export type TopEstabelecimentosData = { + establishments: EstablishmentData[]; + topCategories: TopCategoryData[]; + summary: { + totalEstablishments: number; + totalTransactions: number; + totalSpent: number; + avgPerTransaction: number; + mostFrequent: string | null; + highestSpending: string | null; + }; + periodLabel: string; +}; + +export type PeriodFilter = "3" | "6" | "12"; + +function buildPeriodRange(currentPeriod: string, months: number): string[] { + const periods: string[] = []; + let p = currentPeriod; + for (let i = 0; i < months; i++) { + periods.unshift(p); + p = getPreviousPeriod(p); + } + return periods; +} + +export async function fetchTopEstabelecimentosData( + userId: string, + currentPeriod: string, + periodFilter: PeriodFilter = "6", +): Promise { + const months = parseInt(periodFilter, 10); + const periods = buildPeriodRange(currentPeriod, months); + const startPeriod = periods[0]; + + // Fetch establishments with transaction count and total amount + const establishmentsData = await db + .select({ + name: lancamentos.name, + count: count().as("count"), + totalAmount: sum(lancamentos.amount).as("total"), + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .leftJoin(contas, eq(lancamentos.contaId, contas.id)) + .where( + and( + eq(lancamentos.userId, userId), + gte(lancamentos.period, startPeriod), + lte(lancamentos.period, currentPeriod), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + eq(lancamentos.transactionType, DESPESA), + ne(lancamentos.transactionType, TRANSFERENCIA), + or( + isNull(lancamentos.note), + not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)), + ), + or( + ne(lancamentos.note, INITIAL_BALANCE_NOTE), + isNull(contas.excludeInitialBalanceFromIncome), + eq(contas.excludeInitialBalanceFromIncome, false), + ), + ), + ) + .groupBy(lancamentos.name) + .orderBy(desc(sql`count`)) + .limit(50); + + // Fetch categories for each establishment + const establishmentNames = establishmentsData.map( + (e: (typeof establishmentsData)[0]) => e.name, + ); + + const categoriesByEstablishment = await db + .select({ + establishmentName: lancamentos.name, + categoriaId: lancamentos.categoriaId, + count: count().as("count"), + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + gte(lancamentos.period, startPeriod), + lte(lancamentos.period, currentPeriod), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + eq(lancamentos.transactionType, DESPESA), + ), + ) + .groupBy(lancamentos.name, lancamentos.categoriaId); + + // Fetch all category names + const allCategories = await db + .select({ + id: categorias.id, + name: categorias.name, + icon: categorias.icon, + }) + .from(categorias) + .where(eq(categorias.userId, userId)); + + type CategoryInfo = { id: string; name: string; icon: string | null }; + const categoryMap = new Map( + allCategories.map((c): [string, CategoryInfo] => [c.id, c as CategoryInfo]), + ); + + // Build establishment data with categories + type EstablishmentRow = (typeof establishmentsData)[0]; + type CategoryByEstRow = (typeof categoriesByEstablishment)[0]; + + 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.categoriaId, + ) + .map((c: CategoryByEstRow) => ({ + name: categoryMap.get(c.categoriaId!)?.name || "Sem categoria", + count: Number(c.count) || 0, + })) + .sort( + ( + a: { name: string; count: number }, + b: { name: string; count: number }, + ) => b.count - a.count, + ) + .slice(0, 3); + + return { + name: est.name, + count: cnt, + totalAmount: total, + avgAmount: cnt > 0 ? total / cnt : 0, + categories: estCategories, + }; + }, + ); + + // Fetch top categories by spending + const topCategoriesData = await db + .select({ + categoriaId: lancamentos.categoriaId, + totalAmount: sum(lancamentos.amount).as("total"), + count: count().as("count"), + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .leftJoin(contas, eq(lancamentos.contaId, contas.id)) + .where( + and( + eq(lancamentos.userId, userId), + gte(lancamentos.period, startPeriod), + lte(lancamentos.period, currentPeriod), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + eq(lancamentos.transactionType, DESPESA), + or( + isNull(lancamentos.note), + not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)), + ), + or( + ne(lancamentos.note, INITIAL_BALANCE_NOTE), + isNull(contas.excludeInitialBalanceFromIncome), + eq(contas.excludeInitialBalanceFromIncome, false), + ), + ), + ) + .groupBy(lancamentos.categoriaId) + .orderBy(sql`total ASC`) + .limit(10); + + type TopCategoryRow = (typeof topCategoriesData)[0]; + + const topCategories: TopCategoryData[] = topCategoriesData + .filter((c: TopCategoryRow) => c.categoriaId) + .map((cat: TopCategoryRow) => { + const catInfo = categoryMap.get(cat.categoriaId!); + return { + id: cat.categoriaId!, + name: catInfo?.name || "Sem categoria", + icon: catInfo?.icon || null, + totalAmount: Math.abs(safeToNumber(cat.totalAmount)), + transactionCount: Number(cat.count) || 0, + }; + }); + + // Calculate summary + const totalTransactions = establishments.reduce((acc, e) => acc + e.count, 0); + const totalSpent = establishments.reduce((acc, e) => acc + e.totalAmount, 0); + + const mostFrequent = + establishments.length > 0 ? establishments[0].name : null; + + const sortedBySpending = [...establishments].sort( + (a, b) => b.totalAmount - a.totalAmount, + ); + 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, + summary: { + totalEstablishments: establishments.length, + totalTransactions, + totalSpent, + avgPerTransaction: + totalTransactions > 0 ? totalSpent / totalTransactions : 0, + mostFrequent, + highestSpending, + }, + periodLabel, + }; +}