feat(dashboard): add quick actions and new overview widgets

This commit is contained in:
Felipe Coutinho
2026-03-02 17:20:28 +00:00
parent 3d3a9e1414
commit 2a21bef2da
21 changed files with 1166 additions and 116 deletions

View File

@@ -4,7 +4,13 @@ import { SectionCards } from "@/components/dashboard/section-cards";
import MonthNavigation from "@/components/month-picker/month-navigation"; import MonthNavigation from "@/components/month-picker/month-navigation";
import { getUser } from "@/lib/auth/server"; import { getUser } from "@/lib/auth/server";
import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data"; import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data";
import {
buildOptionSets,
buildSluggedFilters,
fetchLancamentoFilterSources,
} from "@/lib/lancamentos/page-helpers";
import { parsePeriodParam } from "@/lib/utils/period"; import { parsePeriodParam } from "@/lib/utils/period";
import { getRecentEstablishmentsAction } from "../lancamentos/actions";
import { fetchUserDashboardPreferences } from "./data"; import { fetchUserDashboardPreferences } from "./data";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>; type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
@@ -28,12 +34,26 @@ export default async function Page({ searchParams }: PageProps) {
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam); const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const [data, preferences] = await Promise.all([ const [dashboardData, preferences, filterSources, estabelecimentos] =
fetchDashboardData(user.id, selectedPeriod), await Promise.all([
fetchUserDashboardPreferences(user.id), fetchDashboardData(user.id, selectedPeriod),
]); fetchUserDashboardPreferences(user.id),
fetchLancamentoFilterSources(user.id),
getRecentEstablishmentsAction(),
]);
const { disableMagnetlines, dashboardWidgets } = preferences; const { disableMagnetlines, dashboardWidgets } = preferences;
const sluggedFilters = buildSluggedFilters(filterSources);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
return ( return (
<main className="flex flex-col gap-4"> <main className="flex flex-col gap-4">
@@ -42,11 +62,20 @@ export default async function Page({ searchParams }: PageProps) {
disableMagnetlines={disableMagnetlines} disableMagnetlines={disableMagnetlines}
/> />
<MonthNavigation /> <MonthNavigation />
<SectionCards metrics={data.metrics} /> <SectionCards metrics={dashboardData.metrics} />
<DashboardGridEditable <DashboardGridEditable
data={data} data={dashboardData}
period={selectedPeriod} period={selectedPeriod}
initialPreferences={dashboardWidgets} initialPreferences={dashboardWidgets}
quickActionOptions={{
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
estabelecimentos,
}}
/> />
</main> </main>
); );

View File

@@ -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 (
<section className="space-y-6 pt-4">
<PageDescription
icon={<RiStore2Line />}
title="Top Estabelecimentos"
subtitle="Análise dos locais onde você mais compra e gasta"
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,58 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<main className="flex flex-col gap-4 px-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-8 w-48" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardContent className="p-4">
<Skeleton className="h-16 w-full" />
</CardContent>
</Card>
))}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-2">
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</CardContent>
</Card>
</div>
<div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
</main>
);
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | 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 (
<main className="flex flex-col gap-4">
<Card className="flex-row items-center justify-between p-3">
<span className="text-sm text-muted-foreground">
Selecione o intervalo de meses
</span>
<PeriodFilterButtons currentFilter={periodFilter} />
</Card>
<SummaryCards summary={data.summary} />
<HighlightsCards summary={data.summary} />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div>
<EstablishmentsList establishments={data.establishments} />
</div>
<div>
<TopCategories categories={data.topCategories} />
</div>
</div>
</main>
);
}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { import {
closestCenter, closestCorners,
DndContext, DndContext,
type DragEndEvent, type DragEndEvent,
KeyboardSensor, KeyboardSensor,
@@ -16,15 +16,21 @@ import {
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { import {
RiArrowDownLine,
RiArrowUpLine,
RiCheckLine, RiCheckLine,
RiCloseLine, RiCloseLine,
RiDragMove2Line, RiDragMove2Line,
RiEyeOffLine, RiEyeOffLine,
RiTodoLine,
} from "@remixicon/react"; } from "@remixicon/react";
import { useCallback, useMemo, useState, useTransition } from "react"; import { useCallback, useMemo, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { NoteDialog } from "@/components/anotacoes/note-dialog";
import { SortableWidget } from "@/components/dashboard/sortable-widget"; import { SortableWidget } from "@/components/dashboard/sortable-widget";
import { WidgetSettingsDialog } from "@/components/dashboard/widget-settings-dialog"; 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 { Button } from "@/components/ui/button";
import WidgetCard from "@/components/widget-card"; import WidgetCard from "@/components/widget-card";
import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data"; import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data";
@@ -42,12 +48,22 @@ type DashboardGridEditableProps = {
data: DashboardData; data: DashboardData;
period: string; period: string;
initialPreferences: WidgetPreferences | null; initialPreferences: WidgetPreferences | null;
quickActionOptions: {
pagadorOptions: SelectOption[];
splitPagadorOptions: SelectOption[];
defaultPagadorId: string | null;
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
estabelecimentos: string[];
};
}; };
export function DashboardGridEditable({ export function DashboardGridEditable({
data, data,
period, period,
initialPreferences, initialPreferences,
quickActionOptions,
}: DashboardGridEditableProps) { }: DashboardGridEditableProps) {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -183,53 +199,112 @@ export function DashboardGridEditable({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
{isEditing ? ( {!isEditing ? (
<> <div className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
<Button <span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
variant="outline" Ação rápida
size="sm" </span>
onClick={handleCancelEditing} <div className="-mb-1 flex items-center gap-2 overflow-x-auto pb-1 sm:mb-0 sm:overflow-visible sm:pb-0">
disabled={isPending} <LancamentoDialog
className="gap-2" mode="create"
> pagadorOptions={quickActionOptions.pagadorOptions}
<RiCloseLine className="size-4" /> splitPagadorOptions={quickActionOptions.splitPagadorOptions}
Cancelar defaultPagadorId={quickActionOptions.defaultPagadorId}
</Button> contaOptions={quickActionOptions.contaOptions}
<Button cartaoOptions={quickActionOptions.cartaoOptions}
size="sm" categoriaOptions={quickActionOptions.categoriaOptions}
onClick={handleSave} estabelecimentos={quickActionOptions.estabelecimentos}
disabled={isPending} defaultPeriod={period}
className="gap-2" defaultTransactionType="Receita"
> trigger={
<RiCheckLine className="size-4" /> <Button size="sm" variant="outline" className="gap-2">
Salvar <RiArrowUpLine className="size-4 text-success/80" />
</Button> Nova receita
</> </Button>
}
/>
<LancamentoDialog
mode="create"
pagadorOptions={quickActionOptions.pagadorOptions}
splitPagadorOptions={quickActionOptions.splitPagadorOptions}
defaultPagadorId={quickActionOptions.defaultPagadorId}
contaOptions={quickActionOptions.contaOptions}
cartaoOptions={quickActionOptions.cartaoOptions}
categoriaOptions={quickActionOptions.categoriaOptions}
estabelecimentos={quickActionOptions.estabelecimentos}
defaultPeriod={period}
defaultTransactionType="Despesa"
trigger={
<Button size="sm" variant="outline" className="gap-2">
<RiArrowDownLine className="size-4 text-destructive/80" />
Nova despesa
</Button>
}
/>
<NoteDialog
mode="create"
trigger={
<Button size="sm" variant="outline" className="gap-2">
<RiTodoLine className="size-4 text-info/80" />
Nova anotação
</Button>
}
/>
</div>
</div>
) : ( ) : (
<> <div />
<WidgetSettingsDialog
hiddenWidgets={hiddenWidgets}
onToggleWidget={handleToggleWidget}
onReset={handleReset}
/>
<Button
variant="outline"
size="sm"
onClick={handleStartEditing}
className="gap-2"
>
<RiDragMove2Line className="size-4" />
Reordenar
</Button>
</>
)} )}
<div className="flex items-center gap-2">
{isEditing ? (
<>
<Button
variant="outline"
size="sm"
onClick={handleCancelEditing}
disabled={isPending}
className="gap-2"
>
<RiCloseLine className="size-4" />
Cancelar
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={isPending}
className="gap-2"
>
<RiCheckLine className="size-4" />
Salvar
</Button>
</>
) : (
<>
<WidgetSettingsDialog
hiddenWidgets={hiddenWidgets}
onToggleWidget={handleToggleWidget}
onReset={handleReset}
/>
<Button
variant="outline"
size="sm"
onClick={handleStartEditing}
className="gap-2"
>
<RiDragMove2Line className="size-4" />
Reordenar
</Button>
</>
)}
</div>
</div> </div>
{/* Grid */} {/* Grid */}
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCorners}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext <SortableContext

View File

@@ -0,0 +1,146 @@
"use client";
import { RiFundsLine, RiPencilLine } from "@remixicon/react";
import { useCallback, useMemo, useState } from "react";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/money-values";
import { BudgetDialog } from "@/components/orcamentos/budget-dialog";
import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import type { GoalsProgressData } from "@/lib/dashboard/goals-progress";
import { WidgetEmptyState } from "../widget-empty-state";
type GoalsProgressWidgetProps = {
data: GoalsProgressData;
};
const clamp = (value: number, min: number, max: number) =>
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<Budget | null>(null);
const categories = useMemo<BudgetCategory[]>(
() =>
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 (
<WidgetEmptyState
icon={<RiFundsLine className="size-6 text-muted-foreground" />}
title="Nenhum orçamento para o período"
description="Cadastre orçamentos para acompanhar o progresso das metas."
/>
);
}
return (
<div className="flex flex-col gap-4 px-0">
<ul className="flex flex-col">
{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 (
<li
key={item.id}
className="border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 items-start gap-2">
<CategoryIconBadge
icon={item.categoryIcon}
name={item.categoryName}
colorIndex={index}
size="md"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{item.categoryName}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<MoneyValues amount={item.spentAmount} /> de{" "}
<MoneyValues amount={item.budgetAmount} />
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className={`text-xs font-medium ${statusColor}`}>
{formatPercentage(percentageDelta, true)}
</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={() => handleEdit(item)}
aria-label={`Editar orçamento de ${item.categoryName}`}
>
<RiPencilLine className="size-3.5" />
</Button>
</div>
</div>
<div className="mt-1.5 ml-11">
<Progress value={progressValue} />
</div>
</li>
);
})}
</ul>
<BudgetDialog
mode="update"
budget={selectedBudget ?? undefined}
categories={categories}
defaultPeriod={defaultPeriod}
open={editOpen && !!selectedBudget}
onOpenChange={handleEditOpenChange}
/>
</div>
);
}

View File

@@ -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<Note | null>(null);
const [isEditOpen, setIsEditOpen] = useState(false);
const [noteDetails, setNoteDetails] = useState<Note | null>(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 (
<>
<CardContent className="flex flex-col gap-4 px-0">
{mappedNotes.length === 0 ? (
<WidgetEmptyState
icon={<RiTodoLine className="size-6 text-muted-foreground" />}
title="Nenhuma anotação ativa"
description="Crie anotações para acompanhar lembretes e tarefas financeiras."
/>
) : (
<ul className="flex flex-col">
{mappedNotes.map((note) => (
<li
key={note.id}
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{buildDisplayTitle(note.title)}
</p>
<div className="mt-1 flex items-center gap-2">
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px]"
>
{getTasksSummary(note)}
</Badge>
<p className="truncate text-xs text-muted-foreground">
{DATE_FORMATTER.format(new Date(note.createdAt))}
</p>
</div>
</div>
<div className="flex shrink-0 items-center">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleOpenEdit(note)}
aria-label={`Editar anotação ${buildDisplayTitle(note.title)}`}
>
<RiPencilLine className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleOpenDetails(note)}
aria-label={`Ver detalhes da anotação ${buildDisplayTitle(
note.title,
)}`}
>
<RiEyeLine className="size-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</CardContent>
<NoteDialog
mode="update"
note={noteToEdit ?? undefined}
open={isEditOpen}
onOpenChange={handleEditOpenChange}
/>
<NoteDetailsDialog
note={noteDetails}
open={isDetailsOpen}
onOpenChange={handleDetailsOpenChange}
/>
</>
);
}

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { import {
RiArrowDownSFill,
RiArrowUpSFill,
RiExternalLinkLine, RiExternalLinkLine,
RiGroupLine, RiGroupLine,
RiVerifiedBadgeFill, RiVerifiedBadgeFill,
@@ -17,6 +19,10 @@ type PagadoresWidgetProps = {
pagadores: DashboardPagador[]; pagadores: DashboardPagador[];
}; };
const formatPercentage = (value: number) => {
return `${Math.abs(value).toFixed(0)}%`;
};
const buildInitials = (value: string) => { const buildInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean); const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) { if (parts.length === 0) {
@@ -44,6 +50,12 @@ export function PagadoresWidget({ pagadores }: PagadoresWidgetProps) {
<ul className="flex flex-col"> <ul className="flex flex-col">
{pagadores.map((pagador) => { {pagadores.map((pagador) => {
const initials = buildInitials(pagador.name); const initials = buildInitials(pagador.name);
const hasValidPercentageChange =
typeof pagador.percentageChange === "number" &&
Number.isFinite(pagador.percentageChange);
const percentageChange = hasValidPercentageChange
? pagador.percentageChange
: null;
return ( return (
<li <li
@@ -87,6 +99,25 @@ export function PagadoresWidget({ pagadores }: PagadoresWidgetProps) {
<div className="flex shrink-0 flex-col items-end"> <div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={pagador.totalExpenses} /> <MoneyValues amount={pagador.totalExpenses} />
{percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-xs ${
percentageChange > 0
? "text-destructive"
: percentageChange < 0
? "text-success"
: "text-muted-foreground"
}`}
>
{percentageChange > 0 && (
<RiArrowUpSFill className="size-3" />
)}
{percentageChange < 0 && (
<RiArrowDownSFill className="size-3" />
)}
{formatPercentage(percentageChange)}
</span>
)}
</div> </div>
</li> </li>
); );

View File

@@ -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 (
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as "conditions" | "methods")}
className="w-full"
>
<TabsList className="grid grid-cols-2">
<TabsTrigger value="conditions" className="text-xs">
<RiSlideshowLine className="mr-1 size-3.5" />
Condições
</TabsTrigger>
<TabsTrigger value="methods" className="text-xs">
<RiMoneyDollarCircleLine className="mr-1 size-3.5" />
Formas
</TabsTrigger>
</TabsList>
<TabsContent value="conditions" className="mt-2">
<PaymentConditionsWidget data={paymentConditionsData} />
</TabsContent>
<TabsContent value="methods" className="mt-2">
<PaymentMethodsWidget data={paymentMethodsData} />
</TabsContent>
</Tabs>
);
}

View File

@@ -37,7 +37,8 @@ export function SortableWidget({
className={cn( className={cn(
"relative", "relative",
isDragging && "z-50 opacity-90", isDragging && "z-50 opacity-90",
isEditing && "cursor-grab active:cursor-grabbing", isEditing &&
"cursor-grab active:cursor-grabbing touch-none select-none",
)} )}
{...(isEditing ? { ...attributes, ...listeners } : {})} {...(isEditing ? { ...attributes, ...listeners } : {})}
> >

View File

@@ -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 (
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(value as "expenses" | "establishments")
}
className="w-full"
>
<TabsList className="grid grid-cols-2">
<TabsTrigger value="expenses" className="text-xs">
<RiArrowUpDoubleLine className="mr-1 size-3.5" />
Top gastos
</TabsTrigger>
<TabsTrigger value="establishments" className="text-xs">
<RiStore2Line className="mr-1 size-3.5" />
Estabelecimentos
</TabsTrigger>
</TabsList>
<TabsContent value="expenses" className="mt-2">
<TopExpensesWidget
allExpenses={topExpensesAll}
cardOnlyExpenses={topExpensesCardOnly}
/>
</TabsContent>
<TabsContent value="establishments" className="mt-2">
<TopEstablishmentsWidget data={topEstablishmentsData} />
</TabsContent>
</Tabs>
);
}

View File

@@ -9,6 +9,7 @@ import {
RiGroupLine, RiGroupLine,
RiPriceTag3Line, RiPriceTag3Line,
RiSparklingLine, RiSparklingLine,
RiStore2Line,
RiTodoLine, RiTodoLine,
} from "@remixicon/react"; } from "@remixicon/react";
@@ -110,6 +111,11 @@ export const NAV_SECTIONS: NavSection[] = [
icon: <RiBankCard2Line className="size-4" />, icon: <RiBankCard2Line className="size-4" />,
preservePeriod: true, preservePeriod: true,
}, },
{
href: "/relatorios/estabelecimentos",
label: "estabelecimentos",
icon: <RiStore2Line className="size-4" />,
},
], ],
}, },
]; ];

View File

@@ -9,7 +9,6 @@ import {
import { CategoryIcon } from "@/components/categorias/category-icon"; import { CategoryIcon } from "@/components/categorias/category-icon";
import { PeriodPicker } from "@/components/period-picker"; import { PeriodPicker } from "@/components/period-picker";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CurrencyInput } from "@/components/ui/currency-input";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -27,6 +26,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { useControlledState } from "@/hooks/use-controlled-state"; import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state"; import { useFormState } from "@/hooks/use-form-state";
@@ -54,6 +54,12 @@ const buildInitialValues = ({
amount: budget ? (Math.round(budget.amount * 100) / 100).toFixed(2) : "", 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({ export function BudgetDialog({
mode, mode,
trigger, trigger,
@@ -164,6 +170,15 @@ export function BudgetDialog({
const submitLabel = const submitLabel =
mode === "create" ? "Salvar orçamento" : "Atualizar orçamento"; mode === "create" ? "Salvar orçamento" : "Atualizar orçamento";
const disabled = categories.length === 0; 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 ( return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
@@ -215,7 +230,7 @@ export function BudgetDialog({
</Select> </Select>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="budget-period">Período</Label> <Label htmlFor="budget-period">Período</Label>
<PeriodPicker <PeriodPicker
@@ -227,12 +242,30 @@ export function BudgetDialog({
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="budget-amount">Valor limite</Label> <Label htmlFor="budget-amount">Valor limite</Label>
<CurrencyInput <div className="space-y-3 rounded-md border p-3">
id="budget-amount" <div className="flex items-center justify-between text-sm">
placeholder="R$ 0,00" <span className="text-muted-foreground">Limite atual</span>
value={formState.amount} <span className="font-semibold text-foreground">
onValueChange={(value) => updateField("amount", value)} {formatCurrency(sliderValue)}
/> </span>
</div>
<Slider
id="budget-amount"
value={[sliderValue]}
min={0}
max={sliderMax}
step={10}
onValueChange={(value) =>
updateField("amount", value[0]?.toFixed(2) ?? "0.00")
}
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{formatCurrency(0)}</span>
<span>{formatCurrency(sliderMax)}</span>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -22,7 +22,7 @@ export function PeriodFilterButtons({ currentFilter }: PeriodFilterProps) {
const handleFilterChange = (filter: PeriodFilter) => { const handleFilterChange = (filter: PeriodFilter) => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
params.set("meses", filter); params.set("meses", filter);
router.push(`/top-estabelecimentos?${params.toString()}`); router.push(`/relatorios/estabelecimentos?${params.toString()}`);
}; };
return ( return (

37
components/ui/slider.tsx Normal file
View File

@@ -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<typeof SliderPrimitive.Root>) {
return (
<SliderPrimitive.Root
data-slot="slider"
className={cn(
"relative flex w-full touch-none items-center select-none",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className="bg-muted relative h-2 w-full grow overflow-hidden rounded-full"
>
<SliderPrimitive.Range
data-slot="slider-range"
className="bg-primary absolute h-full"
/>
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
data-slot="slider-thumb"
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-colors focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50"
/>
</SliderPrimitive.Root>
);
}
export { Slider };

View File

@@ -27,7 +27,7 @@ export const revalidateConfig = {
estabelecimentos: ["/estabelecimentos", "/lancamentos"], estabelecimentos: ["/estabelecimentos", "/lancamentos"],
orcamentos: ["/orcamentos"], orcamentos: ["/orcamentos"],
pagadores: ["/pagadores"], pagadores: ["/pagadores"],
anotacoes: ["/anotacoes", "/anotacoes/arquivadas"], anotacoes: ["/anotacoes", "/anotacoes/arquivadas", "/dashboard"],
lancamentos: ["/lancamentos", "/contas"], lancamentos: ["/lancamentos", "/contas"],
inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"], inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"],
} as const; } as const;
@@ -39,6 +39,7 @@ const DASHBOARD_ENTITIES: ReadonlySet<string> = new Set([
"cartoes", "cartoes",
"orcamentos", "orcamentos",
"pagadores", "pagadores",
"anotacoes",
"inbox", "inbox",
]); ]);

View File

@@ -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<GoalsProgressData> {
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<number>`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,
};
}

73
lib/dashboard/notes.ts Normal file
View File

@@ -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<DashboardTask>;
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<DashboardNote[]> {
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(),
}));
}

View File

@@ -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 { lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common"; import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { calculatePercentageChange } from "@/lib/utils/math";
import { getPreviousPeriod } from "@/lib/utils/period";
export type DashboardPagador = { export type DashboardPagador = {
id: string; id: string;
@@ -11,6 +13,8 @@ export type DashboardPagador = {
email: string | null; email: string | null;
avatarUrl: string | null; avatarUrl: string | null;
totalExpenses: number; totalExpenses: number;
previousExpenses: number;
percentageChange: number | null;
isAdmin: boolean; isAdmin: boolean;
}; };
@@ -23,6 +27,8 @@ export async function fetchDashboardPagadores(
userId: string, userId: string,
period: string, period: string,
): Promise<DashboardPagadoresSnapshot> { ): Promise<DashboardPagadoresSnapshot> {
const previousPeriod = getPreviousPeriod(period);
const rows = await db const rows = await db
.select({ .select({
id: pagadores.id, id: pagadores.id,
@@ -30,6 +36,7 @@ export async function fetchDashboardPagadores(
email: pagadores.email, email: pagadores.email,
avatarUrl: pagadores.avatarUrl, avatarUrl: pagadores.avatarUrl,
role: pagadores.role, role: pagadores.role,
period: lancamentos.period,
totalExpenses: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`, totalExpenses: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`,
}) })
.from(lancamentos) .from(lancamentos)
@@ -37,7 +44,7 @@ export async function fetchDashboardPagadores(
.where( .where(
and( and(
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.period, period), inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Despesa"), eq(lancamentos.transactionType, "Despesa"),
or( or(
isNull(lancamentos.note), isNull(lancamentos.note),
@@ -51,19 +58,60 @@ export async function fetchDashboardPagadores(
pagadores.email, pagadores.email,
pagadores.avatarUrl, pagadores.avatarUrl,
pagadores.role, pagadores.role,
lancamentos.period,
) )
.orderBy(desc(sql`SUM(ABS(${lancamentos.amount}))`)); .orderBy(desc(sql`SUM(ABS(${lancamentos.amount}))`));
const pagadoresList = rows const groupedPagadores = new Map<
.map((row) => ({ 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, id: row.id,
name: row.name, name: row.name,
email: row.email, email: row.email,
avatarUrl: row.avatarUrl, avatarUrl: row.avatarUrl,
totalExpenses: toNumber(row.totalExpenses),
isAdmin: row.role === PAGADOR_ROLE_ADMIN, 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( const totalExpenses = pagadoresList.reduce(
(sum, p) => sum + p.totalExpenses, (sum, p) => sum + p.totalExpenses,

View File

@@ -69,7 +69,10 @@ export async function fetchTopEstablishments(
), ),
) )
.groupBy(lancamentos.name) .groupBy(lancamentos.name)
.orderBy(sql`ABS(sum(${lancamentos.amount})) DESC`) .orderBy(
sql`count(${lancamentos.id}) DESC`,
sql`ABS(sum(${lancamentos.amount})) DESC`,
)
.limit(10); .limit(10);
const establishments = rows const establishments = rows

View File

@@ -7,33 +7,30 @@ import {
RiExchangeLine, RiExchangeLine,
RiGroupLine, RiGroupLine,
RiLineChartLine, RiLineChartLine,
RiMoneyDollarCircleLine,
RiNumbersLine, RiNumbersLine,
RiPieChartLine, RiPieChartLine,
RiRefreshLine, RiRefreshLine,
RiSlideshowLine,
RiStore2Line,
RiStore3Line, RiStore3Line,
RiTodoLine,
RiWallet3Line, RiWallet3Line,
} from "@remixicon/react"; } from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { BoletosWidget } from "@/components/dashboard/boletos-widget"; import { BoletosWidget } from "@/components/dashboard/boletos-widget";
import { ExpensesByCategoryWidgetWithChart } from "@/components/dashboard/expenses-by-category-widget-with-chart"; 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 { IncomeByCategoryWidgetWithChart } from "@/components/dashboard/income-by-category-widget-with-chart";
import { IncomeExpenseBalanceWidget } from "@/components/dashboard/income-expense-balance-widget"; import { IncomeExpenseBalanceWidget } from "@/components/dashboard/income-expense-balance-widget";
import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget"; import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget";
import { InvoicesWidget } from "@/components/dashboard/invoices-widget"; import { InvoicesWidget } from "@/components/dashboard/invoices-widget";
import { MyAccountsWidget } from "@/components/dashboard/my-accounts-widget"; import { MyAccountsWidget } from "@/components/dashboard/my-accounts-widget";
import { NotesWidget } from "@/components/dashboard/notes-widget";
import { PagadoresWidget } from "@/components/dashboard/pagadores-widget"; import { PagadoresWidget } from "@/components/dashboard/pagadores-widget";
import { PaymentConditionsWidget } from "@/components/dashboard/payment-conditions-widget"; import { PaymentOverviewWidget } from "@/components/dashboard/payment-overview-widget";
import { PaymentMethodsWidget } from "@/components/dashboard/payment-methods-widget";
import { PaymentStatusWidget } from "@/components/dashboard/payment-status-widget"; import { PaymentStatusWidget } from "@/components/dashboard/payment-status-widget";
import { PurchasesByCategoryWidget } from "@/components/dashboard/purchases-by-category-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 { RecurringExpensesWidget } from "@/components/dashboard/recurring-expenses-widget";
import { TopEstablishmentsWidget } from "@/components/dashboard/top-establishments-widget"; import { SpendingOverviewWidget } from "@/components/dashboard/spending-overview-widget";
import { TopExpensesWidget } from "@/components/dashboard/top-expenses-widget";
import type { DashboardData } from "./fetch-dashboard-data"; import type { DashboardData } from "./fetch-dashboard-data";
export type WidgetConfig = { export type WidgetConfig = {
@@ -114,30 +111,49 @@ export const widgetsConfig: WidgetConfig[] = [
), ),
}, },
{ {
id: "recent-transactions", id: "notes",
title: "Lançamentos Recentes", title: "Anotações",
subtitle: "Últimas 5 despesas registradas", subtitle: "Últimas anotações ativas",
icon: <RiTodoLine className="size-4" />,
component: ({ data }) => <NotesWidget notes={data.notesData} />,
action: (
<Link
href="/anotacoes"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Ver todas
<RiArrowRightLine className="size-4" />
</Link>
),
},
{
id: "goals-progress",
title: "Progresso de Orçamentos",
subtitle: "Orçamentos por categoria no período",
icon: <RiExchangeLine className="size-4" />, icon: <RiExchangeLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<RecentTransactionsWidget data={data.recentTransactionsData} /> <GoalsProgressWidget data={data.goalsProgressData} />
),
action: (
<Link
href="/orcamentos"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Ver todos
<RiArrowRightLine className="size-4" />
</Link>
), ),
}, },
{ {
id: "payment-conditions", id: "payment-overview",
title: "Condições de Pagamentos", title: "Comportamento de Pagamento",
subtitle: "Análise das condições", subtitle: "Despesas por condição e forma de pagamento",
icon: <RiSlideshowLine className="size-4" />, icon: <RiWallet3Line className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<PaymentConditionsWidget data={data.paymentConditionsData} /> <PaymentOverviewWidget
), paymentConditionsData={data.paymentConditionsData}
}, paymentMethodsData={data.paymentMethodsData}
{ />
id: "payment-methods",
title: "Formas de Pagamento",
subtitle: "Distribuição das despesas",
icon: <RiMoneyDollarCircleLine className="size-4" />,
component: ({ data }) => (
<PaymentMethodsWidget data={data.paymentMethodsData} />
), ),
}, },
{ {
@@ -168,35 +184,18 @@ export const widgetsConfig: WidgetConfig[] = [
), ),
}, },
{ {
id: "top-expenses", id: "spending-overview",
title: "Maiores Gastos do Mês", title: "Panorama de Gastos",
subtitle: "Top 10 Despesas", subtitle: "Principais despesas e frequência por local",
icon: <RiArrowUpDoubleLine className="size-4" />, icon: <RiArrowUpDoubleLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<TopExpensesWidget <SpendingOverviewWidget
allExpenses={data.topExpensesAll} topExpensesAll={data.topExpensesAll}
cardOnlyExpenses={data.topExpensesCardOnly} topExpensesCardOnly={data.topExpensesCardOnly}
topEstablishmentsData={data.topEstablishmentsData}
/> />
), ),
}, },
{
id: "top-establishments",
title: "Top Estabelecimentos",
subtitle: "Frequência de gastos no período",
icon: <RiStore2Line className="size-4" />,
component: ({ data }) => (
<TopEstablishmentsWidget data={data.topEstablishmentsData} />
),
action: (
<Link
href="/top-estabelecimentos"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Ver mais
<RiArrowRightLine className="size-4" />
</Link>
),
},
{ {
id: "purchases-by-category", id: "purchases-by-category",
title: "Lançamentos por Categorias", title: "Lançamentos por Categorias",