refactor(dashboard): reorganizar módulos em subdiretórios e nova arquitetura de widgets

Arquivos de queries, helpers e controllers dispersos na raiz de dashboard/
foram movidos para subdiretórios temáticos (bills/, invoices/, notes/,
notifications/, overview/, payments/, goals-progress/, categories/).
~25 widgets monolíticos obsoletos removidos em favor de nova arquitetura
baseada em widget-registry com components/widgets/. Novos componentes:
category-breakdown-chart/list, goals-progress-item, percentage-change-indicator.
Imports atualizados em fetch-dashboard-data e transaction-filters limpos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-04-20 17:51:56 +00:00
parent 3e80d5995b
commit ba05985725
99 changed files with 784 additions and 2055 deletions

View File

@@ -0,0 +1,128 @@
"use client";
import {
RiAttachmentLine,
RiFileLine,
RiFilePdf2Line,
RiImageLine,
} from "@remixicon/react";
import { useState } from "react";
import { AttachmentPreview } from "@/features/attachments/components/attachment-preview";
import type { AttachmentForPeriod } from "@/features/attachments/queries";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { formatDateOnly } from "@/shared/utils/date";
import { formatBytes } from "@/shared/utils/number";
type AttachmentsSnapshot = {
totalCount: number;
totalBytes: number;
imageCount: number;
pdfCount: number;
recentAttachments: AttachmentForPeriod[];
};
type AttachmentsWidgetProps = {
snapshot: AttachmentsSnapshot;
};
export function AttachmentsWidget({ snapshot }: AttachmentsWidgetProps) {
const [selectedIndex, setSelectedIndex] = useState(-1);
if (snapshot.totalCount === 0) {
return (
<WidgetEmptyState
icon={<RiAttachmentLine className="size-6 text-muted-foreground" />}
title="Nenhum anexo no período"
description="Adicione comprovantes nos seus lançamentos para vê-los aqui."
/>
);
}
return (
<>
<div className="mb-2 flex flex-wrap gap-2">
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
<RiAttachmentLine className="size-3.5" />
{snapshot.totalCount} {snapshot.totalCount === 1 ? "anexo" : "anexos"}
</span>
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
{formatBytes(snapshot.totalBytes)}
</span>
{snapshot.imageCount > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
<RiImageLine className="size-3.5 text-blue-500" />
{snapshot.imageCount}
</span>
)}
{snapshot.pdfCount > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
<RiFilePdf2Line className="size-3.5 text-red-500" />
{snapshot.pdfCount}
</span>
)}
</div>
<ul className="flex flex-col">
{snapshot.recentAttachments.map((attachment, index) => {
const isPdf = attachment.mimeType === "application/pdf";
const isImage = attachment.mimeType.startsWith("image/");
return (
<li key={`${attachment.attachmentId}-${attachment.transactionId}`}>
<button
type="button"
onClick={() => setSelectedIndex(index)}
className="flex w-full items-center gap-2 py-2 text-left"
>
<div className="shrink-0">
{isPdf && <RiFilePdf2Line className="size-6 text-red-500" />}
{isImage && <RiImageLine className="size-6 text-blue-500" />}
{!isPdf && !isImage && (
<RiFileLine className="size-6 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="block truncate text-sm font-medium text-foreground hover:underline">
{attachment.fileName}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs break-all">
{attachment.fileName}
</TooltipContent>
</Tooltip>
<span className="block truncate text-xs text-muted-foreground">
{attachment.transactionName}
</span>
</div>
<div className="shrink-0 text-right">
<span className="block text-xs text-muted-foreground">
{formatDateOnly(attachment.purchaseDate, {
day: "2-digit",
month: "2-digit",
}) ?? "—"}
</span>
<span className="block text-xs text-muted-foreground/60">
{formatBytes(attachment.fileSize)}
</span>
</div>
</button>
</li>
);
})}
</ul>
<AttachmentPreview
attachments={snapshot.recentAttachments}
selectedIndex={selectedIndex}
onClose={() => setSelectedIndex(-1)}
/>
</>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
import { useBillWidgetController } from "@/features/dashboard/bills/use-bill-widget-controller";
import { BillsWidgetView } from "../bills/bills-widget-view";
type BillWidgetProps = {
bills?: DashboardBill[];
};
export function BillWidget({ bills }: BillWidgetProps) {
const {
items,
selectedBill,
isModalOpen,
modalState,
isPending,
openPaymentDialog,
closePaymentDialog,
confirmPayment,
} = useBillWidgetController(bills);
return (
<BillsWidgetView
bills={items}
selectedBill={selectedBill}
isModalOpen={isModalOpen}
modalState={modalState}
isPending={isPending}
onOpenPaymentDialog={openPaymentDialog}
onClosePaymentDialog={closePaymentDialog}
onConfirmPayment={confirmPayment}
/>
);
}

View File

@@ -0,0 +1,441 @@
"use client";
import {
RiArrowDownSLine,
RiBarChartBoxLine,
RiCloseLine,
} from "@remixicon/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import type { CategoryHistoryData } from "@/features/dashboard/categories/category-history-queries";
import { Button } from "@/shared/components/ui/button";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
} from "@/shared/components/ui/chart";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/shared/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/components/ui/popover";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { CATEGORY_COLORS } from "@/shared/utils/category-colors";
import { formatCurrency, formatCurrencyCompact } from "@/shared/utils/currency";
import { getIconComponent } from "@/shared/utils/icons";
type CategoryHistoryWidgetProps = {
data: CategoryHistoryData;
};
const STORAGE_KEY_SELECTED = "dashboard-category-history-selected";
const CHART_COLORS = CATEGORY_COLORS;
export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [isClient, setIsClient] = useState(false);
const [open, setOpen] = useState(false);
const isFirstRender = useRef(true);
// Load from sessionStorage on mount and save on changes
useEffect(() => {
setIsClient(true);
// Only load from storage on first render
if (isFirstRender.current) {
const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
const validCategories = parsed.filter((id) =>
data.allCategories.some((cat) => cat.id === id),
);
setSelectedCategories(validCategories.slice(0, 5));
}
} catch (_e) {
// Invalid JSON, ignore
}
}
isFirstRender.current = false;
} else {
// Save to storage on subsequent changes
sessionStorage.setItem(
STORAGE_KEY_SELECTED,
JSON.stringify(selectedCategories),
);
}
}, [selectedCategories, data.allCategories]);
// Filter data to show only selected categories with vibrant colors
const filteredCategories = useMemo(() => {
return selectedCategories
.map((id, index) => {
const cat = data.categories.find((c) => c.id === id);
if (!cat) return null;
return {
...cat,
color: CHART_COLORS[index % CHART_COLORS.length],
};
})
.filter(Boolean) as Array<{
id: string;
name: string;
icon: string | null;
color: string;
data: Record<string, number>;
}>;
}, [data.categories, selectedCategories]);
// Filter chart data to include only selected categories
const filteredChartData = useMemo(() => {
if (filteredCategories.length === 0) {
return data.chartData.map((item) => ({ month: item.month }));
}
return data.chartData.map((item) => {
const filtered: Record<string, number | string> = { month: item.month };
filteredCategories.forEach((category) => {
filtered[category.name] = item[category.name] || 0;
});
return filtered;
});
}, [data.chartData, filteredCategories]);
// Build chart config dynamically from filtered categories
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
filteredCategories.forEach((category) => {
config[category.name] = {
label: category.name,
color: category.color,
};
});
return config;
}, [filteredCategories]);
const handleAddCategory = (categoryId: string) => {
if (
categoryId &&
!selectedCategories.includes(categoryId) &&
selectedCategories.length < 5
) {
setSelectedCategories([...selectedCategories, categoryId]);
setOpen(false);
}
};
const handleRemoveCategory = (categoryId: string) => {
setSelectedCategories(selectedCategories.filter((id) => id !== categoryId));
};
const handleClearAll = () => {
setSelectedCategories([]);
};
const availableCategories = useMemo(() => {
return data.allCategories.filter(
(cat) => !selectedCategories.includes(cat.id),
);
}, [data.allCategories, selectedCategories]);
const selectedCategoryDetails = useMemo(() => {
return selectedCategories
.map((id) => data.allCategories.find((cat) => cat.id === id))
.filter(Boolean);
}, [selectedCategories, data.allCategories]);
const isEmpty = filteredCategories.length === 0;
// Group available categories by type
const { despesaCategories, receitaCategories } = useMemo(() => {
const despesa = availableCategories.filter((cat) => cat.type === "despesa");
const receita = availableCategories.filter((cat) => cat.type === "receita");
return { despesaCategories: despesa, receitaCategories: receita };
}, [availableCategories]);
if (!isClient) {
return null;
}
return (
<div className="space-y-2.5">
<div className="space-y-2">
{selectedCategoryDetails.length > 0 && (
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex flex-wrap gap-2">
{selectedCategoryDetails.map((category, colorIndex) => {
if (!category) return null;
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
const color = CHART_COLORS[colorIndex % CHART_COLORS.length];
return (
<div
key={category.id}
className="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm"
style={{ borderColor: color }}
>
{IconComponent ? (
<span style={{ color }}>
<IconComponent className="size-4" />
</span>
) : (
<div
className="size-3 rounded-sm"
style={{ backgroundColor: color }}
/>
)}
<span className="text-sm font-medium text-foreground">
{category.name}
</span>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-transparent"
onClick={() => handleRemoveCategory(category.id)}
>
<RiCloseLine className="size-3" />
</Button>
</div>
);
})}
</div>
<div className="flex items-center gap-2 shrink-0 pt-1.5">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{selectedCategories.length}/5 selecionadas
</span>
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
>
Limpar
</Button>
</div>
</div>
)}
{selectedCategories.length < 5 && availableCategories.length > 0 && (
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between hover:scale-none"
>
Selecionar categorias
<RiArrowDownSLine className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-(--radix-popover-trigger-width) p-0"
align="start"
>
<Command>
<CommandInput placeholder="Pesquisar categoria..." />
<CommandList>
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
{despesaCategories.length > 0 && (
<CommandGroup heading="Despesas">
{despesaCategories.map((category) => {
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
return (
<CommandItem
key={category.id}
value={category.name}
onSelect={() => handleAddCategory(category.id)}
className="gap-2"
>
{IconComponent ? (
<IconComponent className="size-4 text-destructive" />
) : (
<div className="size-3 rounded-sm bg-destructive" />
)}
<span>{category.name}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
{receitaCategories.length > 0 && (
<CommandGroup heading="Receitas">
{receitaCategories.map((category) => {
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
return (
<CommandItem
key={category.id}
value={category.name}
onSelect={() => handleAddCategory(category.id)}
className="gap-2"
>
{IconComponent ? (
<IconComponent className="size-4 text-success" />
) : (
<div className="size-3 rounded-sm bg-success" />
)}
<span>{category.name}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
{isEmpty ? (
<div className="h-[450px] flex items-center justify-center">
<WidgetEmptyState
icon={
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
}
title="Selecione categorias para visualizar"
description="Escolha até 5 categorias para acompanhar o histórico dos últimos 8 meses, mês atual e próximo mês."
/>
</div>
) : (
<ChartContainer config={chartConfig} className="h-[450px] w-full">
<AreaChart
data={filteredChartData}
margin={{ top: 10, right: 20, left: 10, bottom: 0 }}
>
<defs>
{filteredCategories.map((category) => (
<linearGradient
key={`gradient-${category.id}`}
id={`gradient-${category.id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor={category.color}
stopOpacity={0.4}
/>
<stop
offset="95%"
stopColor={category.color}
stopOpacity={0.05}
/>
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
className="text-xs"
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
className="text-xs"
tickFormatter={(value) => formatCurrencyCompact(Number(value))}
/>
<ChartTooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) {
return null;
}
// Sort payload by value (descending)
const sortedPayload = [...payload].sort(
(a, b) => (b.value as number) - (a.value as number),
);
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<div className="mb-2 text-xs font-medium text-muted-foreground">
{payload[0].payload.month}
</div>
<div className="grid gap-1.5">
{sortedPayload
.filter((entry) => (entry.value as number) > 0)
.map((entry) => {
const config =
chartConfig[
entry.dataKey as keyof typeof chartConfig
];
const value = entry.value as number;
return (
<div
key={String(entry.dataKey ?? entry.name)}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<div
className="h-2.5 w-2.5 rounded-sm shrink-0"
style={{ backgroundColor: config?.color }}
/>
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
{config?.label}
</span>
</div>
<span className="text-xs font-medium">
{formatCurrency(value)}
</span>
</div>
);
})}
</div>
</div>
);
}}
cursor={{
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
}}
/>
{filteredCategories.map((category) => (
<Area
key={category.id}
type="monotone"
dataKey={category.name}
stroke={category.color}
strokeWidth={1}
fill={`url(#gradient-${category.id})`}
fillOpacity={1}
dot={false}
activeDot={{
r: 5,
fill: category.color,
stroke: "hsl(var(--background))",
strokeWidth: 2,
}}
/>
))}
</AreaChart>
</ChartContainer>
)}
</div>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { RiLineChartLine } from "@remixicon/react";
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { formatPercentage } from "@/shared/utils/percentage";
type CategoryTrendsWidgetProps = {
categories: DashboardCategoryBreakdownItem[];
};
export function CategoryTrendsWidget({
categories,
}: CategoryTrendsWidgetProps) {
const trending = categories
.filter((c) => c.percentageChange !== null && c.previousAmount > 0)
.sort(
(a, b) =>
Math.abs(b.percentageChange ?? 0) - Math.abs(a.percentageChange ?? 0),
)
.slice(0, 10);
if (trending.length === 0) {
return (
<WidgetEmptyState
icon={<RiLineChartLine className="size-6 text-muted-foreground" />}
title="Dados insuficientes"
description="As variações aparecem após lançamentos em dois meses consecutivos."
/>
);
}
return (
<ul className="flex flex-col space-y-1">
{trending.map((category) => {
const change = category.percentageChange ?? 0;
return (
<li key={category.categoryId}>
<div className="-mx-2 flex items-center gap-3 rounded-md p-2">
<CategoryIconBadge
icon={category.categoryIcon}
name={category.categoryName}
size="md"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{category.categoryName}
</p>
<p className="text-xs text-muted-foreground">
<MoneyValues amount={category.previousAmount} /> vs{" "}
<MoneyValues
amount={category.currentAmount}
className="font-semibold"
/>
</p>
</div>
<PercentageChangeIndicator
value={change}
label={formatPercentage(change, {
absolute: true,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}
positiveTrend="down"
className="shrink-0 text-sm font-semibold"
iconClassName="size-3.5"
/>
</div>
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import type { ExpensesByCategoryData } from "@/features/dashboard/categories/expenses-by-category-queries";
import { CategoryBreakdownWidgetView } from "../category-breakdown/category-breakdown-widget-view";
type ExpensesByCategoryWidgetWithChartProps = {
data: ExpensesByCategoryData;
period: string;
};
export function ExpensesByCategoryWidgetWithChart({
data,
period,
}: ExpensesByCategoryWidgetWithChartProps) {
return (
<CategoryBreakdownWidgetView
data={data}
period={period}
variant="expense"
/>
);
}

View File

@@ -0,0 +1,32 @@
"use client";
import type { GoalsProgressData } from "@/features/dashboard/goals-progress/goals-progress-queries";
import { useGoalsProgressWidgetController } from "@/features/dashboard/goals-progress/use-goals-progress-widget-controller";
import { GoalsProgressWidgetView } from "../goals-progress/goals-progress-widget-view";
type GoalsProgressWidgetProps = {
data: GoalsProgressData;
};
export function GoalsProgressWidget({ data }: GoalsProgressWidgetProps) {
const {
selectedBudget,
editOpen,
categories,
defaultPeriod,
handleEdit,
handleEditOpenChange,
} = useGoalsProgressWidgetController(data);
return (
<GoalsProgressWidgetView
data={data}
selectedBudget={selectedBudget}
editOpen={editOpen}
categories={categories}
defaultPeriod={defaultPeriod}
onEdit={handleEdit}
onEditOpenChange={handleEditOpenChange}
/>
);
}

View File

@@ -0,0 +1,270 @@
"use client";
import {
RiCheckboxCircleFill,
RiCheckLine,
RiDeleteBinLine,
} from "@remixicon/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import type { DashboardInboxSnapshot } from "@/features/dashboard/inbox-snapshot-queries";
import type { DashboardWidgetQuickActionOptions } from "@/features/dashboard/widget-registry/widget-config";
import {
discardInboxItemAction,
markInboxAsProcessedAction,
} from "@/features/inbox/actions";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { resolveLogoSrc } from "@/shared/lib/logo";
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
function relativeTime(date: Date): string {
const diff = Date.now() - date.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "agora";
if (minutes < 60) return `${minutes}min`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
type InboxWidgetProps = {
snapshot: DashboardInboxSnapshot;
quickActionOptions: DashboardWidgetQuickActionOptions;
};
function getDateString(date: Date | string | null | undefined): string | null {
if (!date) return null;
if (typeof date === "string") return date.slice(0, 10);
return date.toISOString().slice(0, 10);
}
export function InboxWidget({
snapshot,
quickActionOptions,
}: InboxWidgetProps) {
const router = useRouter();
const [processOpen, setProcessOpen] = useState(false);
const [discardOpen, setDiscardOpen] = useState(false);
const [itemToProcess, setItemToProcess] = useState<
DashboardInboxSnapshot["recentItems"][number] | null
>(null);
const [itemToDiscard, setItemToDiscard] = useState<
DashboardInboxSnapshot["recentItems"][number] | null
>(null);
const handleProcessOpenChange = (open: boolean) => {
setProcessOpen(open);
if (!open) setItemToProcess(null);
};
const handleDiscardOpenChange = (open: boolean) => {
setDiscardOpen(open);
if (!open) setItemToDiscard(null);
};
const handleProcessRequest = (
item: DashboardInboxSnapshot["recentItems"][number],
) => {
setItemToProcess(item);
setProcessOpen(true);
};
const handleDiscardRequest = (
item: DashboardInboxSnapshot["recentItems"][number],
) => {
setItemToDiscard(item);
setDiscardOpen(true);
};
const refreshWidget = () => {
router.refresh();
};
const handleDiscardConfirm = async () => {
if (!itemToDiscard) return;
const result = await discardInboxItemAction({
inboxItemId: itemToDiscard.id,
});
if (result.success) {
toast.success(result.message);
refreshWidget();
return;
}
toast.error(result.error);
throw new Error(result.error);
};
const handleLancamentoSuccess = async () => {
if (!itemToProcess) return;
const result = await markInboxAsProcessedAction({
inboxItemId: itemToProcess.id,
});
if (result.success) {
toast.success("Notificação processada!");
refreshWidget();
return;
}
toast.error(result.error);
};
const defaultPurchaseDate =
getDateString(itemToProcess?.notificationTimestamp) ?? null;
const defaultName = itemToProcess?.parsedName
? itemToProcess.parsedName
.toLowerCase()
.replace(/\b\w/g, (char) => char.toUpperCase())
: null;
const defaultAmount = itemToProcess?.parsedAmount
? String(Math.abs(Number(itemToProcess.parsedAmount)))
: null;
const matchedCardId = useMemo(() => {
const appName = itemToProcess?.sourceAppName?.toLowerCase();
if (!appName) return null;
for (const option of quickActionOptions.cardOptions) {
const label = option.label.toLowerCase();
if (label.includes(appName) || appName.includes(label)) {
return option.value;
}
}
return null;
}, [itemToProcess?.sourceAppName, quickActionOptions.cardOptions]);
if (snapshot.pendingCount === 0) {
return (
<WidgetEmptyState
icon={<RiCheckboxCircleFill color="green" className="size-6" />}
title="Tudo em dia"
description="Nenhum pré-lançamento aguardando revisão."
/>
);
}
return (
<div className="flex flex-col">
{snapshot.recentItems.map((item) => {
const displayName = item.parsedName ?? item.originalText.slice(0, 40);
const parsedAmount =
item.parsedAmount !== null
? Number.parseFloat(item.parsedAmount)
: null;
const amount =
parsedAmount !== null && Number.isFinite(parsedAmount)
? parsedAmount
: null;
const logoKey = item.sourceAppName?.toLowerCase() ?? "";
const rawLogo = snapshot.logoMap[logoKey] ?? null;
const logoSrc = resolveLogoSrc(rawLogo);
const displayLogo = logoSrc ?? DEFAULT_INBOX_APP_LOGO;
return (
<div
key={item.id}
className="flex items-center justify-between py-1.5"
>
<div className="flex flex-1 items-center gap-2">
<Image
src={displayLogo}
alt={item.sourceAppName ?? ""}
width={38}
height={38}
className="size-9.5 shrink-0 rounded-full object-contain"
unoptimized
/>
<div>
<p className="text-sm font-medium text-foreground">
{displayName.length > 30
? `${displayName.slice(0, 30)}...`
: displayName}
</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{item.sourceAppName && <span>{item.sourceAppName}</span>}
<span className="text-muted-foreground/60">
{relativeTime(item.createdAt)}
</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
{amount !== null && (
<MoneyValues className="font-medium" amount={amount} />
)}
<div className="flex items-center">
<Button
size="icon-sm"
variant="ghost"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => handleProcessRequest(item)}
aria-label="Processar notificação"
title="Processar"
>
<RiCheckLine className="size-3.5" />
</Button>
<Button
size="icon-sm"
variant="ghost"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={() => handleDiscardRequest(item)}
aria-label="Descartar notificação"
title="Descartar"
>
<RiDeleteBinLine className="size-3.5" />
</Button>
</div>
</div>
</div>
);
})}
<TransactionDialog
mode="create"
open={processOpen}
onOpenChange={handleProcessOpenChange}
payerOptions={quickActionOptions.payerOptions}
splitPayerOptions={quickActionOptions.splitPayerOptions}
defaultPayerId={quickActionOptions.defaultPayerId}
accountOptions={quickActionOptions.accountOptions}
cardOptions={quickActionOptions.cardOptions}
categoryOptions={quickActionOptions.categoryOptions}
estabelecimentos={quickActionOptions.estabelecimentos}
defaultPurchaseDate={defaultPurchaseDate}
defaultName={defaultName}
defaultAmount={defaultAmount}
defaultCardId={matchedCardId}
defaultPaymentMethod={matchedCardId ? "Cartão de crédito" : null}
defaultTransactionType="Despesa"
forceShowTransactionType
onSuccess={handleLancamentoSuccess}
/>
<ConfirmActionDialog
open={discardOpen}
onOpenChange={handleDiscardOpenChange}
title="Descartar notificação?"
description="A notificação será marcada como descartada e não aparecerá mais na lista de pendentes."
confirmLabel="Descartar"
confirmVariant="destructive"
pendingLabel="Descartando..."
onConfirm={handleDiscardConfirm}
/>
</div>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
import type { IncomeByCategoryData } from "@/features/dashboard/categories/income-by-category-queries";
import { CategoryBreakdownWidgetView } from "../category-breakdown/category-breakdown-widget-view";
type IncomeByCategoryWidgetWithChartProps = {
data: IncomeByCategoryData;
period: string;
};
export function IncomeByCategoryWidgetWithChart({
data,
period,
}: IncomeByCategoryWidgetWithChartProps) {
return (
<CategoryBreakdownWidgetView data={data} period={period} variant="income" />
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import { RiLineChartLine } from "@remixicon/react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import type { IncomeExpenseBalanceData } from "@/features/dashboard/overview/income-expense-balance-queries";
import { CardContent } from "@/shared/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
} from "@/shared/components/ui/chart";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { formatCurrency } from "@/shared/utils/currency";
type IncomeExpenseBalanceWidgetProps = {
data: IncomeExpenseBalanceData;
};
const chartConfig = {
receita: {
label: "Receita",
color: "var(--success)",
},
despesa: {
label: "Despesa",
color: "var(--destructive)",
},
balanco: {
label: "Balanço",
color: "var(--warning)",
},
} satisfies ChartConfig;
export function IncomeExpenseBalanceWidget({
data,
}: IncomeExpenseBalanceWidgetProps) {
const chartData = data.months.map((month) => ({
month: month.monthLabel,
receita: month.income,
despesa: month.expense,
balanco: month.balance,
}));
// Verifica se todos os valores são zero
const isEmpty = chartData.every(
(item) => item.receita === 0 && item.despesa === 0 && item.balanco === 0,
);
if (isEmpty) {
return (
<CardContent className="px-0">
<WidgetEmptyState
icon={<RiLineChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma movimentação financeira no período"
description="Registre receitas e despesas para visualizar o balanço mensal."
/>
</CardContent>
);
}
return (
<CardContent className="space-y-4 px-0">
<ChartContainer
config={chartConfig}
className="h-[270px] w-full aspect-auto"
>
<BarChart
data={chartData}
margin={{ top: 20, right: 10, left: 10, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<ChartTooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) {
return null;
}
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
{payload.map((entry) => {
const config =
chartConfig[entry.dataKey as keyof typeof chartConfig];
const value = entry.value as number;
return (
<div
key={String(entry.dataKey ?? entry.name)}
className="flex items-center gap-2"
>
<div
className="size-2 rounded-full"
style={{ backgroundColor: config?.color }}
/>
<span className="text-xs text-muted-foreground">
{config?.label}:
</span>
<span className="text-xs font-medium">
{formatCurrency(value)}
</span>
</div>
);
})}
</div>
</div>
);
}}
cursor={{ fill: "hsl(var(--muted))", opacity: 0.3 }}
/>
<Bar
dataKey="receita"
fill={chartConfig.receita.color}
radius={[4, 4, 0, 0]}
maxBarSize={60}
/>
<Bar
dataKey="despesa"
fill={chartConfig.despesa.color}
radius={[4, 4, 0, 0]}
maxBarSize={60}
/>
<Bar
dataKey="balanco"
fill={chartConfig.balanco.color}
radius={[4, 4, 0, 0]}
maxBarSize={60}
/>
</BarChart>
</ChartContainer>
<div className="flex items-center justify-center gap-6">
<div className="flex items-center gap-2">
<div
className="size-2 rounded-full"
style={{ backgroundColor: chartConfig.receita.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.receita.label}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="size-2 rounded-full"
style={{ backgroundColor: chartConfig.despesa.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.despesa.label}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="size-2 rounded-full"
style={{ backgroundColor: chartConfig.balanco.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.balanco.label}
</span>
</div>
</div>
</CardContent>
);
}

View File

@@ -0,0 +1,12 @@
import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries";
import { InstallmentExpensesWidgetView } from "../installment-expenses/installment-expenses-widget-view";
type InstallmentExpensesWidgetProps = {
data: InstallmentExpensesData;
};
export function InstallmentExpensesWidget({
data,
}: InstallmentExpensesWidgetProps) {
return <InstallmentExpensesWidgetView data={data} />;
}

View File

@@ -0,0 +1,35 @@
"use client";
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
import { useInvoicesWidgetController } from "@/features/dashboard/invoices/use-invoices-widget-controller";
import { InvoicesWidgetView } from "../invoices/invoices-widget-view";
type InvoicesWidgetProps = {
invoices: DashboardInvoice[];
};
export function InvoicesWidget({ invoices }: InvoicesWidgetProps) {
const {
items,
selectedInvoice,
isModalOpen,
modalState,
isPending,
openPaymentDialog,
closePaymentDialog,
confirmPayment,
} = useInvoicesWidgetController(invoices);
return (
<InvoicesWidgetView
invoices={items}
selectedInvoice={selectedInvoice}
isModalOpen={isModalOpen}
modalState={modalState}
isPending={isPending}
onOpenPaymentDialog={openPaymentDialog}
onClosePaymentDialog={closePaymentDialog}
onConfirmPayment={confirmPayment}
/>
);
}

View File

@@ -0,0 +1,222 @@
"use client";
import {
RiBarChartBoxLine,
RiExternalLinkLine,
RiEyeLine,
RiEyeOffLine,
} from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
import { useTransition } from "react";
import { toast } from "sonner";
import type { DashboardAccount } from "@/features/dashboard/accounts-queries";
import { updateMyAccountsWidgetPreference } from "@/features/dashboard/widget-registry/widget-actions";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { CardFooter } from "@/shared/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { formatPeriodForUrl } from "@/shared/utils/period";
type MyAccountsWidgetProps = {
accounts: DashboardAccount[];
showExcludedAccounts: boolean;
onShowExcludedAccountsChange?: (value: boolean) => void;
totalBalance: number;
period: string;
};
export function MyAccountsWidget({
accounts,
showExcludedAccounts,
onShowExcludedAccountsChange,
totalBalance,
period,
}: MyAccountsWidgetProps) {
const [isPending, startTransition] = useTransition();
const excludedAccountsCount = accounts.filter(
(account) => account.excludeFromBalance,
).length;
const visibleAccounts = showExcludedAccounts
? accounts
: accounts.filter((account) => !account.excludeFromBalance);
const displayedAccounts = visibleAccounts.slice(0, 5);
const remainingCount = visibleAccounts.length - displayedAccounts.length;
const hiddenExcludedAccountsCount = showExcludedAccounts
? 0
: excludedAccountsCount;
const toggleButtonLabel = showExcludedAccounts
? "Ocultar contas não consideradas"
: "Mostrar contas não consideradas";
const handleToggleExcludedAccounts = () => {
const nextShowExcludedAccounts = !showExcludedAccounts;
onShowExcludedAccountsChange?.(nextShowExcludedAccounts);
startTransition(async () => {
const result = await updateMyAccountsWidgetPreference({
showExcludedAccounts: nextShowExcludedAccounts,
});
if (!result.success) {
onShowExcludedAccountsChange?.(!nextShowExcludedAccounts);
toast.error(result.error ?? "Erro ao salvar preferência");
}
});
};
return (
<>
<div className="flex items-start justify-between gap-3 py-1">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Saldo Total</p>
<MoneyValues className="text-2xl font-medium" amount={totalBalance} />
</div>
{excludedAccountsCount > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={isPending}
className="mt-0.5 text-muted-foreground"
aria-label={toggleButtonLabel}
onClick={handleToggleExcludedAccounts}
>
{showExcludedAccounts ? (
<RiEyeOffLine className="size-4" aria-hidden />
) : (
<RiEyeLine className="size-4" aria-hidden />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs">
<p className="text-xs">{toggleButtonLabel}</p>
</TooltipContent>
</Tooltip>
) : null}
</div>
{hiddenExcludedAccountsCount > 0 ? (
<p className="pb-2 text-xs text-muted-foreground">
{hiddenExcludedAccountsCount}{" "}
{hiddenExcludedAccountsCount === 1
? "conta não considerada oculta"
: "contas não consideradas ocultas"}
</p>
) : null}
<div>
{accounts.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState
icon={
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
}
title="Você ainda não adicionou nenhuma conta"
description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações."
/>
</div>
) : displayedAccounts.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState
icon={<RiEyeOffLine className="size-6 text-muted-foreground" />}
title="As contas não consideradas estão ocultas"
description="Use o botão no topo do widget para mostrá-las novamente."
/>
</div>
) : (
<ul className="flex flex-col">
{displayedAccounts.map((account, index) => {
const logoSrc = resolveLogoSrc(account.logo);
return (
<div
key={account.id}
className="flex items-center justify-between transition-all duration-300 py-1.5 "
>
<div className="flex min-w-0 flex-1 items-center gap-2 py-1">
<div className="relative size-9.5 overflow-hidden">
{logoSrc ? (
<Image
src={logoSrc}
alt={`Logo da conta ${account.name}`}
fill
sizes="38px"
className="object-contain rounded-full"
priority={index === 0}
/>
) : null}
</div>
<div className="min-w-0">
<Link
prefetch
href={`/accounts/${
account.id
}/statement?periodo=${formatPeriodForUrl(period)}`}
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
>
<span className="truncate">{account.name}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
{account.excludeFromBalance ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help ml-2">
<Badge className="font-normal" variant="info">
Não considerada
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs">
Esta conta aparece na lista, mas não entra no
cálculo do saldo total porque está marcada para
desconsiderar do saldo total.
</p>
</TooltipContent>
</Tooltip>
) : null}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{account.accountType}</span>
</div>
</div>
</div>
<div className="flex flex-col items-end gap-0.5 text-right">
<MoneyValues
className="font-medium"
amount={account.balance}
/>
</div>
</div>
);
})}
</ul>
)}
</div>
{remainingCount > 0 ? (
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground">
+{remainingCount} contas não exibidas
</CardFooter>
) : null}
</>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import type { DashboardNote } from "@/features/dashboard/notes/notes-queries";
import { useNotesWidgetController } from "@/features/dashboard/notes/use-notes-widget-controller";
import { NotesWidgetView } from "../notes/notes-widget-view";
type NotesWidgetProps = {
notes: DashboardNote[];
};
export function NotesWidget({ notes }: NotesWidgetProps) {
const {
mappedNotes,
noteToEdit,
isEditOpen,
noteDetails,
isDetailsOpen,
openEdit,
openDetails,
handleEditOpenChange,
handleDetailsOpenChange,
} = useNotesWidgetController(notes);
return (
<NotesWidgetView
notes={mappedNotes}
noteToEdit={noteToEdit}
isEditOpen={isEditOpen}
noteDetails={noteDetails}
isDetailsOpen={isDetailsOpen}
onOpenEdit={openEdit}
onOpenDetails={openDetails}
onEditOpenChange={handleEditOpenChange}
onDetailsOpenChange={handleDetailsOpenChange}
/>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import {
RiExternalLinkLine,
RiGroupLine,
RiVerifiedBadgeFill,
} from "@remixicon/react";
import Link from "next/link";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import type { DashboardPagador } from "@/features/dashboard/payers-queries";
import MoneyValues from "@/shared/components/money-values";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/shared/components/ui/avatar";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { buildInitials } from "@/shared/utils/initials";
type PayersWidgetProps = {
payers: DashboardPagador[];
};
export function PayersWidget({ payers }: PayersWidgetProps) {
return (
<div className="flex flex-col">
{payers.length === 0 ? (
<WidgetEmptyState
icon={<RiGroupLine className="size-6 text-muted-foreground" />}
title="Nenhum pagador para o período"
description="Quando houver despesas associadas a pagadores, eles aparecerão aqui."
/>
) : (
<div className="flex flex-col">
{payers.map((payer) => {
const initials = buildInitials(payer.name);
const hasValidPercentageChange =
typeof payer.percentageChange === "number" &&
Number.isFinite(payer.percentageChange);
const percentageChange = hasValidPercentageChange
? payer.percentageChange
: null;
return (
<div
key={payer.id}
className="flex items-center justify-between transition-all duration-300 py-1.5"
>
<div className="flex min-w-0 flex-1 items-center gap-2 py-1">
<Avatar className="size-9.5 shrink-0">
<AvatarImage
src={getAvatarSrc(payer.avatarUrl)}
alt={`Avatar de ${payer.name}`}
/>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<Link
prefetch
href={`/payers/${payer.id}`}
className="inline-flex max-w-full items-center gap-1 text-sm text-foreground underline-offset-2 hover:text-primary hover:underline"
>
<span className="truncate font-medium">{payer.name}</span>
{payer.isAdmin && (
<RiVerifiedBadgeFill
className="size-4 shrink-0 text-blue-500"
aria-hidden
/>
)}
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
<p className="truncate text-xs text-muted-foreground">
{payer.email ?? "Sem email cadastrado"}
</p>
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues
className="font-medium"
amount={payer.totalExpenses}
/>
<PercentageChangeIndicator value={percentageChange} />
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
import { usePaymentOverviewWidgetController } from "@/features/dashboard/payments/use-payment-overview-widget-controller";
import { PaymentOverviewWidgetView } from "../payment-overview/payment-overview-widget-view";
type PaymentOverviewWidgetProps = {
paymentConditionsData: PaymentConditionsData;
paymentMethodsData: PaymentMethodsData;
period: string;
adminPayerSlug: string | null;
};
export function PaymentOverviewWidget({
paymentConditionsData,
paymentMethodsData,
period,
adminPayerSlug,
}: PaymentOverviewWidgetProps) {
const { activeTab, handleTabChange } = usePaymentOverviewWidgetController();
return (
<PaymentOverviewWidgetView
activeTab={activeTab}
paymentConditionsData={paymentConditionsData}
paymentMethodsData={paymentMethodsData}
onTabChange={handleTabChange}
period={period}
adminPayerSlug={adminPayerSlug}
/>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
import { PaymentStatusWidgetView } from "../payment-status/payment-status-widget-view";
type PaymentStatusWidgetProps = {
data: PaymentStatusData;
};
export function PaymentStatusWidget({ data }: PaymentStatusWidgetProps) {
return <PaymentStatusWidgetView data={data} />;
}

View File

@@ -0,0 +1,193 @@
"use client";
import { RiArrowDownSFill, RiStore3Line } from "@remixicon/react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { PurchasesByCategoryData } from "@/features/dashboard/categories/purchases-by-category-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { CATEGORY_TYPE_LABEL } from "@/shared/lib/categories/constants";
import { formatTransactionDate } from "@/shared/utils/date";
type PurchasesByCategoryWidgetProps = {
data: PurchasesByCategoryData;
};
const STORAGE_KEY = "purchases-by-category-selected";
export function PurchasesByCategoryWidget({
data,
}: PurchasesByCategoryWidgetProps) {
const firstCategoryId = data.categories[0]?.id ?? "";
const hasRestoredSelectionRef = useRef(false);
const hasPersistedSelectionRef = useRef(false);
const [selectedCategoryId, setSelectedCategoryId] =
useState<string>(firstCategoryId);
// Agrupa categorias por tipo
const categoriesByType = useMemo(() => {
const grouped: Record<string, typeof data.categories> = {};
for (const category of data.categories) {
if (!grouped[category.type]) {
grouped[category.type] = [];
}
const typeGroup = grouped[category.type];
if (typeGroup) {
typeGroup.push(category);
}
}
return grouped;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.categories]);
// Restaura a categoria salva apenas depois da montagem para manter SSR e cliente consistentes.
useEffect(() => {
if (hasRestoredSelectionRef.current) {
return;
}
hasRestoredSelectionRef.current = true;
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved && data.categories.some((cat) => cat.id === saved)) {
setSelectedCategoryId(saved);
return;
}
setSelectedCategoryId(firstCategoryId);
}, [data.categories, firstCategoryId]);
// Salva a categoria selecionada quando mudar, sem sobrescrever o valor salvo na primeira montagem.
useEffect(() => {
if (!hasPersistedSelectionRef.current) {
hasPersistedSelectionRef.current = true;
return;
}
if (selectedCategoryId) {
sessionStorage.setItem(STORAGE_KEY, selectedCategoryId);
return;
}
sessionStorage.removeItem(STORAGE_KEY);
}, [selectedCategoryId]);
// Atualiza a categoria selecionada se ela não existir mais na lista
useEffect(() => {
if (!selectedCategoryId && firstCategoryId) {
setSelectedCategoryId(firstCategoryId);
return;
}
if (
selectedCategoryId &&
!data.categories.some((cat) => cat.id === selectedCategoryId)
) {
setSelectedCategoryId(firstCategoryId);
}
}, [data.categories, firstCategoryId, selectedCategoryId]);
const currentTransactions = useMemo(() => {
if (!selectedCategoryId) {
return [];
}
return data.transactionsByCategory[selectedCategoryId] ?? [];
}, [selectedCategoryId, data.transactionsByCategory]);
const selectedCategory = useMemo(() => {
return data.categories.find((cat) => cat.id === selectedCategoryId);
}, [data.categories, selectedCategoryId]);
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiStore3Line className="size-6 text-muted-foreground" />}
title="Nenhuma categoria encontrada"
description="Crie categorias de despesas ou receitas para visualizar as compras."
/>
);
}
return (
<div className="flex flex-col gap-4 px-0">
<div className="flex items-center gap-3">
<Select
value={selectedCategoryId}
onValueChange={setSelectedCategoryId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Selecione uma categoria" />
</SelectTrigger>
<SelectContent>
{Object.entries(categoriesByType).map(([type, categories]) => (
<div key={type}>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{CATEGORY_TYPE_LABEL[
type as keyof typeof CATEGORY_TYPE_LABEL
] ?? type}
</div>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</div>
{currentTransactions.length === 0 ? (
<WidgetEmptyState
icon={<RiArrowDownSFill className="size-6 text-muted-foreground" />}
title="Nenhuma compra encontrada"
description={
selectedCategory
? `Não há lançamentos na categoria "${selectedCategory.name}".`
: "Selecione uma categoria para visualizar as compras."
}
/>
) : (
<div className="flex flex-col">
{currentTransactions.map((transaction) => {
return (
<div
key={transaction.id}
className="flex items-center justify-between gap-3 transition-all duration-300 py-2"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<EstablishmentLogo name={transaction.name} size={37} />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{transaction.name}
</p>
<p className="text-xs text-muted-foreground">
{formatTransactionDate(transaction.purchaseDate)}
</p>
</div>
</div>
<div className="shrink-0 text-foreground">
<MoneyValues
className="font-medium"
amount={transaction.amount}
/>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { RiRefreshLine } from "@remixicon/react";
import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
type RecurringExpensesWidgetProps = {
data: RecurringExpensesData;
};
const formatOccurrences = (value: number | null) => {
if (!value) {
return "Recorrência contínua";
}
return `${value} recorrências`;
};
export function RecurringExpensesWidget({
data,
}: RecurringExpensesWidgetProps) {
if (data.expenses.length === 0) {
return (
<WidgetEmptyState
icon={<RiRefreshLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa recorrente"
description="Lançamentos recorrentes aparecerão aqui conforme forem registrados."
/>
);
}
return (
<div className="flex flex-col">
{data.expenses.map((expense) => {
return (
<div
key={expense.id}
className="flex items-center gap-2 transition-all duration-300 py-1.5"
>
<EstablishmentLogo name={expense.name} size={37} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="truncate text-foreground text-sm font-medium">
{expense.name}
</p>
<MoneyValues className="font-medium" amount={expense.amount} />
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
{expense.paymentMethod}
</span>
<span>{formatOccurrences(expense.recurrenceCount)}</span>
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { ReactNode } from "react";
import { cn } from "@/shared/utils";
type SortableWidgetProps = {
id: string;
children: ReactNode;
isEditing: boolean;
};
export function SortableWidget({
id,
children,
isEditing,
}: SortableWidgetProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled: !isEditing });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"relative",
isDragging && "z-50 opacity-90",
isEditing &&
"cursor-grab active:cursor-grabbing touch-none select-none",
)}
{...(isEditing ? { ...attributes, ...listeners } : {})}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import { RiArrowUpDoubleLine, RiStore2Line } from "@remixicon/react";
import { useState } from "react";
import type { TopExpensesData } from "@/features/dashboard/expenses/top-expenses-queries";
import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/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 data-[state=active]:bg-transparent"
>
<RiArrowUpDoubleLine className="mr-1 size-3.5" />
Top gastos
</TabsTrigger>
<TabsTrigger
value="establishments"
className="text-xs data-[state=active]:bg-transparent"
>
<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

@@ -0,0 +1,63 @@
import { RiStore2Line } from "@remixicon/react";
import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
type TopEstablishmentsWidgetProps = {
data: TopEstablishmentsData;
};
const formatOccurrencesLabel = (occurrences: number) => {
if (occurrences === 1) {
return "1 lançamento";
}
return `${occurrences} lançamentos`;
};
export function TopEstablishmentsWidget({
data,
}: TopEstablishmentsWidgetProps) {
return (
<div className="flex flex-col px-0">
{data.establishments.length === 0 ? (
<WidgetEmptyState
icon={<RiStore2Line className="size-6 text-muted-foreground" />}
title="Nenhum estabelecimento encontrado"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
) : (
<div className="flex flex-col">
{data.establishments.map((establishment) => {
return (
<div
key={establishment.id}
className="flex items-center justify-between gap-3 transition-all duration-300 py-2"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<EstablishmentLogo name={establishment.name} size={37} />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{establishment.name}
</p>
<p className="text-xs text-muted-foreground">
{formatOccurrencesLabel(establishment.occurrences)}
</p>
</div>
</div>
<div className="shrink-0 text-foreground">
<MoneyValues
className="font-medium"
amount={establishment.amount}
/>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { RiArrowUpDoubleLine } from "@remixicon/react";
import { useMemo, useState } from "react";
import type {
TopExpense,
TopExpensesData,
} from "@/features/dashboard/expenses/top-expenses-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { Switch } from "@/shared/components/ui/switch";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { formatTransactionDate } from "@/shared/utils/date";
type TopExpensesWidgetProps = {
allExpenses: TopExpensesData;
cardOnlyExpenses: TopExpensesData;
};
const shouldIncludeExpense = (expense: TopExpense) => {
const normalizedName = expense.name.trim().toLowerCase();
if (normalizedName === "saldo inicial") {
return false;
}
if (normalizedName.includes("fatura")) {
return false;
}
return true;
};
const isCardExpense = (expense: TopExpense) =>
expense.paymentMethod?.toLowerCase().includes("cartão") ?? false;
export function TopExpensesWidget({
allExpenses,
cardOnlyExpenses,
}: TopExpensesWidgetProps) {
const [cardOnly, setCardOnly] = useState(false);
const normalizedAllExpenses = useMemo(() => {
return allExpenses.expenses.filter(shouldIncludeExpense);
}, [allExpenses]);
const normalizedCardOnlyExpenses = useMemo(() => {
const merged = [...cardOnlyExpenses.expenses, ...normalizedAllExpenses];
const seen = new Set<string>();
return merged.filter((expense) => {
if (seen.has(expense.id)) {
return false;
}
if (!isCardExpense(expense) || !shouldIncludeExpense(expense)) {
return false;
}
seen.add(expense.id);
return true;
});
}, [cardOnlyExpenses, normalizedAllExpenses]);
const data = cardOnly
? { expenses: normalizedCardOnlyExpenses }
: { expenses: normalizedAllExpenses };
return (
<div className="flex flex-col gap-4 px-0">
<div className="flex items-center justify-between gap-3">
<label
htmlFor="card-only-toggle"
className="text-sm text-muted-foreground"
>
Apenas cartões
</label>
<Switch
id="card-only-toggle"
checked={cardOnly}
onCheckedChange={setCardOnly}
/>
</div>
{data.expenses.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState
icon={
<RiArrowUpDoubleLine className="size-6 text-muted-foreground" />
}
title="Nenhuma despesa encontrada"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
</div>
) : (
<div className="flex flex-col">
{data.expenses.map((expense) => {
return (
<div
key={expense.id}
className="flex items-center justify-between gap-3 transition-all duration-300 py-2"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<EstablishmentLogo name={expense.name} size={37} />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{expense.name}
</p>
<p className="text-xs text-muted-foreground">
{formatTransactionDate(expense.purchaseDate)}
</p>
</div>
</div>
<div className="shrink-0 text-foreground">
<MoneyValues
className="font-medium"
amount={expense.amount}
/>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import { RiRefreshLine, RiSettings4Line } from "@remixicon/react";
import { useState } from "react";
import { widgetsConfig } from "@/features/dashboard/widget-registry/widget-config";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Switch } from "@/shared/components/ui/switch";
import { cn } from "@/shared/utils";
type WidgetSettingsDialogProps = {
hiddenWidgets: string[];
onToggleWidget: (widgetId: string) => void;
onReset: () => void;
triggerClassName?: string;
};
export function WidgetSettingsDialog({
hiddenWidgets,
onToggleWidget,
onReset,
triggerClassName,
}: WidgetSettingsDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn("gap-2", triggerClassName)}
>
<RiSettings4Line className="size-4" />
Widgets
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Configurar Widgets</DialogTitle>
<DialogDescription>
Escolha quais widgets deseja exibir no seu dashboard.
</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] overflow-y-auto py-4">
<div className="space-y-3">
{widgetsConfig.map((widget) => {
const isVisible = !hiddenWidgets.includes(widget.id);
return (
<div
key={widget.id}
className="flex items-center justify-between gap-4 rounded-lg border p-3"
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-primary shrink-0">{widget.icon}</span>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{widget.title}
</p>
<p className="text-xs text-muted-foreground truncate">
{widget.subtitle}
</p>
</div>
</div>
<Switch
checked={isVisible}
onCheckedChange={() => onToggleWidget(widget.id)}
/>
</div>
);
})}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={onReset}
className="gap-2"
>
<RiRefreshLine className="size-4" />
Restaurar Padrão
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}