From 7a3bff52aca44f59822937a5b7a4c9caef64c049 Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Sat, 11 Apr 2026 17:51:09 +0000 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20novos=20widgets=20de=20anexo?= =?UTF-8?q?s,=20inbox=20e=20tend=C3=AAncias=20de=20categoria?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/(dashboard)/attachments/layout.tsx | 26 ++ .../components/attachments-widget.tsx | 128 +++++++++ .../components/category-trends-widget.tsx | 84 ++++++ .../components/dashboard-grid-editable.tsx | 17 +- .../dashboard/components/inbox-widget.tsx | 267 ++++++++++++++++++ .../dashboard/fetch-dashboard-data.ts | 25 ++ .../dashboard/inbox-snapshot-queries.ts | 74 +++++ .../dashboard/widgets/widgets-config.tsx | 238 ++++++++++------ src/proxy.ts | 1 + 9 files changed, 766 insertions(+), 94 deletions(-) create mode 100644 src/app/(dashboard)/attachments/layout.tsx create mode 100644 src/features/dashboard/components/attachments-widget.tsx create mode 100644 src/features/dashboard/components/category-trends-widget.tsx create mode 100644 src/features/dashboard/components/inbox-widget.tsx create mode 100644 src/features/dashboard/inbox-snapshot-queries.ts diff --git a/src/app/(dashboard)/attachments/layout.tsx b/src/app/(dashboard)/attachments/layout.tsx new file mode 100644 index 0000000..b2c6ae7 --- /dev/null +++ b/src/app/(dashboard)/attachments/layout.tsx @@ -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 ( +
+ } + title="Anexos" + subtitle="Gerencie os anexos das suas transações" + /> + + + {children} +
+ ); +} diff --git a/src/features/dashboard/components/attachments-widget.tsx b/src/features/dashboard/components/attachments-widget.tsx new file mode 100644 index 0000000..e78731c --- /dev/null +++ b/src/features/dashboard/components/attachments-widget.tsx @@ -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 ( + } + title="Nenhum anexo no período" + description="Adicione comprovantes nos seus lançamentos para vê-los aqui." + /> + ); + } + + return ( + <> +
+ + + {snapshot.totalCount} {snapshot.totalCount === 1 ? "anexo" : "anexos"} + + + {formatBytes(snapshot.totalBytes)} + + {snapshot.imageCount > 0 && ( + + + {snapshot.imageCount} + + )} + {snapshot.pdfCount > 0 && ( + + + {snapshot.pdfCount} + + )} +
+ +
    + {snapshot.recentAttachments.map((attachment, index) => { + const isPdf = attachment.mimeType === "application/pdf"; + const isImage = attachment.mimeType.startsWith("image/"); + + return ( +
  • + +
  • + ); + })} +
+ + setSelectedIndex(-1)} + /> + + ); +} diff --git a/src/features/dashboard/components/category-trends-widget.tsx b/src/features/dashboard/components/category-trends-widget.tsx new file mode 100644 index 0000000..4459ef5 --- /dev/null +++ b/src/features/dashboard/components/category-trends-widget.tsx @@ -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 ( + } + title="Dados insuficientes" + description="As variações aparecem após lançamentos em dois meses consecutivos." + /> + ); + } + + return ( +
    + {trending.map((category) => { + const change = category.percentageChange ?? 0; + const isUp = change > 0; + + return ( +
  • +
    + +
    +

    + {category.categoryName} +

    +

    + vs{" "} + +

    +
    + + {isUp ? ( + + ) : ( + + )} + {Math.abs(change).toFixed(0)}% + +
    +
  • + ); + })} +
+ ); +} diff --git a/src/features/dashboard/components/dashboard-grid-editable.tsx b/src/features/dashboard/components/dashboard-grid-editable.tsx index 5b2abe4..a6b768d 100644 --- a/src/features/dashboard/components/dashboard-grid-editable.tsx +++ b/src/features/dashboard/components/dashboard-grid-editable.tsx @@ -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, })} diff --git a/src/features/dashboard/components/inbox-widget.tsx b/src/features/dashboard/components/inbox-widget.tsx new file mode 100644 index 0000000..d3b897a --- /dev/null +++ b/src/features/dashboard/components/inbox-widget.tsx @@ -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 `há ${minutes}min`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `há ${hours}h`; + const days = Math.floor(hours / 24); + return `há ${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 ( + } + title="Tudo em dia" + description="Nenhum pré-lançamento aguardando revisão." + /> + ); + } + + return ( +
+ {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 ( +
+
+ {item.sourceAppName + +
+

+ {displayName} +

+
+ {item.sourceAppName && ( + + {item.sourceAppName} + + )} + + {relativeTime(item.createdAt)} + +
+ + +
+
+
+ + {amount !== null && ( + + )} +
+
+ ); + })} + + + + +
+ ); +} diff --git a/src/features/dashboard/fetch-dashboard-data.ts b/src/features/dashboard/fetch-dashboard-data.ts index 48bb84c..f091634 100644 --- a/src/features/dashboard/fetch-dashboard-data.ts +++ b/src/features/dashboard/fetch-dashboard-data.ts @@ -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, }; } diff --git a/src/features/dashboard/inbox-snapshot-queries.ts b/src/features/dashboard/inbox-snapshot-queries.ts new file mode 100644 index 0000000..6bf7a2f --- /dev/null +++ b/src/features/dashboard/inbox-snapshot-queries.ts @@ -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; +}; + +export async function fetchDashboardInboxSnapshot( + userId: string, +): Promise { + "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 = {}; + 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, + }; +} diff --git a/src/features/dashboard/widgets/widgets-config.tsx b/src/features/dashboard/widgets/widgets-config.tsx index 5922cdb..f0fc06b 100644 --- a/src/features/dashboard/widgets/widgets-config.tsx +++ b/src/features/dashboard/widgets/widgets-config.tsx @@ -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: , component: ({ data }) => ( ), }, + { + id: "inbox", + title: "Pré-lançamentos", + subtitle: "Notificações pendentes de revisão", + icon: , + component: ({ data, quickActionOptions }) => ( + + ), + action: ( + + Revisar + + + ), + }, { id: "income-expense-balance", title: "Receita, Despesa e Balanço", - subtitle: "Últimos 6 Meses", + subtitle: "Últimos 6 meses", icon: , component: ({ data }) => ( ), }, + { + id: "goals-progress", + title: "Progresso de Orçamentos", + subtitle: "Orçamentos por categoria no período", + icon: , + component: ({ data }) => ( + + ), + action: ( + + Ver todos + + + ), + }, + { + id: "category-trends", + title: "Tendências de Categorias", + subtitle: "Top 6 maiores variações vs. mês anterior", + icon: , + component: ({ data }) => ( + + ), + }, + { + id: "spending-overview", + title: "Panorama de Gastos", + subtitle: "Principais despesas e frequência por local", + icon: , + component: ({ data }) => ( + + ), + }, + { + id: "payment-overview", + title: "Comportamento de Pagamento", + subtitle: "Despesas por condição e forma de pagamento", + icon: , + component: ({ data, period, adminPayerSlug }) => ( + + ), + }, + { + id: "expenses-by-category", + title: "Categorias por Despesas", + subtitle: "Distribuição de despesas por categoria", + icon: , + component: ({ data, period }) => ( + + ), + }, + { + id: "income-by-category", + title: "Categorias por Receitas", + subtitle: "Distribuição de receitas por categoria", + icon: , + component: ({ data, period }) => ( + + ), + }, + { + id: "purchases-by-category", + title: "Lançamentos por Categorias", + subtitle: "Distribuição de lançamentos por categoria", + icon: , + component: ({ data }) => ( + + ), + }, + { + id: "recurring-expenses", + title: "Lançamentos Recorrentes", + subtitle: "Despesas recorrentes do período", + icon: , + component: ({ data }) => ( + + ), + }, + { + id: "installment-expenses", + title: "Lançamentos Parcelados", + subtitle: "Acompanhe as parcelas abertas", + icon: , + component: ({ data }) => ( + + ), + }, { 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: , + id: "attachments", + title: "Anexos", + subtitle: "Comprovantes do período", + icon: , component: ({ data }) => ( - + ), action: ( Ver todos @@ -155,80 +301,4 @@ export const widgetsConfig: WidgetConfig[] = [ ), }, - { - id: "payment-overview", - title: "Comportamento de Pagamento", - subtitle: "Despesas por condição e forma de pagamento", - icon: , - component: ({ data }) => ( - - ), - }, - { - id: "recurring-expenses", - title: "Lançamentos Recorrentes", - subtitle: "Despesas recorrentes do período", - icon: , - component: ({ data }) => ( - - ), - }, - { - id: "installment-expenses", - title: "Lançamentos Parcelados", - subtitle: "Acompanhe as parcelas abertas", - icon: , - component: ({ data }) => ( - - ), - }, - { - id: "spending-overview", - title: "Panorama de Gastos", - subtitle: "Principais despesas e frequência por local", - icon: , - component: ({ data }) => ( - - ), - }, - { - id: "purchases-by-category", - title: "Lançamentos por Categorias", - subtitle: "Distribuição de lançamentos por categoria", - icon: , - component: ({ data }) => ( - - ), - }, - { - id: "income-by-category", - title: "Categorias por Receitas", - subtitle: "Distribuição de receitas por categoria", - icon: , - component: ({ data, period }) => ( - - ), - }, - { - id: "expenses-by-category", - title: "Categorias por Despesas", - subtitle: "Distribuição de despesas por categoria", - icon: , - component: ({ data, period }) => ( - - ), - }, ]; diff --git a/src/proxy.ts b/src/proxy.ts index 011a964..0fe5f17 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -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("; "); }