mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-10 11:21:45 +00:00
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:
128
src/features/dashboard/components/attachments-widget.tsx
Normal file
128
src/features/dashboard/components/attachments-widget.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
src/features/dashboard/components/category-trends-widget.tsx
Normal file
84
src/features/dashboard/components/category-trends-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
267
src/features/dashboard/components/inbox-widget.tsx
Normal file
267
src/features/dashboard/components/inbox-widget.tsx
Normal 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 `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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user