feat(dashboard): novos widgets de anexos, inbox e tendências de categoria

- Widget Anexos: resumo de arquivos do período (total, imagens, PDFs, recentes)
- Widget Inbox: snapshot de pré-lançamentos pendentes do Companion
- Widget Tendências de Categoria: redireciona para relatório de tendências
- fetch-dashboard-data: busca attachmentsSnapshot e inboxSnapshot em paralelo
- widgets-config: tipo DashboardWidgetQuickActionOptions centralizado; props
  adminPayerSlug e quickActionOptions adicionadas ao contrato do widget
- dashboard-grid-editable: usa o novo tipo unificado de quickActionOptions
- proxy.ts: frame-src adicionado à CSP para preview de PDFs via S3
- rota /attachments criada com layout próprio

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-04-11 17:51:09 +00:00
parent dfb4126b12
commit 7a3bff52ac
9 changed files with 766 additions and 94 deletions

View File

@@ -0,0 +1,26 @@
import { RiAttachmentLine } from "@remixicon/react";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Anexos",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6">
<PageDescription
icon={<RiAttachmentLine />}
title="Anexos"
subtitle="Gerencie os anexos das suas transações"
/>
<MonthNavigation />
{children}
</section>
);
}

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,84 @@
"use client";
import {
RiArrowDownSFill,
RiArrowUpSFill,
RiLineChartLine,
} from "@remixicon/react";
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown";
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { cn } from "@/shared/utils/ui";
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, 6);
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;
const isUp = change > 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>
<span
className={cn(
"inline-flex shrink-0 items-center gap-0.5 font-semibold text-sm",
isUp ? " text-destructive" : " text-success",
)}
>
{isUp ? (
<RiArrowUpSFill className="size-3.5" />
) : (
<RiArrowDownSFill className="size-3.5" />
)}
{Math.abs(change).toFixed(0)}%
</span>
</div>
</li>
);
})}
</ul>
);
}

View File

@@ -34,12 +34,12 @@ import {
type WidgetPreferences,
} from "@/features/dashboard/widgets/actions";
import {
type DashboardWidgetQuickActionOptions,
type WidgetConfig,
widgetsConfig,
} from "@/features/dashboard/widgets/widgets-config";
import { NoteDialog } from "@/features/notes/components/note-dialog";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import type { SelectOption } from "@/features/transactions/components/types";
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
import { Button } from "@/shared/components/ui/button";
@@ -47,15 +47,7 @@ type DashboardGridEditableProps = {
data: DashboardData;
period: string;
initialPreferences: WidgetPreferences | null;
quickActionOptions: {
payerOptions: SelectOption[];
splitPayerOptions: SelectOption[];
defaultPayerId: string | null;
accountOptions: SelectOption[];
cardOptions: SelectOption[];
categoryOptions: SelectOption[];
estabelecimentos: string[];
};
quickActionOptions: DashboardWidgetQuickActionOptions;
};
const DEFAULT_WIDGET_ORDER = widgetsConfig.map((widget) => widget.id);
@@ -368,11 +360,16 @@ export function DashboardGridEditable({
{widget.component({
data,
period,
adminPayerSlug:
quickActionOptions.payerOptions.find(
(p) => p.value === quickActionOptions.defaultPayerId,
)?.slug ?? null,
widgetPreferences: {
order: widgetOrder,
hidden: hiddenWidgets,
myAccountsShowExcluded,
},
quickActionOptions,
onMyAccountsShowExcludedChange: setMyAccountsShowExcluded,
})}
</ExpandableWidgetCard>

View File

@@ -0,0 +1,267 @@
"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/widgets/widgets-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="space-y-1">
{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="-mx-2 rounded-md p-2">
<div className="flex items-center gap-2">
<Image
src={displayLogo}
alt={item.sourceAppName ?? ""}
width={36}
height={36}
className="size-9 shrink-0 rounded-full object-contain"
unoptimized
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{displayName}
</p>
<div className="flex items-center gap-2">
{item.sourceAppName && (
<span className="text-xs text-muted-foreground">
{item.sourceAppName}
</span>
)}
<span className="text-xs text-muted-foreground/60">
{relativeTime(item.createdAt)}
</span>
<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>
{amount !== null && (
<MoneyValues className="font-medium" amount={amount} />
)}
</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

@@ -1,7 +1,9 @@
import { cacheLife, cacheTag } from "next/cache";
import { fetchAttachmentsForPeriod } from "@/features/attachments/queries";
import { fetchDashboardAccounts } from "./accounts-queries";
import { fetchDashboardCategoryOverview } from "./category-overview-queries";
import { fetchDashboardCurrentPeriodOverview } from "./current-period-overview-queries";
import { fetchDashboardInboxSnapshot } from "./inbox-snapshot-queries";
import { fetchDashboardInvoices } from "./invoices-queries";
import { fetchDashboardNotes } from "./notes-queries";
import { fetchDashboardPayers } from "./payers-queries";
@@ -16,6 +18,8 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
categoryOverview,
pagadoresSnapshot,
notesData,
allAttachments,
inboxSnapshot,
] = await Promise.all([
fetchDashboardPeriodOverview(userId, period),
fetchDashboardAccounts(userId),
@@ -24,8 +28,27 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
fetchDashboardCategoryOverview(userId, period),
fetchDashboardPayers(userId, period),
fetchDashboardNotes(userId),
fetchAttachmentsForPeriod(userId, period),
fetchDashboardInboxSnapshot(userId),
]);
const attachmentsSnapshot = allAttachments.reduce(
(acc, attachment, index) => {
acc.totalBytes += attachment.fileSize;
if (attachment.mimeType.startsWith("image/")) acc.imageCount++;
if (attachment.mimeType === "application/pdf") acc.pdfCount++;
if (index < 5) acc.recentAttachments.push(attachment);
return acc;
},
{
totalCount: allAttachments.length,
totalBytes: 0,
imageCount: 0,
pdfCount: 0,
recentAttachments: [] as typeof allAttachments,
},
);
return {
metrics: periodOverview.metrics,
accountsSnapshot,
@@ -46,6 +69,8 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
purchasesByCategoryData: currentPeriodOverview.purchasesByCategoryData,
incomeByCategoryData: categoryOverview.incomeByCategoryData,
expensesByCategoryData: categoryOverview.expensesByCategoryData,
attachmentsSnapshot,
inboxSnapshot,
};
}

View File

@@ -0,0 +1,74 @@
import { and, count, desc, eq } from "drizzle-orm";
import { cacheLife, cacheTag } from "next/cache";
import { cards, financialAccounts, inboxItems } from "@/db/schema";
import { db } from "@/shared/lib/db";
export type DashboardInboxItem = {
id: string;
sourceAppName: string | null;
parsedName: string | null;
parsedAmount: string | null;
originalText: string;
notificationTimestamp: Date;
createdAt: Date;
};
export type DashboardInboxSnapshot = {
pendingCount: number;
recentItems: DashboardInboxItem[];
logoMap: Record<string, string>;
};
export async function fetchDashboardInboxSnapshot(
userId: string,
): Promise<DashboardInboxSnapshot> {
"use cache";
cacheTag(`dashboard-${userId}`);
cacheLife({ revalidate: 3 });
const [countRows, items, userCards, userAccounts] = await Promise.all([
db
.select({ total: count() })
.from(inboxItems)
.where(
and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")),
),
db
.select({
id: inboxItems.id,
sourceAppName: inboxItems.sourceAppName,
parsedName: inboxItems.parsedName,
parsedAmount: inboxItems.parsedAmount,
originalText: inboxItems.originalText,
notificationTimestamp: inboxItems.notificationTimestamp,
createdAt: inboxItems.createdAt,
})
.from(inboxItems)
.where(
and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")),
)
.orderBy(desc(inboxItems.notificationTimestamp))
.limit(10),
db
.select({ name: cards.name, logo: cards.logo })
.from(cards)
.where(eq(cards.userId, userId)),
db
.select({ name: financialAccounts.name, logo: financialAccounts.logo })
.from(financialAccounts)
.where(eq(financialAccounts.userId, userId)),
]);
const logoMap: Record<string, string> = {};
for (const item of [...userCards, ...userAccounts]) {
if (item.logo) {
logoMap[item.name.toLowerCase()] = item.logo;
}
}
return {
pendingCount: Number(countRows[0]?.total ?? 0),
recentItems: items,
logoMap,
};
}

View File

@@ -1,6 +1,8 @@
import {
RiArrowRightLine,
RiArrowUpDoubleLine,
RiAtLine,
RiAttachmentLine,
RiBarChartBoxLine,
RiBarcodeLine,
RiBillLine,
@@ -16,9 +18,12 @@ import {
} from "@remixicon/react";
import Link from "next/link";
import type { ReactNode } from "react";
import { AttachmentsWidget } from "@/features/dashboard/components/attachments-widget";
import { BillWidget } from "@/features/dashboard/components/bill-widget";
import { CategoryTrendsWidget } from "@/features/dashboard/components/category-trends-widget";
import { ExpensesByCategoryWidgetWithChart } from "@/features/dashboard/components/expenses-by-category-widget-with-chart";
import { GoalsProgressWidget } from "@/features/dashboard/components/goals-progress-widget";
import { InboxWidget } from "@/features/dashboard/components/inbox-widget";
import { IncomeByCategoryWidgetWithChart } from "@/features/dashboard/components/income-by-category-widget-with-chart";
import { IncomeExpenseBalanceWidget } from "@/features/dashboard/components/income-expense-balance-widget";
import { InstallmentExpensesWidget } from "@/features/dashboard/components/installment-expenses-widget";
@@ -32,8 +37,19 @@ import { PurchasesByCategoryWidget } from "@/features/dashboard/components/purch
import { RecurringExpensesWidget } from "@/features/dashboard/components/recurring-expenses-widget";
import { SpendingOverviewWidget } from "@/features/dashboard/components/spending-overview-widget";
import type { WidgetPreferences } from "@/features/dashboard/widgets/actions";
import type { SelectOption } from "@/features/transactions/components/types";
import type { DashboardData } from "../fetch-dashboard-data";
export type DashboardWidgetQuickActionOptions = {
payerOptions: SelectOption[];
splitPayerOptions: SelectOption[];
defaultPayerId: string | null;
accountOptions: SelectOption[];
cardOptions: SelectOption[];
categoryOptions: SelectOption[];
estabelecimentos: string[];
};
export type WidgetConfig = {
id: string;
title: string;
@@ -42,7 +58,9 @@ export type WidgetConfig = {
component: (props: {
data: DashboardData;
period: string;
adminPayerSlug: string | null;
widgetPreferences: WidgetPreferences;
quickActionOptions: DashboardWidgetQuickActionOptions;
onMyAccountsShowExcludedChange?: (value: boolean) => void;
}) => ReactNode;
action?: ReactNode;
@@ -88,21 +106,149 @@ export const widgetsConfig: WidgetConfig[] = [
{
id: "payment-status",
title: "Status de Pagamento",
subtitle: "Valores Confirmados E Pendentes",
subtitle: "Valores confirmados e pendentes",
icon: <RiWallet3Line className="size-4" />,
component: ({ data }) => (
<PaymentStatusWidget data={data.paymentStatusData} />
),
},
{
id: "inbox",
title: "Pré-lançamentos",
subtitle: "Notificações pendentes de revisão",
icon: <RiAtLine className="size-4" />,
component: ({ data, quickActionOptions }) => (
<InboxWidget
snapshot={data.inboxSnapshot}
quickActionOptions={quickActionOptions}
/>
),
action: (
<Link
href="/inbox"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Revisar
<RiArrowRightLine className="size-4" />
</Link>
),
},
{
id: "income-expense-balance",
title: "Receita, Despesa e Balanço",
subtitle: "Últimos 6 Meses",
subtitle: "Últimos 6 meses",
icon: <RiLineChartLine className="size-4" />,
component: ({ data }) => (
<IncomeExpenseBalanceWidget data={data.incomeExpenseBalanceData} />
),
},
{
id: "goals-progress",
title: "Progresso de Orçamentos",
subtitle: "Orçamentos por categoria no período",
icon: <RiExchangeLine className="size-4" />,
component: ({ data }) => (
<GoalsProgressWidget data={data.goalsProgressData} />
),
action: (
<Link
href="/budgets"
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: "category-trends",
title: "Tendências de Categorias",
subtitle: "Top 6 maiores variações vs. mês anterior",
icon: <RiLineChartLine className="size-4" />,
component: ({ data }) => (
<CategoryTrendsWidget
categories={data.expensesByCategoryData.categories}
/>
),
},
{
id: "spending-overview",
title: "Panorama de Gastos",
subtitle: "Principais despesas e frequência por local",
icon: <RiArrowUpDoubleLine className="size-4" />,
component: ({ data }) => (
<SpendingOverviewWidget
topExpensesAll={data.topExpensesAll}
topExpensesCardOnly={data.topExpensesCardOnly}
topEstablishmentsData={data.topEstablishmentsData}
/>
),
},
{
id: "payment-overview",
title: "Comportamento de Pagamento",
subtitle: "Despesas por condição e forma de pagamento",
icon: <RiWallet3Line className="size-4" />,
component: ({ data, period, adminPayerSlug }) => (
<PaymentOverviewWidget
paymentConditionsData={data.paymentConditionsData}
paymentMethodsData={data.paymentMethodsData}
period={period}
adminPayerSlug={adminPayerSlug}
/>
),
},
{
id: "expenses-by-category",
title: "Categorias por Despesas",
subtitle: "Distribuição de despesas por categoria",
icon: <RiPieChartLine className="size-4" />,
component: ({ data, period }) => (
<ExpensesByCategoryWidgetWithChart
data={data.expensesByCategoryData}
period={period}
/>
),
},
{
id: "income-by-category",
title: "Categorias por Receitas",
subtitle: "Distribuição de receitas por categoria",
icon: <RiPieChartLine className="size-4" />,
component: ({ data, period }) => (
<IncomeByCategoryWidgetWithChart
data={data.incomeByCategoryData}
period={period}
/>
),
},
{
id: "purchases-by-category",
title: "Lançamentos por Categorias",
subtitle: "Distribuição de lançamentos por categoria",
icon: <RiStore3Line className="size-4" />,
component: ({ data }) => (
<PurchasesByCategoryWidget data={data.purchasesByCategoryData} />
),
},
{
id: "recurring-expenses",
title: "Lançamentos Recorrentes",
subtitle: "Despesas recorrentes do período",
icon: <RiRefreshLine className="size-4" />,
component: ({ data }) => (
<RecurringExpensesWidget data={data.recurringExpensesData} />
),
},
{
id: "installment-expenses",
title: "Lançamentos Parcelados",
subtitle: "Acompanhe as parcelas abertas",
icon: <RiNumbersLine className="size-4" />,
component: ({ data }) => (
<InstallmentExpensesWidget data={data.installmentExpensesData} />
),
},
{
id: "pagadores",
title: "Pagadores",
@@ -138,16 +284,16 @@ export const widgetsConfig: WidgetConfig[] = [
),
},
{
id: "goals-progress",
title: "Progresso de Orçamentos",
subtitle: "Orçamentos por categoria no período",
icon: <RiExchangeLine className="size-4" />,
id: "attachments",
title: "Anexos",
subtitle: "Comprovantes do período",
icon: <RiAttachmentLine className="size-4" />,
component: ({ data }) => (
<GoalsProgressWidget data={data.goalsProgressData} />
<AttachmentsWidget snapshot={data.attachmentsSnapshot} />
),
action: (
<Link
href="/budgets"
href="/attachments"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Ver todos
@@ -155,80 +301,4 @@ export const widgetsConfig: WidgetConfig[] = [
</Link>
),
},
{
id: "payment-overview",
title: "Comportamento de Pagamento",
subtitle: "Despesas por condição e forma de pagamento",
icon: <RiWallet3Line className="size-4" />,
component: ({ data }) => (
<PaymentOverviewWidget
paymentConditionsData={data.paymentConditionsData}
paymentMethodsData={data.paymentMethodsData}
/>
),
},
{
id: "recurring-expenses",
title: "Lançamentos Recorrentes",
subtitle: "Despesas recorrentes do período",
icon: <RiRefreshLine className="size-4" />,
component: ({ data }) => (
<RecurringExpensesWidget data={data.recurringExpensesData} />
),
},
{
id: "installment-expenses",
title: "Lançamentos Parcelados",
subtitle: "Acompanhe as parcelas abertas",
icon: <RiNumbersLine className="size-4" />,
component: ({ data }) => (
<InstallmentExpensesWidget data={data.installmentExpensesData} />
),
},
{
id: "spending-overview",
title: "Panorama de Gastos",
subtitle: "Principais despesas e frequência por local",
icon: <RiArrowUpDoubleLine className="size-4" />,
component: ({ data }) => (
<SpendingOverviewWidget
topExpensesAll={data.topExpensesAll}
topExpensesCardOnly={data.topExpensesCardOnly}
topEstablishmentsData={data.topEstablishmentsData}
/>
),
},
{
id: "purchases-by-category",
title: "Lançamentos por Categorias",
subtitle: "Distribuição de lançamentos por categoria",
icon: <RiStore3Line className="size-4" />,
component: ({ data }) => (
<PurchasesByCategoryWidget data={data.purchasesByCategoryData} />
),
},
{
id: "income-by-category",
title: "Categorias por Receitas",
subtitle: "Distribuição de receitas por categoria",
icon: <RiPieChartLine className="size-4" />,
component: ({ data, period }) => (
<IncomeByCategoryWidgetWithChart
data={data.incomeByCategoryData}
period={period}
/>
),
},
{
id: "expenses-by-category",
title: "Categorias por Despesas",
subtitle: "Distribuição de despesas por categoria",
icon: <RiPieChartLine className="size-4" />,
component: ({ data, period }) => (
<ExpensesByCategoryWidgetWithChart
data={data.expensesByCategoryData}
period={period}
/>
),
},
];

View File

@@ -49,6 +49,7 @@ function buildCsp(): string {
`img-src 'self' ${imgExtras} data: blob:`,
"font-src 'self'",
`connect-src 'self' ${connectExtras}`,
`frame-src 'self'${s3Origin ? ` ${s3Origin}` : ""}`,
"frame-ancestors 'none'",
].join("; ");
}