diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 0b049e2..fd5da45 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -4,7 +4,13 @@ import { SectionCards } from "@/components/dashboard/section-cards"; import MonthNavigation from "@/components/month-picker/month-navigation"; import { getUser } from "@/lib/auth/server"; import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data"; +import { + buildOptionSets, + buildSluggedFilters, + fetchLancamentoFilterSources, +} from "@/lib/lancamentos/page-helpers"; import { parsePeriodParam } from "@/lib/utils/period"; +import { getRecentEstablishmentsAction } from "../lancamentos/actions"; import { fetchUserDashboardPreferences } from "./data"; type PageSearchParams = Promise>; @@ -28,12 +34,26 @@ export default async function Page({ searchParams }: PageProps) { const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const { period: selectedPeriod } = parsePeriodParam(periodoParam); - const [data, preferences] = await Promise.all([ - fetchDashboardData(user.id, selectedPeriod), - fetchUserDashboardPreferences(user.id), - ]); - + const [dashboardData, preferences, filterSources, estabelecimentos] = + await Promise.all([ + fetchDashboardData(user.id, selectedPeriod), + fetchUserDashboardPreferences(user.id), + fetchLancamentoFilterSources(user.id), + getRecentEstablishmentsAction(), + ]); const { disableMagnetlines, dashboardWidgets } = preferences; + const sluggedFilters = buildSluggedFilters(filterSources); + const { + pagadorOptions, + splitPagadorOptions, + defaultPagadorId, + contaOptions, + cartaoOptions, + categoriaOptions, + } = buildOptionSets({ + ...sluggedFilters, + pagadorRows: filterSources.pagadorRows, + }); return (
@@ -42,11 +62,20 @@ export default async function Page({ searchParams }: PageProps) { disableMagnetlines={disableMagnetlines} /> - +
); diff --git a/app/(dashboard)/relatorios/estabelecimentos/layout.tsx b/app/(dashboard)/relatorios/estabelecimentos/layout.tsx new file mode 100644 index 0000000..c20ca10 --- /dev/null +++ b/app/(dashboard)/relatorios/estabelecimentos/layout.tsx @@ -0,0 +1,23 @@ +import { RiStore2Line } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; + +export const metadata = { + title: "Top Estabelecimentos | OpenMonetis", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ } + title="Top Estabelecimentos" + subtitle="Análise dos locais onde você mais compra e gasta" + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/relatorios/estabelecimentos/loading.tsx b/app/(dashboard)/relatorios/estabelecimentos/loading.tsx new file mode 100644 index 0000000..ac15aa9 --- /dev/null +++ b/app/(dashboard)/relatorios/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)/relatorios/estabelecimentos/page.tsx b/app/(dashboard)/relatorios/estabelecimentos/page.tsx new file mode 100644 index 0000000..4c0d359 --- /dev/null +++ b/app/(dashboard)/relatorios/estabelecimentos/page.tsx @@ -0,0 +1,76 @@ +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 { Card } from "@/components/ui/card"; +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 ( +
+ + + Selecione o intervalo de meses + + + + + + + + +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/components/dashboard/dashboard-grid-editable.tsx b/components/dashboard/dashboard-grid-editable.tsx index bff17e8..d2cb153 100644 --- a/components/dashboard/dashboard-grid-editable.tsx +++ b/components/dashboard/dashboard-grid-editable.tsx @@ -1,7 +1,7 @@ "use client"; import { - closestCenter, + closestCorners, DndContext, type DragEndEvent, KeyboardSensor, @@ -16,15 +16,21 @@ import { sortableKeyboardCoordinates, } from "@dnd-kit/sortable"; import { + RiArrowDownLine, + RiArrowUpLine, RiCheckLine, RiCloseLine, RiDragMove2Line, RiEyeOffLine, + RiTodoLine, } from "@remixicon/react"; import { useCallback, useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; +import { NoteDialog } from "@/components/anotacoes/note-dialog"; import { SortableWidget } from "@/components/dashboard/sortable-widget"; import { WidgetSettingsDialog } from "@/components/dashboard/widget-settings-dialog"; +import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog"; +import type { SelectOption } from "@/components/lancamentos/types"; import { Button } from "@/components/ui/button"; import WidgetCard from "@/components/widget-card"; import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data"; @@ -42,12 +48,22 @@ type DashboardGridEditableProps = { data: DashboardData; period: string; initialPreferences: WidgetPreferences | null; + quickActionOptions: { + pagadorOptions: SelectOption[]; + splitPagadorOptions: SelectOption[]; + defaultPagadorId: string | null; + contaOptions: SelectOption[]; + cartaoOptions: SelectOption[]; + categoriaOptions: SelectOption[]; + estabelecimentos: string[]; + }; }; export function DashboardGridEditable({ data, period, initialPreferences, + quickActionOptions, }: DashboardGridEditableProps) { const [isEditing, setIsEditing] = useState(false); const [isPending, startTransition] = useTransition(); @@ -183,53 +199,112 @@ export function DashboardGridEditable({ return (
{/* Toolbar */} -
- {isEditing ? ( - <> - - - +
+ {!isEditing ? ( +
+ + Ação rápida + +
+ + + Nova receita + + } + /> + + + Nova despesa + + } + /> + + + Nova anotação + + } + /> +
+
) : ( - <> - - - +
)} + +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
{/* Grid */} + Math.min(max, Math.max(min, value)); + +const formatPercentage = (value: number, withSign = false) => + `${new Intl.NumberFormat("pt-BR", { + minimumFractionDigits: 0, + maximumFractionDigits: 1, + ...(withSign ? { signDisplay: "always" as const } : {}), + }).format(value)}%`; + +export function GoalsProgressWidget({ data }: GoalsProgressWidgetProps) { + const [editOpen, setEditOpen] = useState(false); + const [selectedBudget, setSelectedBudget] = useState(null); + + const categories = useMemo( + () => + data.categories.map((category) => ({ + id: category.id, + name: category.name, + icon: category.icon, + })), + [data.categories], + ); + + const defaultPeriod = data.items[0]?.period ?? ""; + + const handleEdit = useCallback((item: GoalsProgressData["items"][number]) => { + setSelectedBudget({ + id: item.id, + amount: item.budgetAmount, + spent: item.spentAmount, + period: item.period, + createdAt: item.createdAt, + category: item.categoryId + ? { + id: item.categoryId, + name: item.categoryName, + icon: item.categoryIcon, + } + : null, + }); + setEditOpen(true); + }, []); + + const handleEditOpenChange = useCallback((open: boolean) => { + setEditOpen(open); + if (!open) { + setSelectedBudget(null); + } + }, []); + + if (data.items.length === 0) { + return ( + } + title="Nenhum orçamento para o período" + description="Cadastre orçamentos para acompanhar o progresso das metas." + /> + ); + } + + return ( +
+
    + {data.items.map((item, index) => { + const statusColor = + item.status === "exceeded" ? "text-destructive" : ""; + const progressValue = clamp(item.usedPercentage, 0, 100); + const percentageDelta = item.usedPercentage - 100; + + return ( +
  • +
    +
    + +
    +

    + {item.categoryName} +

    +

    + de{" "} + +

    +
    +
    + +
    + + {formatPercentage(percentageDelta, true)} + + +
    +
    +
    + +
    +
  • + ); + })} +
+ + +
+ ); +} diff --git a/components/dashboard/notes-widget.tsx b/components/dashboard/notes-widget.tsx new file mode 100644 index 0000000..8e70ad2 --- /dev/null +++ b/components/dashboard/notes-widget.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { RiEyeLine, RiPencilLine, RiTodoLine } from "@remixicon/react"; +import { useCallback, useMemo, useState } from "react"; +import { NoteDetailsDialog } from "@/components/anotacoes/note-details-dialog"; +import { NoteDialog } from "@/components/anotacoes/note-dialog"; +import type { Note } from "@/components/anotacoes/types"; +import { Button } from "@/components/ui/button"; +import { CardContent } from "@/components/ui/card"; +import type { DashboardNote } from "@/lib/dashboard/notes"; +import { Badge } from "../ui/badge"; +import { WidgetEmptyState } from "../widget-empty-state"; + +type NotesWidgetProps = { + notes: DashboardNote[]; +}; + +const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", { + day: "2-digit", + month: "short", + year: "numeric", + timeZone: "UTC", +}); + +const buildDisplayTitle = (value: string) => { + const trimmed = value.trim(); + return trimmed.length ? trimmed : "Anotação sem título"; +}; + +const mapDashboardNoteToNote = (note: DashboardNote): Note => ({ + id: note.id, + title: note.title, + description: note.description, + type: note.type, + tasks: note.tasks, + arquivada: note.arquivada, + createdAt: note.createdAt, +}); + +const getTasksSummary = (note: DashboardNote) => { + if (note.type !== "tarefa") { + return "Nota"; + } + + const tasks = note.tasks ?? []; + const completed = tasks.filter((task) => task.completed).length; + return `${completed}/${tasks.length} concluídas`; +}; + +export function NotesWidget({ notes }: NotesWidgetProps) { + const [noteToEdit, setNoteToEdit] = useState(null); + const [isEditOpen, setIsEditOpen] = useState(false); + const [noteDetails, setNoteDetails] = useState(null); + const [isDetailsOpen, setIsDetailsOpen] = useState(false); + + const mappedNotes = useMemo(() => notes.map(mapDashboardNoteToNote), [notes]); + + const handleOpenEdit = useCallback((note: Note) => { + setNoteToEdit(note); + setIsEditOpen(true); + }, []); + + const handleOpenDetails = useCallback((note: Note) => { + setNoteDetails(note); + setIsDetailsOpen(true); + }, []); + + const handleEditOpenChange = useCallback((open: boolean) => { + setIsEditOpen(open); + if (!open) { + setNoteToEdit(null); + } + }, []); + + const handleDetailsOpenChange = useCallback((open: boolean) => { + setIsDetailsOpen(open); + if (!open) { + setNoteDetails(null); + } + }, []); + + return ( + <> + + {mappedNotes.length === 0 ? ( + } + title="Nenhuma anotação ativa" + description="Crie anotações para acompanhar lembretes e tarefas financeiras." + /> + ) : ( +
    + {mappedNotes.map((note) => ( +
  • +
    +

    + {buildDisplayTitle(note.title)} +

    +
    + + {getTasksSummary(note)} + +

    + {DATE_FORMATTER.format(new Date(note.createdAt))} +

    +
    +
    + +
    + + +
    +
  • + ))} +
+ )} +
+ + + + + + ); +} diff --git a/components/dashboard/pagadores-widget.tsx b/components/dashboard/pagadores-widget.tsx index 19c87cb..afe22fa 100644 --- a/components/dashboard/pagadores-widget.tsx +++ b/components/dashboard/pagadores-widget.tsx @@ -1,6 +1,8 @@ "use client"; import { + RiArrowDownSFill, + RiArrowUpSFill, RiExternalLinkLine, RiGroupLine, RiVerifiedBadgeFill, @@ -17,6 +19,10 @@ type PagadoresWidgetProps = { pagadores: DashboardPagador[]; }; +const formatPercentage = (value: number) => { + return `${Math.abs(value).toFixed(0)}%`; +}; + const buildInitials = (value: string) => { const parts = value.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) { @@ -44,6 +50,12 @@ export function PagadoresWidget({ pagadores }: PagadoresWidgetProps) {
    {pagadores.map((pagador) => { const initials = buildInitials(pagador.name); + const hasValidPercentageChange = + typeof pagador.percentageChange === "number" && + Number.isFinite(pagador.percentageChange); + const percentageChange = hasValidPercentageChange + ? pagador.percentageChange + : null; return (
  • + {percentageChange !== null && ( + 0 + ? "text-destructive" + : percentageChange < 0 + ? "text-success" + : "text-muted-foreground" + }`} + > + {percentageChange > 0 && ( + + )} + {percentageChange < 0 && ( + + )} + {formatPercentage(percentageChange)} + + )}
); diff --git a/components/dashboard/payment-overview-widget.tsx b/components/dashboard/payment-overview-widget.tsx new file mode 100644 index 0000000..e9bd20c --- /dev/null +++ b/components/dashboard/payment-overview-widget.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react"; +import { useState } from "react"; +import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions"; +import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; +import { PaymentConditionsWidget } from "./payment-conditions-widget"; +import { PaymentMethodsWidget } from "./payment-methods-widget"; + +type PaymentOverviewWidgetProps = { + paymentConditionsData: PaymentConditionsData; + paymentMethodsData: PaymentMethodsData; +}; + +export function PaymentOverviewWidget({ + paymentConditionsData, + paymentMethodsData, +}: PaymentOverviewWidgetProps) { + const [activeTab, setActiveTab] = useState<"conditions" | "methods">( + "conditions", + ); + + return ( + setActiveTab(value as "conditions" | "methods")} + className="w-full" + > + + + + Condições + + + + Formas + + + + + + + + + + + + ); +} diff --git a/components/dashboard/sortable-widget.tsx b/components/dashboard/sortable-widget.tsx index a158ee8..93b783e 100644 --- a/components/dashboard/sortable-widget.tsx +++ b/components/dashboard/sortable-widget.tsx @@ -37,7 +37,8 @@ export function SortableWidget({ className={cn( "relative", isDragging && "z-50 opacity-90", - isEditing && "cursor-grab active:cursor-grabbing", + isEditing && + "cursor-grab active:cursor-grabbing touch-none select-none", )} {...(isEditing ? { ...attributes, ...listeners } : {})} > diff --git a/components/dashboard/spending-overview-widget.tsx b/components/dashboard/spending-overview-widget.tsx new file mode 100644 index 0000000..72af23f --- /dev/null +++ b/components/dashboard/spending-overview-widget.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { RiArrowUpDoubleLine, RiStore2Line } from "@remixicon/react"; +import { useState } from "react"; +import type { TopExpensesData } from "@/lib/dashboard/expenses/top-expenses"; +import type { TopEstablishmentsData } from "@/lib/dashboard/top-establishments"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; +import { TopEstablishmentsWidget } from "./top-establishments-widget"; +import { TopExpensesWidget } from "./top-expenses-widget"; + +type SpendingOverviewWidgetProps = { + topExpensesAll: TopExpensesData; + topExpensesCardOnly: TopExpensesData; + topEstablishmentsData: TopEstablishmentsData; +}; + +export function SpendingOverviewWidget({ + topExpensesAll, + topExpensesCardOnly, + topEstablishmentsData, +}: SpendingOverviewWidgetProps) { + const [activeTab, setActiveTab] = useState<"expenses" | "establishments">( + "expenses", + ); + + return ( + + setActiveTab(value as "expenses" | "establishments") + } + className="w-full" + > + + + + Top gastos + + + + Estabelecimentos + + + + + + + + + + + + ); +} diff --git a/components/navbar/nav-items.tsx b/components/navbar/nav-items.tsx index b937bb3..7623184 100644 --- a/components/navbar/nav-items.tsx +++ b/components/navbar/nav-items.tsx @@ -9,6 +9,7 @@ import { RiGroupLine, RiPriceTag3Line, RiSparklingLine, + RiStore2Line, RiTodoLine, } from "@remixicon/react"; @@ -110,6 +111,11 @@ export const NAV_SECTIONS: NavSection[] = [ icon: , preservePeriod: true, }, + { + href: "/relatorios/estabelecimentos", + label: "estabelecimentos", + icon: , + }, ], }, ]; diff --git a/components/orcamentos/budget-dialog.tsx b/components/orcamentos/budget-dialog.tsx index b614680..b29387e 100644 --- a/components/orcamentos/budget-dialog.tsx +++ b/components/orcamentos/budget-dialog.tsx @@ -9,7 +9,6 @@ import { import { CategoryIcon } from "@/components/categorias/category-icon"; import { PeriodPicker } from "@/components/period-picker"; import { Button } from "@/components/ui/button"; -import { CurrencyInput } from "@/components/ui/currency-input"; import { Dialog, DialogContent, @@ -27,6 +26,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; import { useControlledState } from "@/hooks/use-controlled-state"; import { useFormState } from "@/hooks/use-form-state"; @@ -54,6 +54,12 @@ const buildInitialValues = ({ amount: budget ? (Math.round(budget.amount * 100) / 100).toFixed(2) : "", }); +const formatCurrency = (value: number) => + new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + }).format(value); + export function BudgetDialog({ mode, trigger, @@ -164,6 +170,15 @@ export function BudgetDialog({ const submitLabel = mode === "create" ? "Salvar orçamento" : "Atualizar orçamento"; const disabled = categories.length === 0; + const parsedAmount = Number.parseFloat(formState.amount); + const sliderValue = Number.isFinite(parsedAmount) + ? Math.max(0, parsedAmount) + : 0; + const baseForSlider = Math.max(budget?.spent ?? 0, sliderValue, 1000); + const sliderMax = Math.max( + 1000, + Math.ceil((baseForSlider * 1.5) / 100) * 100, + ); return ( @@ -215,7 +230,7 @@ export function BudgetDialog({
-
+
- updateField("amount", value)} - /> +
+
+ Limite atual + + {formatCurrency(sliderValue)} + +
+ + + updateField("amount", value[0]?.toFixed(2) ?? "0.00") + } + /> + +
+ {formatCurrency(0)} + {formatCurrency(sliderMax)} +
+
diff --git a/components/top-estabelecimentos/period-filter.tsx b/components/top-estabelecimentos/period-filter.tsx index 2bc9495..d1bf19a 100644 --- a/components/top-estabelecimentos/period-filter.tsx +++ b/components/top-estabelecimentos/period-filter.tsx @@ -22,7 +22,7 @@ export function PeriodFilterButtons({ currentFilter }: PeriodFilterProps) { const handleFilterChange = (filter: PeriodFilter) => { const params = new URLSearchParams(searchParams.toString()); params.set("meses", filter); - router.push(`/top-estabelecimentos?${params.toString()}`); + router.push(`/relatorios/estabelecimentos?${params.toString()}`); }; return ( diff --git a/components/ui/slider.tsx b/components/ui/slider.tsx new file mode 100644 index 0000000..250cc19 --- /dev/null +++ b/components/ui/slider.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Slider as SliderPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/utils/ui"; + +function Slider({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + + ); +} + +export { Slider }; diff --git a/lib/actions/helpers.ts b/lib/actions/helpers.ts index 196bb62..ba75655 100644 --- a/lib/actions/helpers.ts +++ b/lib/actions/helpers.ts @@ -27,7 +27,7 @@ export const revalidateConfig = { estabelecimentos: ["/estabelecimentos", "/lancamentos"], orcamentos: ["/orcamentos"], pagadores: ["/pagadores"], - anotacoes: ["/anotacoes", "/anotacoes/arquivadas"], + anotacoes: ["/anotacoes", "/anotacoes/arquivadas", "/dashboard"], lancamentos: ["/lancamentos", "/contas"], inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"], } as const; @@ -39,6 +39,7 @@ const DASHBOARD_ENTITIES: ReadonlySet = new Set([ "cartoes", "orcamentos", "pagadores", + "anotacoes", "inbox", ]); diff --git a/lib/dashboard/goals-progress.ts b/lib/dashboard/goals-progress.ts new file mode 100644 index 0000000..3142a57 --- /dev/null +++ b/lib/dashboard/goals-progress.ts @@ -0,0 +1,147 @@ +import { and, eq, ne, sql } from "drizzle-orm"; +import { categorias, lancamentos, orcamentos } from "@/db/schema"; +import { toNumber } from "@/lib/dashboard/common"; +import { db } from "@/lib/db"; +import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; + +const BUDGET_CRITICAL_THRESHOLD = 80; + +export type GoalProgressStatus = "on-track" | "critical" | "exceeded"; + +export type GoalProgressItem = { + id: string; + categoryId: string | null; + categoryName: string; + categoryIcon: string | null; + period: string; + createdAt: string; + budgetAmount: number; + spentAmount: number; + usedPercentage: number; + status: GoalProgressStatus; +}; + +export type GoalProgressCategory = { + id: string; + name: string; + icon: string | null; +}; + +export type GoalsProgressData = { + items: GoalProgressItem[]; + categories: GoalProgressCategory[]; + totalBudgets: number; + exceededCount: number; + criticalCount: number; +}; + +const resolveStatus = (usedPercentage: number): GoalProgressStatus => { + if (usedPercentage >= 100) { + return "exceeded"; + } + if (usedPercentage >= BUDGET_CRITICAL_THRESHOLD) { + return "critical"; + } + return "on-track"; +}; + +export async function fetchGoalsProgressData( + userId: string, + period: string, +): Promise { + const adminPagadorId = await getAdminPagadorId(userId); + + if (!adminPagadorId) { + return { + items: [], + categories: [], + totalBudgets: 0, + exceededCount: 0, + criticalCount: 0, + }; + } + + const [rows, categoryRows] = await Promise.all([ + db + .select({ + orcamentoId: orcamentos.id, + categoryId: categorias.id, + categoryName: categorias.name, + categoryIcon: categorias.icon, + period: orcamentos.period, + createdAt: orcamentos.createdAt, + budgetAmount: orcamentos.amount, + spentAmount: sql`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`, + }) + .from(orcamentos) + .innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id)) + .leftJoin( + lancamentos, + and( + eq(lancamentos.categoriaId, orcamentos.categoriaId), + eq(lancamentos.userId, orcamentos.userId), + eq(lancamentos.period, orcamentos.period), + eq(lancamentos.pagadorId, adminPagadorId), + eq(lancamentos.transactionType, "Despesa"), + ne(lancamentos.condition, "cancelado"), + ), + ) + .where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))) + .groupBy( + orcamentos.id, + categorias.id, + categorias.name, + categorias.icon, + orcamentos.period, + orcamentos.createdAt, + orcamentos.amount, + ), + db.query.categorias.findMany({ + where: and(eq(categorias.userId, userId), eq(categorias.type, "despesa")), + orderBy: (category, { asc }) => [asc(category.name)], + }), + ]); + + const categories: GoalProgressCategory[] = categoryRows.map((category) => ({ + id: category.id, + name: category.name, + icon: category.icon, + })); + + const items: GoalProgressItem[] = rows + .map((row) => { + const budgetAmount = toNumber(row.budgetAmount); + const spentAmount = toNumber(row.spentAmount); + const usedPercentage = + budgetAmount > 0 ? (spentAmount / budgetAmount) * 100 : 0; + + return { + id: row.orcamentoId, + 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 = items.filter( + (item) => item.status === "exceeded", + ).length; + const criticalCount = items.filter( + (item) => item.status === "critical", + ).length; + + return { + items, + categories, + totalBudgets: items.length, + exceededCount, + criticalCount, + }; +} diff --git a/lib/dashboard/notes.ts b/lib/dashboard/notes.ts new file mode 100644 index 0000000..fae68c2 --- /dev/null +++ b/lib/dashboard/notes.ts @@ -0,0 +1,73 @@ +import { and, eq } from "drizzle-orm"; +import { anotacoes } from "@/db/schema"; +import { db } from "@/lib/db"; + +export type DashboardTask = { + id: string; + text: string; + completed: boolean; +}; + +export type DashboardNote = { + id: string; + title: string; + description: string; + type: "nota" | "tarefa"; + tasks?: DashboardTask[]; + arquivada: boolean; + createdAt: string; +}; + +const 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 undefined; + } +}; + +export async function fetchDashboardNotes( + userId: string, +): Promise { + const notes = await db.query.anotacoes.findMany({ + where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)), + orderBy: (note, { desc }) => [desc(note.createdAt)], + limit: 5, + }); + + return notes.map((note) => ({ + id: note.id, + title: (note.title ?? "").trim(), + description: (note.description ?? "").trim(), + type: (note.type ?? "nota") as "nota" | "tarefa", + tasks: parseTasks(note.tasks), + arquivada: note.arquivada, + createdAt: note.createdAt.toISOString(), + })); +} diff --git a/lib/dashboard/pagadores.ts b/lib/dashboard/pagadores.ts index 9b648b8..2c7e19c 100644 --- a/lib/dashboard/pagadores.ts +++ b/lib/dashboard/pagadores.ts @@ -1,9 +1,11 @@ -import { and, desc, eq, isNull, or, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { lancamentos, pagadores } from "@/db/schema"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants"; import { toNumber } from "@/lib/dashboard/common"; import { db } from "@/lib/db"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { calculatePercentageChange } from "@/lib/utils/math"; +import { getPreviousPeriod } from "@/lib/utils/period"; export type DashboardPagador = { id: string; @@ -11,6 +13,8 @@ export type DashboardPagador = { email: string | null; avatarUrl: string | null; totalExpenses: number; + previousExpenses: number; + percentageChange: number | null; isAdmin: boolean; }; @@ -23,6 +27,8 @@ export async function fetchDashboardPagadores( userId: string, period: string, ): Promise { + const previousPeriod = getPreviousPeriod(period); + const rows = await db .select({ id: pagadores.id, @@ -30,6 +36,7 @@ export async function fetchDashboardPagadores( email: pagadores.email, avatarUrl: pagadores.avatarUrl, role: pagadores.role, + period: lancamentos.period, totalExpenses: sql`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`, }) .from(lancamentos) @@ -37,7 +44,7 @@ export async function fetchDashboardPagadores( .where( and( eq(lancamentos.userId, userId), - eq(lancamentos.period, period), + inArray(lancamentos.period, [period, previousPeriod]), eq(lancamentos.transactionType, "Despesa"), or( isNull(lancamentos.note), @@ -51,19 +58,60 @@ export async function fetchDashboardPagadores( pagadores.email, pagadores.avatarUrl, pagadores.role, + lancamentos.period, ) .orderBy(desc(sql`SUM(ABS(${lancamentos.amount}))`)); - const pagadoresList = rows - .map((row) => ({ + const groupedPagadores = new Map< + string, + { + id: string; + name: string; + email: string | null; + avatarUrl: string | null; + isAdmin: boolean; + currentExpenses: number; + previousExpenses: number; + } + >(); + + for (const row of rows) { + const entry = groupedPagadores.get(row.id) ?? { id: row.id, name: row.name, email: row.email, avatarUrl: row.avatarUrl, - totalExpenses: toNumber(row.totalExpenses), isAdmin: row.role === PAGADOR_ROLE_ADMIN, + currentExpenses: 0, + previousExpenses: 0, + }; + + const amount = toNumber(row.totalExpenses); + if (row.period === period) { + entry.currentExpenses = amount; + } else { + entry.previousExpenses = amount; + } + + groupedPagadores.set(row.id, entry); + } + + const pagadoresList = Array.from(groupedPagadores.values()) + .filter((p) => p.currentExpenses > 0) + .map((pagador) => ({ + id: pagador.id, + name: pagador.name, + email: pagador.email, + avatarUrl: pagador.avatarUrl, + totalExpenses: pagador.currentExpenses, + previousExpenses: pagador.previousExpenses, + percentageChange: calculatePercentageChange( + pagador.currentExpenses, + pagador.previousExpenses, + ), + isAdmin: pagador.isAdmin, })) - .filter((p) => p.totalExpenses > 0); + .sort((a, b) => b.totalExpenses - a.totalExpenses); const totalExpenses = pagadoresList.reduce( (sum, p) => sum + p.totalExpenses, diff --git a/lib/dashboard/top-establishments.ts b/lib/dashboard/top-establishments.ts index 69fd1ac..d7057c7 100644 --- a/lib/dashboard/top-establishments.ts +++ b/lib/dashboard/top-establishments.ts @@ -69,7 +69,10 @@ export async function fetchTopEstablishments( ), ) .groupBy(lancamentos.name) - .orderBy(sql`ABS(sum(${lancamentos.amount})) DESC`) + .orderBy( + sql`count(${lancamentos.id}) DESC`, + sql`ABS(sum(${lancamentos.amount})) DESC`, + ) .limit(10); const establishments = rows diff --git a/lib/dashboard/widgets/widgets-config.tsx b/lib/dashboard/widgets/widgets-config.tsx index 57eab3e..b1461f9 100644 --- a/lib/dashboard/widgets/widgets-config.tsx +++ b/lib/dashboard/widgets/widgets-config.tsx @@ -7,33 +7,30 @@ import { RiExchangeLine, RiGroupLine, RiLineChartLine, - RiMoneyDollarCircleLine, RiNumbersLine, RiPieChartLine, RiRefreshLine, - RiSlideshowLine, - RiStore2Line, RiStore3Line, + RiTodoLine, RiWallet3Line, } from "@remixicon/react"; import Link from "next/link"; import type { ReactNode } from "react"; import { BoletosWidget } from "@/components/dashboard/boletos-widget"; import { ExpensesByCategoryWidgetWithChart } from "@/components/dashboard/expenses-by-category-widget-with-chart"; +import { GoalsProgressWidget } from "@/components/dashboard/goals-progress-widget"; import { IncomeByCategoryWidgetWithChart } from "@/components/dashboard/income-by-category-widget-with-chart"; import { IncomeExpenseBalanceWidget } from "@/components/dashboard/income-expense-balance-widget"; import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget"; import { InvoicesWidget } from "@/components/dashboard/invoices-widget"; import { MyAccountsWidget } from "@/components/dashboard/my-accounts-widget"; +import { NotesWidget } from "@/components/dashboard/notes-widget"; import { PagadoresWidget } from "@/components/dashboard/pagadores-widget"; -import { PaymentConditionsWidget } from "@/components/dashboard/payment-conditions-widget"; -import { PaymentMethodsWidget } from "@/components/dashboard/payment-methods-widget"; +import { PaymentOverviewWidget } from "@/components/dashboard/payment-overview-widget"; import { PaymentStatusWidget } from "@/components/dashboard/payment-status-widget"; import { PurchasesByCategoryWidget } from "@/components/dashboard/purchases-by-category-widget"; -import { RecentTransactionsWidget } from "@/components/dashboard/recent-transactions-widget"; import { RecurringExpensesWidget } from "@/components/dashboard/recurring-expenses-widget"; -import { TopEstablishmentsWidget } from "@/components/dashboard/top-establishments-widget"; -import { TopExpensesWidget } from "@/components/dashboard/top-expenses-widget"; +import { SpendingOverviewWidget } from "@/components/dashboard/spending-overview-widget"; import type { DashboardData } from "./fetch-dashboard-data"; export type WidgetConfig = { @@ -114,30 +111,49 @@ export const widgetsConfig: WidgetConfig[] = [ ), }, { - id: "recent-transactions", - title: "Lançamentos Recentes", - subtitle: "Últimas 5 despesas registradas", + id: "notes", + title: "Anotações", + subtitle: "Últimas anotações ativas", + icon: , + component: ({ data }) => , + action: ( + + Ver todas + + + ), + }, + { + id: "goals-progress", + title: "Progresso de Orçamentos", + subtitle: "Orçamentos por categoria no período", icon: , component: ({ data }) => ( - + + ), + action: ( + + Ver todos + + ), }, { - id: "payment-conditions", - title: "Condições de Pagamentos", - subtitle: "Análise das condições", - icon: , + id: "payment-overview", + title: "Comportamento de Pagamento", + subtitle: "Despesas por condição e forma de pagamento", + icon: , component: ({ data }) => ( - - ), - }, - { - id: "payment-methods", - title: "Formas de Pagamento", - subtitle: "Distribuição das despesas", - icon: , - component: ({ data }) => ( - + ), }, { @@ -168,35 +184,18 @@ export const widgetsConfig: WidgetConfig[] = [ ), }, { - id: "top-expenses", - title: "Maiores Gastos do Mês", - subtitle: "Top 10 Despesas", + id: "spending-overview", + title: "Panorama de Gastos", + subtitle: "Principais despesas e frequência por local", icon: , component: ({ data }) => ( - ), }, - { - id: "top-establishments", - title: "Top Estabelecimentos", - subtitle: "Frequência de gastos no período", - icon: , - component: ({ data }) => ( - - ), - action: ( - - Ver mais - - - ), - }, { id: "purchases-by-category", title: "Lançamentos por Categorias",