From 9ff42ecbe7005edfa4253fd3ceb243039d2a0593 Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Fri, 23 Jan 2026 12:12:22 +0000 Subject: [PATCH] feat(inbox): add Caixa de Entrada page for managing companion notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create inbox page with pending items management: - InboxCard: displays notification summary with parsed data - InboxDetailsDialog: view full notification details - ProcessDialog: convert notification to transaction (lancamento) - Add server actions for inbox operations: - getInboxItems: fetch pending inbox items - processInboxItem: create lancamento from inbox item - discardInboxItem: discard unwanted notifications - Add navigation link to sidebar under 'Gestão Financeira' - Add revalidation config for inbox-related paths --- app/(dashboard)/caixa-de-entrada/actions.ts | 183 ++++++++++ app/(dashboard)/caixa-de-entrada/data.ts | 82 +++++ app/(dashboard)/caixa-de-entrada/loading.tsx | 33 ++ app/(dashboard)/caixa-de-entrada/page.tsx | 30 ++ components/caixa-de-entrada/inbox-card.tsx | 135 ++++++++ .../caixa-de-entrada/inbox-details-dialog.tsx | 169 +++++++++ components/caixa-de-entrada/inbox-page.tsx | 163 +++++++++ .../caixa-de-entrada/process-dialog.tsx | 326 ++++++++++++++++++ components/caixa-de-entrada/types.ts | 49 +++ components/sidebar/nav-link.tsx | 6 + lib/actions/helpers.ts | 1 + 11 files changed, 1177 insertions(+) create mode 100644 app/(dashboard)/caixa-de-entrada/actions.ts create mode 100644 app/(dashboard)/caixa-de-entrada/data.ts create mode 100644 app/(dashboard)/caixa-de-entrada/loading.tsx create mode 100644 app/(dashboard)/caixa-de-entrada/page.tsx create mode 100644 components/caixa-de-entrada/inbox-card.tsx create mode 100644 components/caixa-de-entrada/inbox-details-dialog.tsx create mode 100644 components/caixa-de-entrada/inbox-page.tsx create mode 100644 components/caixa-de-entrada/process-dialog.tsx create mode 100644 components/caixa-de-entrada/types.ts diff --git a/app/(dashboard)/caixa-de-entrada/actions.ts b/app/(dashboard)/caixa-de-entrada/actions.ts new file mode 100644 index 0000000..fc7b25b --- /dev/null +++ b/app/(dashboard)/caixa-de-entrada/actions.ts @@ -0,0 +1,183 @@ +"use server"; + +import { inboxItems, lancamentos } from "@/db/schema"; +import { handleActionError } from "@/lib/actions/helpers"; +import type { ActionResult } from "@/lib/actions/types"; +import { db } from "@/lib/db"; +import { getUser } from "@/lib/auth/server"; +import { and, eq, inArray } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { getCurrentPeriod } from "@/lib/utils/period"; + +const processInboxSchema = z.object({ + inboxItemId: z.string().uuid("ID do item inválido"), + name: z.string().min(1, "Nome é obrigatório"), + amount: z.coerce.number().positive("Valor deve ser positivo"), + purchaseDate: z.string().min(1, "Data é obrigatória"), + transactionType: z.enum(["Despesa", "Receita"]), + condition: z.string().min(1, "Condição é obrigatória"), + paymentMethod: z.string().min(1, "Forma de pagamento é obrigatória"), + categoriaId: z.string().uuid("Categoria inválida"), + contaId: z.string().uuid("Conta inválida").optional(), + cartaoId: z.string().uuid("Cartão inválido").optional(), + note: z.string().optional(), +}); + +const discardInboxSchema = z.object({ + inboxItemId: z.string().uuid("ID do item inválido"), + reason: z.string().optional(), +}); + +const bulkDiscardSchema = z.object({ + inboxItemIds: z.array(z.string().uuid()).min(1, "Selecione ao menos um item"), +}); + +function revalidateInbox() { + revalidatePath("/caixa-de-entrada"); + revalidatePath("/lancamentos"); + revalidatePath("/dashboard"); +} + +export async function processInboxItemAction( + input: z.infer +): Promise { + try { + const user = await getUser(); + const data = processInboxSchema.parse(input); + + // Verificar se item existe e pertence ao usuário + const [item] = await db + .select() + .from(inboxItems) + .where( + and( + eq(inboxItems.id, data.inboxItemId), + eq(inboxItems.userId, user.id), + eq(inboxItems.status, "pending") + ) + ) + .limit(1); + + if (!item) { + return { success: false, error: "Item não encontrado ou já processado." }; + } + + // Determinar período baseado na data de compra + const purchaseDate = new Date(data.purchaseDate); + const period = getCurrentPeriod(purchaseDate); + + // Criar lançamento + const [newLancamento] = await db + .insert(lancamentos) + .values({ + userId: user.id, + name: data.name, + amount: data.amount.toString(), + purchaseDate: purchaseDate, + transactionType: data.transactionType, + condition: data.condition, + paymentMethod: data.paymentMethod, + categoriaId: data.categoriaId, + contaId: data.contaId, + cartaoId: data.cartaoId, + note: data.note, + period, + }) + .returning({ id: lancamentos.id }); + + // Marcar item como processado + await db + .update(inboxItems) + .set({ + status: "processed", + processedAt: new Date(), + lancamentoId: newLancamento.id, + updatedAt: new Date(), + }) + .where(eq(inboxItems.id, data.inboxItemId)); + + revalidateInbox(); + + return { success: true, message: "Lançamento criado com sucesso!" }; + } catch (error) { + return handleActionError(error); + } +} + +export async function discardInboxItemAction( + input: z.infer +): Promise { + try { + const user = await getUser(); + const data = discardInboxSchema.parse(input); + + // Verificar se item existe e pertence ao usuário + const [item] = await db + .select() + .from(inboxItems) + .where( + and( + eq(inboxItems.id, data.inboxItemId), + eq(inboxItems.userId, user.id), + eq(inboxItems.status, "pending") + ) + ) + .limit(1); + + if (!item) { + return { success: false, error: "Item não encontrado ou já processado." }; + } + + // Marcar item como descartado + await db + .update(inboxItems) + .set({ + status: "discarded", + discardedAt: new Date(), + discardReason: data.reason, + updatedAt: new Date(), + }) + .where(eq(inboxItems.id, data.inboxItemId)); + + revalidateInbox(); + + return { success: true, message: "Item descartado." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function bulkDiscardInboxItemsAction( + input: z.infer +): Promise { + try { + const user = await getUser(); + const data = bulkDiscardSchema.parse(input); + + // Marcar todos os itens como descartados + await db + .update(inboxItems) + .set({ + status: "discarded", + discardedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + inArray(inboxItems.id, data.inboxItemIds), + eq(inboxItems.userId, user.id), + eq(inboxItems.status, "pending") + ) + ); + + revalidateInbox(); + + return { + success: true, + message: `${data.inboxItemIds.length} item(s) descartado(s).`, + }; + } catch (error) { + return handleActionError(error); + } +} diff --git a/app/(dashboard)/caixa-de-entrada/data.ts b/app/(dashboard)/caixa-de-entrada/data.ts new file mode 100644 index 0000000..b9f7be4 --- /dev/null +++ b/app/(dashboard)/caixa-de-entrada/data.ts @@ -0,0 +1,82 @@ +/** + * Data fetching functions for Caixa de Entrada + */ + +import { db } from "@/lib/db"; +import { inboxItems, categorias, contas, cartoes } from "@/db/schema"; +import { eq, desc, and } from "drizzle-orm"; +import type { InboxItem, SelectOption } from "@/components/caixa-de-entrada/types"; + +export async function fetchInboxItems( + userId: string, + status: "pending" | "processed" | "discarded" = "pending" +): Promise { + const items = await db + .select() + .from(inboxItems) + .where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status))) + .orderBy(desc(inboxItems.createdAt)); + + return items; +} + +export async function fetchInboxItemById( + userId: string, + itemId: string +): Promise { + const [item] = await db + .select() + .from(inboxItems) + .where(and(eq(inboxItems.id, itemId), eq(inboxItems.userId, userId))) + .limit(1); + + return item ?? null; +} + +export async function fetchCategoriasForSelect( + userId: string, + type?: string +): Promise { + const query = db + .select({ id: categorias.id, name: categorias.name }) + .from(categorias) + .where( + type + ? and(eq(categorias.userId, userId), eq(categorias.type, type)) + : eq(categorias.userId, userId) + ) + .orderBy(categorias.name); + + return query; +} + +export async function fetchContasForSelect(userId: string): Promise { + const items = await db + .select({ id: contas.id, name: contas.name }) + .from(contas) + .where(and(eq(contas.userId, userId), eq(contas.status, "ativo"))) + .orderBy(contas.name); + + return items; +} + +export async function fetchCartoesForSelect( + userId: string +): Promise<(SelectOption & { lastDigits?: string })[]> { + const items = await db + .select({ id: cartoes.id, name: cartoes.name }) + .from(cartoes) + .where(and(eq(cartoes.userId, userId), eq(cartoes.status, "ativo"))) + .orderBy(cartoes.name); + + return items; +} + +export async function fetchPendingInboxCount(userId: string): Promise { + const items = await db + .select({ id: inboxItems.id }) + .from(inboxItems) + .where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending"))); + + return items.length; +} diff --git a/app/(dashboard)/caixa-de-entrada/loading.tsx b/app/(dashboard)/caixa-de-entrada/loading.tsx new file mode 100644 index 0000000..badd381 --- /dev/null +++ b/app/(dashboard)/caixa-de-entrada/loading.tsx @@ -0,0 +1,33 @@ +import { Card } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + +
+
+ + +
+ + +
+ + +
+
+
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/caixa-de-entrada/page.tsx b/app/(dashboard)/caixa-de-entrada/page.tsx new file mode 100644 index 0000000..15b70ab --- /dev/null +++ b/app/(dashboard)/caixa-de-entrada/page.tsx @@ -0,0 +1,30 @@ +import { InboxPage } from "@/components/caixa-de-entrada/inbox-page"; +import { getUserId } from "@/lib/auth/server"; +import { + fetchInboxItems, + fetchCategoriasForSelect, + fetchContasForSelect, + fetchCartoesForSelect, +} from "./data"; + +export default async function Page() { + const userId = await getUserId(); + + const [items, categorias, contas, cartoes] = await Promise.all([ + fetchInboxItems(userId, "pending"), + fetchCategoriasForSelect(userId), + fetchContasForSelect(userId), + fetchCartoesForSelect(userId), + ]); + + return ( +
+ +
+ ); +} diff --git a/components/caixa-de-entrada/inbox-card.tsx b/components/caixa-de-entrada/inbox-card.tsx new file mode 100644 index 0000000..3f36ab6 --- /dev/null +++ b/components/caixa-de-entrada/inbox-card.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { formatDistanceToNow } from "date-fns"; +import { ptBR } from "date-fns/locale"; +import { + RiCheckLine, + RiDeleteBinLine, + RiEyeLine, + RiMoreLine, + RiSmartphoneLine, +} from "@remixicon/react"; +import type { InboxItem } from "./types"; + +interface InboxCardProps { + item: InboxItem; + onProcess: (item: InboxItem) => void; + onDiscard: (item: InboxItem) => void; + onViewDetails: (item: InboxItem) => void; +} + +export function InboxCard({ + item, + onProcess, + onDiscard, + onViewDetails, +}: InboxCardProps) { + const formattedAmount = item.parsedAmount + ? new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + }).format(parseFloat(item.parsedAmount)) + : null; + + const timeAgo = formatDistanceToNow(new Date(item.notificationTimestamp), { + addSuffix: true, + locale: ptBR, + }); + + return ( + + +
+ + + {item.sourceAppName || item.sourceApp} + +
+
+ {formattedAmount && ( + + {formattedAmount} + + )} + + + + + + onViewDetails(item)}> + + Ver detalhes + + onProcess(item)}> + + Processar + + onDiscard(item)} + className="text-destructive" + > + + Descartar + + + +
+
+ + +
+ {item.parsedName && ( +

{item.parsedName}

+ )} +

+ {item.originalText} +

+
+ +
+ {timeAgo} + {item.parsedCardLastDigits && ( + + •••• {item.parsedCardLastDigits} + + )} +
+ +
+ + +
+
+
+ ); +} diff --git a/components/caixa-de-entrada/inbox-details-dialog.tsx b/components/caixa-de-entrada/inbox-details-dialog.tsx new file mode 100644 index 0000000..9e10549 --- /dev/null +++ b/components/caixa-de-entrada/inbox-details-dialog.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { format } from "date-fns"; +import { ptBR } from "date-fns/locale"; +import type { InboxItem } from "./types"; + +interface InboxDetailsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + item: InboxItem | null; +} + +export function InboxDetailsDialog({ + open, + onOpenChange, + item, +}: InboxDetailsDialogProps) { + if (!item) return null; + + const formattedAmount = item.parsedAmount + ? new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + }).format(parseFloat(item.parsedAmount)) + : "Não extraído"; + + return ( + + + + Detalhes da Notificação + + +
+ {/* Dados da fonte */} +
+

+ Fonte +

+
+
+ App + {item.sourceAppName || item.sourceApp} +
+
+ Package + {item.sourceApp} +
+ {item.deviceId && ( +
+ Dispositivo + {item.deviceId} +
+ )} +
+
+ + + + {/* Texto original */} +
+

+ Notificação Original +

+ {item.originalTitle && ( +

{item.originalTitle}

+ )} +

{item.originalText}

+

+ Recebida em{" "} + {format(new Date(item.notificationTimestamp), "PPpp", { + locale: ptBR, + })} +

+
+ + + + {/* Dados parseados */} +
+

+ Dados Extraídos +

+
+
+ Estabelecimento + {item.parsedName || "Não extraído"} +
+
+ Valor + + {formattedAmount} + +
+ {item.parsedDate && ( +
+ Data + + {format(new Date(item.parsedDate), "dd/MM/yyyy", { + locale: ptBR, + })} + +
+ )} + {item.parsedCardLastDigits && ( +
+ Cartão + •••• {item.parsedCardLastDigits} +
+ )} +
+ Tipo + {item.parsedTransactionType || "Não identificado"} +
+
+
+ + + + {/* Metadados */} +
+

+ Metadados +

+
+
+ ID + {item.id} +
+
+ Status + {item.status} +
+
+ Criado em + + {format(new Date(item.createdAt), "PPpp", { locale: ptBR })} + +
+
+
+
+ + + + + + +
+
+ ); +} diff --git a/components/caixa-de-entrada/inbox-page.tsx b/components/caixa-de-entrada/inbox-page.tsx new file mode 100644 index 0000000..874c122 --- /dev/null +++ b/components/caixa-de-entrada/inbox-page.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { discardInboxItemAction } from "@/app/(dashboard)/caixa-de-entrada/actions"; +import { ConfirmActionDialog } from "@/components/confirm-action-dialog"; +import { EmptyState } from "@/components/empty-state"; +import { Card } from "@/components/ui/card"; +import { RiInboxLine } from "@remixicon/react"; +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { InboxCard } from "./inbox-card"; +import { InboxDetailsDialog } from "./inbox-details-dialog"; +import { ProcessDialog } from "./process-dialog"; +import type { InboxItem, SelectOption } from "./types"; + +interface InboxPageProps { + items: InboxItem[]; + categorias: SelectOption[]; + contas: SelectOption[]; + cartoes: SelectOption[]; +} + +export function InboxPage({ + items, + categorias, + contas, + cartoes, +}: InboxPageProps) { + const [processOpen, setProcessOpen] = useState(false); + const [itemToProcess, setItemToProcess] = useState(null); + + const [detailsOpen, setDetailsOpen] = useState(false); + const [itemDetails, setItemDetails] = useState(null); + + const [discardOpen, setDiscardOpen] = useState(false); + const [itemToDiscard, setItemToDiscard] = useState(null); + + const sortedItems = useMemo( + () => + [...items].sort( + (a, b) => + new Date(b.notificationTimestamp).getTime() - + new Date(a.notificationTimestamp).getTime() + ), + [items] + ); + + const handleProcessOpenChange = useCallback((open: boolean) => { + setProcessOpen(open); + if (!open) { + setItemToProcess(null); + } + }, []); + + const handleDetailsOpenChange = useCallback((open: boolean) => { + setDetailsOpen(open); + if (!open) { + setItemDetails(null); + } + }, []); + + const handleDiscardOpenChange = useCallback((open: boolean) => { + setDiscardOpen(open); + if (!open) { + setItemToDiscard(null); + } + }, []); + + const handleProcessRequest = useCallback((item: InboxItem) => { + setItemToProcess(item); + setProcessOpen(true); + }, []); + + const handleDetailsRequest = useCallback((item: InboxItem) => { + setItemDetails(item); + setDetailsOpen(true); + }, []); + + const handleDiscardRequest = useCallback((item: InboxItem) => { + setItemToDiscard(item); + setDiscardOpen(true); + }, []); + + const handleDiscardConfirm = useCallback(async () => { + if (!itemToDiscard) return; + + const result = await discardInboxItemAction({ + inboxItemId: itemToDiscard.id, + }); + + if (result.success) { + toast.success(result.message); + return; + } + + toast.error(result.error); + throw new Error(result.error); + }, [itemToDiscard]); + + return ( + <> +
+
+
+

Caixa de Entrada

+

+ {items.length === 0 + ? "Nenhuma notificação pendente" + : `${items.length} notificação${items.length > 1 ? "ões" : ""} pendente${items.length > 1 ? "s" : ""}`} +

+
+
+ + {sortedItems.length === 0 ? ( + + } + title="Caixa de entrada vazia" + description="As notificações capturadas pelo app OpenSheets Companion aparecerão aqui para você processar." + /> + + ) : ( +
+ {sortedItems.map((item) => ( + + ))} +
+ )} +
+ + + + + + + + ); +} diff --git a/components/caixa-de-entrada/process-dialog.tsx b/components/caixa-de-entrada/process-dialog.tsx new file mode 100644 index 0000000..9b2a2cd --- /dev/null +++ b/components/caixa-de-entrada/process-dialog.tsx @@ -0,0 +1,326 @@ +"use client"; + +import { processInboxItemAction } from "@/app/(dashboard)/caixa-de-entrada/actions"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import type { InboxItem, SelectOption } from "./types"; + +interface ProcessDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + item: InboxItem | null; + categorias: SelectOption[]; + contas: SelectOption[]; + cartoes: SelectOption[]; +} + +export function ProcessDialog({ + open, + onOpenChange, + item, + categorias, + contas, + cartoes, +}: ProcessDialogProps) { + const [loading, setLoading] = useState(false); + + // Form state + const [name, setName] = useState(""); + const [amount, setAmount] = useState(""); + const [purchaseDate, setPurchaseDate] = useState(""); + const [transactionType, setTransactionType] = useState<"Despesa" | "Receita">( + "Despesa" + ); + const [condition, setCondition] = useState("realizado"); + const [paymentMethod, setPaymentMethod] = useState("cartao-credito"); + const [categoriaId, setCategoriaId] = useState(""); + const [contaId, setContaId] = useState(""); + const [cartaoId, setCartaoId] = useState(""); + const [note, setNote] = useState(""); + + // Pré-preencher com dados parseados + useEffect(() => { + if (item) { + setName(item.parsedName || ""); + setAmount(item.parsedAmount || ""); + setPurchaseDate( + item.parsedDate + ? new Date(item.parsedDate).toISOString().split("T")[0] + : new Date(item.notificationTimestamp).toISOString().split("T")[0] + ); + setTransactionType( + (item.parsedTransactionType as "Despesa" | "Receita") || "Despesa" + ); + setCondition("realizado"); + setPaymentMethod(item.parsedCardLastDigits ? "cartao-credito" : "outros"); + setCategoriaId(""); + setContaId(""); + setCartaoId(""); + setNote(""); + } + }, [item]); + + // Por enquanto, mostrar todas as categorias + // Em produção, seria melhor filtrar pelo tipo (Despesa/Receita) + const filteredCategorias = categorias; + + const handleSubmit = useCallback(async () => { + if (!item) return; + + if (!categoriaId) { + toast.error("Selecione uma categoria."); + return; + } + + if (paymentMethod === "cartao-credito" && !cartaoId) { + toast.error("Selecione um cartão."); + return; + } + + if (paymentMethod !== "cartao-credito" && !contaId) { + toast.error("Selecione uma conta."); + return; + } + + setLoading(true); + + try { + const result = await processInboxItemAction({ + inboxItemId: item.id, + name, + amount: parseFloat(amount), + purchaseDate, + transactionType, + condition, + paymentMethod, + categoriaId, + contaId: paymentMethod !== "cartao-credito" ? contaId : undefined, + cartaoId: paymentMethod === "cartao-credito" ? cartaoId : undefined, + note: note || undefined, + }); + + if (result.success) { + toast.success(result.message); + onOpenChange(false); + } else { + toast.error(result.error); + } + } finally { + setLoading(false); + } + }, [ + item, + name, + amount, + purchaseDate, + transactionType, + condition, + paymentMethod, + categoriaId, + contaId, + cartaoId, + note, + onOpenChange, + ]); + + if (!item) return null; + + return ( + + + + Processar Notificação + + Revise os dados extraídos e complete as informações para criar o + lançamento. + + + +
+ {/* Texto original */} +
+

Notificação original:

+

{item.originalText}

+
+ + {/* Nome/Descrição */} +
+ + setName(e.target.value)} + placeholder="Ex: Supermercado, Uber, etc." + /> +
+ + {/* Valor e Data */} +
+
+ + setAmount(e.target.value)} + placeholder="0,00" + /> +
+
+ + setPurchaseDate(e.target.value)} + /> +
+
+ + {/* Tipo de transação */} +
+ + +
+ + {/* Forma de pagamento */} +
+ + +
+ + {/* Cartão ou Conta */} + {paymentMethod === "cartao-credito" ? ( +
+ + +
+ ) : ( +
+ + +
+ )} + + {/* Categoria */} +
+ + +
+ + {/* Condição */} +
+ + +
+ + {/* Notas */} +
+ +