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:
183
app/(dashboard)/caixa-de-entrada/actions.ts
Normal file
183
app/(dashboard)/caixa-de-entrada/actions.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
app/(dashboard)/caixa-de-entrada/data.ts
Normal file
82
app/(dashboard)/caixa-de-entrada/data.ts
Normal 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;
|
||||||
|
}
|
||||||
33
app/(dashboard)/caixa-de-entrada/loading.tsx
Normal file
33
app/(dashboard)/caixa-de-entrada/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
app/(dashboard)/caixa-de-entrada/page.tsx
Normal file
30
app/(dashboard)/caixa-de-entrada/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
components/caixa-de-entrada/inbox-card.tsx
Normal file
135
components/caixa-de-entrada/inbox-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
components/caixa-de-entrada/inbox-details-dialog.tsx
Normal file
169
components/caixa-de-entrada/inbox-details-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
components/caixa-de-entrada/inbox-page.tsx
Normal file
163
components/caixa-de-entrada/inbox-page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
326
components/caixa-de-entrada/process-dialog.tsx
Normal file
326
components/caixa-de-entrada/process-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
components/caixa-de-entrada/types.ts
Normal file
49
components/caixa-de-entrada/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user