feat(inbox): add Caixa de Entrada page for managing companion notifications

- 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
This commit is contained in:
Felipe Coutinho
2026-01-23 12:12:22 +00:00
parent 48d9eea8a9
commit 9ff42ecbe7
11 changed files with 1177 additions and 0 deletions

View File

@@ -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<typeof processInboxSchema>
): Promise<ActionResult> {
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<typeof discardInboxSchema>
): Promise<ActionResult> {
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<typeof bulkDiscardSchema>
): Promise<ActionResult> {
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);
}
}

View File

@@ -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<InboxItem[]> {
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<InboxItem | null> {
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<SelectOption[]> {
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<SelectOption[]> {
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<number> {
const items = await db
.select({ id: inboxItems.id })
.from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")));
return items.length;
}

View File

@@ -0,0 +1,33 @@
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<main className="flex flex-col items-start gap-6">
<div className="flex w-full flex-col gap-6">
<div className="flex justify-between">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-10 w-32" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="p-4">
<div className="space-y-3">
<div className="flex justify-between">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-16" />
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<div className="flex gap-2 pt-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
</div>
</Card>
))}
</div>
</div>
</main>
);
}

View File

@@ -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 (
<main className="flex flex-col items-start gap-6">
<InboxPage
items={items}
categorias={categorias}
contas={contas}
cartoes={cartoes}
/>
</main>
);
}

View File

@@ -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 (
<Card className="flex flex-col">
<CardHeader className="flex flex-row items-start justify-between gap-2 pb-2">
<div className="flex items-center gap-2">
<RiSmartphoneLine className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">
{item.sourceAppName || item.sourceApp}
</span>
</div>
<div className="flex items-center gap-2">
{formattedAmount && (
<Badge
variant={
item.parsedTransactionType === "Receita"
? "success"
: "destructive"
}
>
{formattedAmount}
</Badge>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<RiMoreLine className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onViewDetails(item)}>
<RiEyeLine className="mr-2 size-4" />
Ver detalhes
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onProcess(item)}>
<RiCheckLine className="mr-2 size-4" />
Processar
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDiscard(item)}
className="text-destructive"
>
<RiDeleteBinLine className="mr-2 size-4" />
Descartar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-3">
<div className="flex-1">
{item.parsedName && (
<p className="font-medium">{item.parsedName}</p>
)}
<p className="line-clamp-2 text-sm text-muted-foreground">
{item.originalText}
</p>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{timeAgo}</span>
{item.parsedCardLastDigits && (
<span className="text-xs text-muted-foreground">
{item.parsedCardLastDigits}
</span>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
className="flex-1"
onClick={() => onProcess(item)}
>
<RiCheckLine className="mr-1 size-4" />
Processar
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onDiscard(item)}
>
<RiDeleteBinLine className="size-4" />
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Detalhes da Notificação</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Dados da fonte */}
<div>
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
Fonte
</h4>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">App</span>
<span>{item.sourceAppName || item.sourceApp}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Package</span>
<span className="font-mono text-xs">{item.sourceApp}</span>
</div>
{item.deviceId && (
<div className="flex justify-between">
<span className="text-muted-foreground">Dispositivo</span>
<span className="font-mono text-xs">{item.deviceId}</span>
</div>
)}
</div>
</div>
<Separator />
{/* Texto original */}
<div>
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
Notificação Original
</h4>
{item.originalTitle && (
<p className="mb-1 font-medium">{item.originalTitle}</p>
)}
<p className="text-sm">{item.originalText}</p>
<p className="mt-2 text-xs text-muted-foreground">
Recebida em{" "}
{format(new Date(item.notificationTimestamp), "PPpp", {
locale: ptBR,
})}
</p>
</div>
<Separator />
{/* Dados parseados */}
<div>
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
Dados Extraídos
</h4>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Estabelecimento</span>
<span>{item.parsedName || "Não extraído"}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Valor</span>
<Badge
variant={
item.parsedTransactionType === "Receita"
? "success"
: "destructive"
}
>
{formattedAmount}
</Badge>
</div>
{item.parsedDate && (
<div className="flex justify-between">
<span className="text-muted-foreground">Data</span>
<span>
{format(new Date(item.parsedDate), "dd/MM/yyyy", {
locale: ptBR,
})}
</span>
</div>
)}
{item.parsedCardLastDigits && (
<div className="flex justify-between">
<span className="text-muted-foreground">Cartão</span>
<span> {item.parsedCardLastDigits}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Tipo</span>
<span>{item.parsedTransactionType || "Não identificado"}</span>
</div>
</div>
</div>
<Separator />
{/* Metadados */}
<div>
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
Metadados
</h4>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">ID</span>
<span className="font-mono text-xs">{item.id}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Status</span>
<Badge variant="outline">{item.status}</Badge>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Criado em</span>
<span>
{format(new Date(item.createdAt), "PPpp", { locale: ptBR })}
</span>
</div>
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button>Fechar</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<InboxItem | null>(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const [itemDetails, setItemDetails] = useState<InboxItem | null>(null);
const [discardOpen, setDiscardOpen] = useState(false);
const [itemToDiscard, setItemToDiscard] = useState<InboxItem | null>(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 (
<>
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Caixa de Entrada</h1>
<p className="text-sm text-muted-foreground">
{items.length === 0
? "Nenhuma notificação pendente"
: `${items.length} notificação${items.length > 1 ? "ões" : ""} pendente${items.length > 1 ? "s" : ""}`}
</p>
</div>
</div>
{sortedItems.length === 0 ? (
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState
media={<RiInboxLine className="size-6 text-primary" />}
title="Caixa de entrada vazia"
description="As notificações capturadas pelo app OpenSheets Companion aparecerão aqui para você processar."
/>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sortedItems.map((item) => (
<InboxCard
key={item.id}
item={item}
onProcess={handleProcessRequest}
onDiscard={handleDiscardRequest}
onViewDetails={handleDetailsRequest}
/>
))}
</div>
)}
</div>
<ProcessDialog
open={processOpen}
onOpenChange={handleProcessOpenChange}
item={itemToProcess}
categorias={categorias}
contas={contas}
cartoes={cartoes}
/>
<InboxDetailsDialog
open={detailsOpen}
onOpenChange={handleDetailsOpenChange}
item={itemDetails}
/>
<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}
/>
</>
);
}

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Processar Notificação</DialogTitle>
<DialogDescription>
Revise os dados extraídos e complete as informações para criar o
lançamento.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Texto original */}
<div className="rounded-md bg-muted p-3">
<p className="text-xs text-muted-foreground">Notificação original:</p>
<p className="mt-1 text-sm">{item.originalText}</p>
</div>
{/* Nome/Descrição */}
<div className="grid gap-2">
<Label htmlFor="name">Descrição</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ex: Supermercado, Uber, etc."
/>
</div>
{/* Valor e Data */}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="amount">Valor (R$)</Label>
<Input
id="amount"
type="number"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0,00"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="purchaseDate">Data</Label>
<Input
id="purchaseDate"
type="date"
value={purchaseDate}
onChange={(e) => setPurchaseDate(e.target.value)}
/>
</div>
</div>
{/* Tipo de transação */}
<div className="grid gap-2">
<Label>Tipo</Label>
<Select
value={transactionType}
onValueChange={(v) => setTransactionType(v as "Despesa" | "Receita")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Despesa">Despesa</SelectItem>
<SelectItem value="Receita">Receita</SelectItem>
</SelectContent>
</Select>
</div>
{/* Forma de pagamento */}
<div className="grid gap-2">
<Label>Forma de Pagamento</Label>
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cartao-credito">Cartão de Crédito</SelectItem>
<SelectItem value="cartao-debito">Cartão de Débito</SelectItem>
<SelectItem value="pix">PIX</SelectItem>
<SelectItem value="dinheiro">Dinheiro</SelectItem>
<SelectItem value="transferencia">Transferência</SelectItem>
<SelectItem value="boleto">Boleto</SelectItem>
<SelectItem value="outros">Outros</SelectItem>
</SelectContent>
</Select>
</div>
{/* Cartão ou Conta */}
{paymentMethod === "cartao-credito" ? (
<div className="grid gap-2">
<Label>Cartão</Label>
<Select value={cartaoId} onValueChange={setCartaoId}>
<SelectTrigger>
<SelectValue placeholder="Selecione o cartão" />
</SelectTrigger>
<SelectContent>
{cartoes.map((cartao) => (
<SelectItem key={cartao.id} value={cartao.id}>
{cartao.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="grid gap-2">
<Label>Conta</Label>
<Select value={contaId} onValueChange={setContaId}>
<SelectTrigger>
<SelectValue placeholder="Selecione a conta" />
</SelectTrigger>
<SelectContent>
{contas.map((conta) => (
<SelectItem key={conta.id} value={conta.id}>
{conta.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Categoria */}
<div className="grid gap-2">
<Label>Categoria</Label>
<Select value={categoriaId} onValueChange={setCategoriaId}>
<SelectTrigger>
<SelectValue placeholder="Selecione a categoria" />
</SelectTrigger>
<SelectContent>
{filteredCategorias.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Condição */}
<div className="grid gap-2">
<Label>Condição</Label>
<Select value={condition} onValueChange={setCondition}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="realizado">Realizado</SelectItem>
<SelectItem value="aberto">Aberto</SelectItem>
</SelectContent>
</Select>
</div>
{/* Notas */}
<div className="grid gap-2">
<Label htmlFor="note">Observações (opcional)</Label>
<Textarea
id="note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Observações adicionais..."
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? "Processando..." : "Criar Lançamento"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,49 @@
/**
* Types for Caixa de Entrada (Inbox) feature
*/
export interface InboxItem {
id: string;
sourceApp: string;
sourceAppName: string | null;
deviceId: string | null;
originalTitle: string | null;
originalText: string;
notificationTimestamp: Date;
parsedName: string | null;
parsedAmount: string | null;
parsedDate: Date | null;
parsedCardLastDigits: string | null;
parsedTransactionType: string | null;
status: string;
lancamentoId: string | null;
processedAt: Date | null;
discardedAt: Date | null;
discardReason: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface ProcessInboxInput {
inboxItemId: string;
name: string;
amount: number;
purchaseDate: string;
transactionType: "Despesa" | "Receita";
condition: string;
paymentMethod: string;
categoriaId: string;
contaId?: string;
cartaoId?: string;
note?: string;
}
export interface DiscardInboxInput {
inboxItemId: string;
reason?: string;
}
export interface SelectOption {
id: string;
name: string;
}

View File

@@ -8,6 +8,7 @@ import {
RiFileChartLine, RiFileChartLine,
RiFundsLine, RiFundsLine,
RiGroupLine, RiGroupLine,
RiInboxLine,
RiNoCreditCardLine, RiNoCreditCardLine,
RiPriceTag3Line, RiPriceTag3Line,
RiSettingsLine, RiSettingsLine,
@@ -87,6 +88,11 @@ export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData {
{ {
title: "Gestão Financeira", title: "Gestão Financeira",
items: [ items: [
{
title: "Caixa de Entrada",
url: "/caixa-de-entrada",
icon: RiInboxLine,
},
{ {
title: "Lançamentos", title: "Lançamentos",
url: "/lancamentos", url: "/lancamentos",

View File

@@ -32,6 +32,7 @@ export const revalidateConfig = {
pagadores: ["/pagadores"], pagadores: ["/pagadores"],
anotacoes: ["/anotacoes", "/anotacoes/arquivadas"], anotacoes: ["/anotacoes", "/anotacoes/arquivadas"],
lancamentos: ["/lancamentos", "/contas"], lancamentos: ["/lancamentos", "/contas"],
inbox: ["/caixa-de-entrada", "/lancamentos", "/dashboard"],
} as const; } as const;
/** /**