refactor(inbox): rename caixa-de-entrada to pre-lancamentos e remove colunas não utilizadas

BREAKING CHANGES:
- Renomeia rota /caixa-de-entrada para /pre-lancamentos
- Remove colunas device_id, parsed_date e discard_reason da tabela inbox_items

Mudanças:
- Move componentes de caixa-de-entrada para pre-lancamentos
- Atualiza sidebar e navegação para nova rota
- Remove campos não utilizados do schema, types e APIs
- Adiciona migration 0011 para remover colunas do banco
- Simplifica lógica de data padrão usando notificationTimestamp
This commit is contained in:
Felipe Coutinho
2026-01-26 17:05:55 +00:00
parent c0fb11f89c
commit 8ffe61c59b
23 changed files with 2606 additions and 272 deletions

View File

@@ -7,6 +7,7 @@ import { fetchDashboardNotifications } from "@/lib/dashboard/notifications";
import { fetchPagadoresWithAccess } from "@/lib/pagadores/access"; import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { parsePeriodParam } from "@/lib/utils/period"; import { parsePeriodParam } from "@/lib/utils/period";
import { fetchPendingInboxCount } from "./pre-lancamentos/data";
export default async function DashboardLayout({ export default async function DashboardLayout({
children, children,
@@ -40,6 +41,9 @@ export default async function DashboardLayout({
currentPeriod, currentPeriod,
); );
// Buscar contagem de pré-lançamentos pendentes
const preLancamentosCount = await fetchPendingInboxCount(session.user.id);
return ( return (
<PrivacyProvider> <PrivacyProvider>
<SidebarProvider> <SidebarProvider>
@@ -52,6 +56,7 @@ export default async function DashboardLayout({
avatarUrl: item.avatarUrl, avatarUrl: item.avatarUrl,
canEdit: item.canEdit, canEdit: item.canEdit,
}))} }))}
preLancamentosCount={preLancamentosCount}
variant="sidebar" variant="sidebar"
/> />
<SidebarInset> <SidebarInset>

View File

@@ -3,8 +3,8 @@
import { inboxItems } from "@/db/schema"; import { inboxItems } from "@/db/schema";
import { handleActionError } from "@/lib/actions/helpers"; import { handleActionError } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types"; import type { ActionResult } from "@/lib/actions/types";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server"; import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
@@ -15,7 +15,6 @@ const markProcessedSchema = z.object({
const discardInboxSchema = z.object({ const discardInboxSchema = z.object({
inboxItemId: z.string().uuid("ID do item inválido"), inboxItemId: z.string().uuid("ID do item inválido"),
reason: z.string().optional(),
}); });
const bulkDiscardSchema = z.object({ const bulkDiscardSchema = z.object({
@@ -23,7 +22,7 @@ const bulkDiscardSchema = z.object({
}); });
function revalidateInbox() { function revalidateInbox() {
revalidatePath("/caixa-de-entrada"); revalidatePath("/pre-lancamentos");
revalidatePath("/lancamentos"); revalidatePath("/lancamentos");
revalidatePath("/dashboard"); revalidatePath("/dashboard");
} }
@@ -32,7 +31,7 @@ function revalidateInbox() {
* Mark an inbox item as processed after a lancamento was created * Mark an inbox item as processed after a lancamento was created
*/ */
export async function markInboxAsProcessedAction( export async function markInboxAsProcessedAction(
input: z.infer<typeof markProcessedSchema> input: z.infer<typeof markProcessedSchema>,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -46,8 +45,8 @@ export async function markInboxAsProcessedAction(
and( and(
eq(inboxItems.id, data.inboxItemId), eq(inboxItems.id, data.inboxItemId),
eq(inboxItems.userId, user.id), eq(inboxItems.userId, user.id),
eq(inboxItems.status, "pending") eq(inboxItems.status, "pending"),
) ),
) )
.limit(1); .limit(1);
@@ -74,7 +73,7 @@ export async function markInboxAsProcessedAction(
} }
export async function discardInboxItemAction( export async function discardInboxItemAction(
input: z.infer<typeof discardInboxSchema> input: z.infer<typeof discardInboxSchema>,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -88,8 +87,8 @@ export async function discardInboxItemAction(
and( and(
eq(inboxItems.id, data.inboxItemId), eq(inboxItems.id, data.inboxItemId),
eq(inboxItems.userId, user.id), eq(inboxItems.userId, user.id),
eq(inboxItems.status, "pending") eq(inboxItems.status, "pending"),
) ),
) )
.limit(1); .limit(1);
@@ -103,7 +102,6 @@ export async function discardInboxItemAction(
.set({ .set({
status: "discarded", status: "discarded",
discardedAt: new Date(), discardedAt: new Date(),
discardReason: data.reason,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(inboxItems.id, data.inboxItemId)); .where(eq(inboxItems.id, data.inboxItemId));
@@ -117,7 +115,7 @@ export async function discardInboxItemAction(
} }
export async function bulkDiscardInboxItemsAction( export async function bulkDiscardInboxItemsAction(
input: z.infer<typeof bulkDiscardSchema> input: z.infer<typeof bulkDiscardSchema>,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -135,8 +133,8 @@ export async function bulkDiscardInboxItemsAction(
and( and(
inArray(inboxItems.id, data.inboxItemIds), inArray(inboxItems.id, data.inboxItemIds),
eq(inboxItems.userId, user.id), eq(inboxItems.userId, user.id),
eq(inboxItems.status, "pending") eq(inboxItems.status, "pending"),
) ),
); );
revalidateInbox(); revalidateInbox();

View File

@@ -1,11 +1,11 @@
/** /**
* Data fetching functions for Caixa de Entrada * Data fetching functions for Pré-Lançamentos
*/ */
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { inboxItems, categorias, contas, cartoes, lancamentos } from "@/db/schema"; import { inboxItems, categorias, contas, cartoes, lancamentos } from "@/db/schema";
import { eq, desc, and, gte } from "drizzle-orm"; import { eq, desc, and, gte } from "drizzle-orm";
import type { InboxItem, SelectOption } from "@/components/caixa-de-entrada/types"; import type { InboxItem, SelectOption } from "@/components/pre-lancamentos/types";
import { import {
fetchLancamentoFilterSources, fetchLancamentoFilterSources,
buildSluggedFilters, buildSluggedFilters,

View File

@@ -1,8 +1,8 @@
import PageDescription from "@/components/page-description"; import PageDescription from "@/components/page-description";
import { RiInbox2Line } from "@remixicon/react"; import { RiInboxLine } from "@remixicon/react";
export const metadata = { export const metadata = {
title: "Caixa de Entrada | Opensheets", title: "Pré-Lançamentos | Opensheets",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -13,9 +13,9 @@ export default function RootLayout({
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 px-6">
<PageDescription <PageDescription
icon={<RiInbox2Line />} icon={<RiInboxLine />}
title="Caixa de Entrada" title="Pré-Lançamentos"
subtitle="Visialize seus lançamentos pendentes" subtitle="Notificações capturadas aguardando processamento"
/> />
{children} {children}
</section> </section>

View File

@@ -1,6 +1,6 @@
import { InboxPage } from "@/components/caixa-de-entrada/inbox-page"; import { InboxPage } from "@/components/pre-lancamentos/inbox-page";
import { getUserId } from "@/lib/auth/server"; import { getUserId } from "@/lib/auth/server";
import { fetchInboxItems, fetchInboxDialogData } from "./data"; import { fetchInboxDialogData, fetchInboxItems } from "./data";
export default async function Page() { export default async function Page() {
const userId = await getUserId(); const userId = await getUserId();

View File

@@ -5,12 +5,12 @@
* Requer autenticação via API token (formato os_xxx). * Requer autenticação via API token (formato os_xxx).
*/ */
import { apiTokens, inboxItems } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/lib/auth/api-token"; import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { apiTokens, inboxItems } from "@/db/schema";
import { eq, and, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { inboxBatchSchema } from "@/lib/schemas/inbox"; import { inboxBatchSchema } from "@/lib/schemas/inbox";
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
// Rate limiting simples em memória // Rate limiting simples em memória
@@ -51,7 +51,7 @@ export async function POST(request: Request) {
if (!token) { if (!token) {
return NextResponse.json( return NextResponse.json(
{ error: "Token não fornecido" }, { error: "Token não fornecido" },
{ status: 401 } { status: 401 },
); );
} }
@@ -59,7 +59,7 @@ export async function POST(request: Request) {
if (!token.startsWith("os_")) { if (!token.startsWith("os_")) {
return NextResponse.json( return NextResponse.json(
{ error: "Formato de token inválido" }, { error: "Formato de token inválido" },
{ status: 401 } { status: 401 },
); );
} }
@@ -69,14 +69,14 @@ export async function POST(request: Request) {
const tokenRecord = await db.query.apiTokens.findFirst({ const tokenRecord = await db.query.apiTokens.findFirst({
where: and( where: and(
eq(apiTokens.tokenHash, tokenHash), eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt) isNull(apiTokens.revokedAt),
), ),
}); });
if (!tokenRecord) { if (!tokenRecord) {
return NextResponse.json( return NextResponse.json(
{ error: "Token inválido ou revogado" }, { error: "Token inválido ou revogado" },
{ status: 401 } { status: 401 },
); );
} }
@@ -84,7 +84,7 @@ export async function POST(request: Request) {
if (!checkRateLimit(tokenRecord.userId)) { if (!checkRateLimit(tokenRecord.userId)) {
return NextResponse.json( return NextResponse.json(
{ error: "Limite de requisições excedido", retryAfter: 60 }, { error: "Limite de requisições excedido", retryAfter: 60 },
{ status: 429 } { status: 429 },
); );
} }
@@ -103,13 +103,11 @@ export async function POST(request: Request) {
userId: tokenRecord.userId, userId: tokenRecord.userId,
sourceApp: item.sourceApp, sourceApp: item.sourceApp,
sourceAppName: item.sourceAppName, sourceAppName: item.sourceAppName,
deviceId: item.deviceId,
originalTitle: item.originalTitle, originalTitle: item.originalTitle,
originalText: item.originalText, originalText: item.originalText,
notificationTimestamp: item.notificationTimestamp, notificationTimestamp: item.notificationTimestamp,
parsedName: item.parsedName, parsedName: item.parsedName,
parsedAmount: item.parsedAmount?.toString(), parsedAmount: item.parsedAmount?.toString(),
parsedDate: item.parsedDate,
parsedTransactionType: item.parsedTransactionType, parsedTransactionType: item.parsedTransactionType,
status: "pending", status: "pending",
}) })
@@ -130,9 +128,10 @@ export async function POST(request: Request) {
} }
// Atualizar último uso do token // Atualizar último uso do token
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() const clientIp =
|| request.headers.get("x-real-ip") request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|| null; request.headers.get("x-real-ip") ||
null;
await db await db
.update(apiTokens) .update(apiTokens)
@@ -153,20 +152,20 @@ export async function POST(request: Request) {
failed: failCount, failed: failCount,
results, results,
}, },
{ status: 201 } { status: 201 },
); );
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
return NextResponse.json( return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" }, { error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 } { status: 400 },
); );
} }
console.error("[API] Error creating batch inbox items:", error); console.error("[API] Error creating batch inbox items:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Erro ao processar notificações" }, { error: "Erro ao processar notificações" },
{ status: 500 } { status: 500 },
); );
} }
} }

View File

@@ -5,12 +5,12 @@
* Requer autenticação via API token (formato os_xxx). * Requer autenticação via API token (formato os_xxx).
*/ */
import { apiTokens, inboxItems } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/lib/auth/api-token"; import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { apiTokens, inboxItems } from "@/db/schema";
import { eq, and, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { inboxItemSchema } from "@/lib/schemas/inbox"; import { inboxItemSchema } from "@/lib/schemas/inbox";
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
// Rate limiting simples em memória (em produção, use Redis) // Rate limiting simples em memória (em produção, use Redis)
@@ -44,7 +44,7 @@ export async function POST(request: Request) {
if (!token) { if (!token) {
return NextResponse.json( return NextResponse.json(
{ error: "Token não fornecido" }, { error: "Token não fornecido" },
{ status: 401 } { status: 401 },
); );
} }
@@ -52,7 +52,7 @@ export async function POST(request: Request) {
if (!token.startsWith("os_")) { if (!token.startsWith("os_")) {
return NextResponse.json( return NextResponse.json(
{ error: "Formato de token inválido" }, { error: "Formato de token inválido" },
{ status: 401 } { status: 401 },
); );
} }
@@ -62,14 +62,14 @@ export async function POST(request: Request) {
const tokenRecord = await db.query.apiTokens.findFirst({ const tokenRecord = await db.query.apiTokens.findFirst({
where: and( where: and(
eq(apiTokens.tokenHash, tokenHash), eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt) isNull(apiTokens.revokedAt),
), ),
}); });
if (!tokenRecord) { if (!tokenRecord) {
return NextResponse.json( return NextResponse.json(
{ error: "Token inválido ou revogado" }, { error: "Token inválido ou revogado" },
{ status: 401 } { status: 401 },
); );
} }
@@ -77,7 +77,7 @@ export async function POST(request: Request) {
if (!checkRateLimit(tokenRecord.userId)) { if (!checkRateLimit(tokenRecord.userId)) {
return NextResponse.json( return NextResponse.json(
{ error: "Limite de requisições excedido", retryAfter: 60 }, { error: "Limite de requisições excedido", retryAfter: 60 },
{ status: 429 } { status: 429 },
); );
} }
@@ -92,22 +92,21 @@ export async function POST(request: Request) {
userId: tokenRecord.userId, userId: tokenRecord.userId,
sourceApp: data.sourceApp, sourceApp: data.sourceApp,
sourceAppName: data.sourceAppName, sourceAppName: data.sourceAppName,
deviceId: data.deviceId,
originalTitle: data.originalTitle, originalTitle: data.originalTitle,
originalText: data.originalText, originalText: data.originalText,
notificationTimestamp: data.notificationTimestamp, notificationTimestamp: data.notificationTimestamp,
parsedName: data.parsedName, parsedName: data.parsedName,
parsedAmount: data.parsedAmount?.toString(), parsedAmount: data.parsedAmount?.toString(),
parsedDate: data.parsedDate,
parsedTransactionType: data.parsedTransactionType, parsedTransactionType: data.parsedTransactionType,
status: "pending", status: "pending",
}) })
.returning({ id: inboxItems.id }); .returning({ id: inboxItems.id });
// Atualizar último uso do token // Atualizar último uso do token
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() const clientIp =
|| request.headers.get("x-real-ip") request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|| null; request.headers.get("x-real-ip") ||
null;
await db await db
.update(apiTokens) .update(apiTokens)
@@ -123,20 +122,20 @@ export async function POST(request: Request) {
clientId: data.clientId, clientId: data.clientId,
message: "Notificação recebida", message: "Notificação recebida",
}, },
{ status: 201 } { status: 201 },
); );
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
return NextResponse.json( return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" }, { error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 } { status: 400 },
); );
} }
console.error("[API] Error creating inbox item:", error); console.error("[API] Error creating inbox item:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Erro ao processar notificação" }, { error: "Erro ao processar notificação" },
{ status: 500 } { status: 500 },
); );
} }
} }

View File

@@ -1,122 +0,0 @@
"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 {
RiCheckLine,
RiDeleteBinLine,
RiEyeLine,
RiMoreLine,
RiSmartphoneLine,
} from "@remixicon/react";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
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">
<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.originalTitle && (
<p className="font-medium">{item.originalTitle}</p>
)}
<p className="whitespace-pre-wrap 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>
</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

@@ -2,12 +2,7 @@
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { CardContent, CardDescription, CardHeader } from "@/components/ui/card";
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@@ -16,8 +11,6 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { getPaymentMethodIcon } from "@/lib/utils/icons";
import { parseLocalDateString } from "@/lib/utils/date";
import { import {
currencyFormatter, currencyFormatter,
formatCondition, formatCondition,
@@ -25,6 +18,8 @@ import {
formatPeriod, formatPeriod,
getTransactionBadgeVariant, getTransactionBadgeVariant,
} from "@/lib/lancamentos/formatting-helpers"; } from "@/lib/lancamentos/formatting-helpers";
import { parseLocalDateString } from "@/lib/utils/date";
import { getPaymentMethodIcon } from "@/lib/utils/icons";
import { InstallmentTimeline } from "../shared/installment-timeline"; import { InstallmentTimeline } from "../shared/installment-timeline";
import type { LancamentoItem } from "../types"; import type { LancamentoItem } from "../types";
@@ -59,7 +54,7 @@ export function LancamentoDetailsDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="p-0 sm:max-w-xl"> <DialogContent className="p-0 sm:max-w-xl">
<Card className="gap-2 space-y-4"> <div className="gap-2 space-y-4 py-6">
<CardHeader className="flex flex-row items-start border-b"> <CardHeader className="flex flex-row items-start border-b">
<div> <div>
<DialogTitle className="group flex items-center gap-2 text-lg"> <DialogTitle className="group flex items-center gap-2 text-lg">
@@ -112,7 +107,7 @@ export function LancamentoDetailsDialog({
variant={getTransactionBadgeVariant( variant={getTransactionBadgeVariant(
lancamento.categoriaName === "Saldo inicial" lancamento.categoriaName === "Saldo inicial"
? "Saldo inicial" ? "Saldo inicial"
: lancamento.transactionType : lancamento.transactionType,
)} )}
> >
{lancamento.categoriaName === "Saldo inicial" {lancamento.categoriaName === "Saldo inicial"
@@ -148,7 +143,9 @@ export function LancamentoDetailsDialog({
{isInstallment && ( {isInstallment && (
<li className="mt-4"> <li className="mt-4">
<InstallmentTimeline <InstallmentTimeline
purchaseDate={parseLocalDateString(lancamento.purchaseDate)} purchaseDate={parseLocalDateString(
lancamento.purchaseDate,
)}
currentInstallment={parcelaAtual} currentInstallment={parcelaAtual}
totalInstallments={totalParcelas} totalInstallments={totalParcelas}
period={lancamento.period} period={lancamento.period}
@@ -194,7 +191,7 @@ export function LancamentoDetailsDialog({
</DialogClose> </DialogClose>
</DialogFooter> </DialogFooter>
</CardContent> </CardContent>
</Card> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -0,0 +1,153 @@
"use client";
import MoneyValues from "@/components/money-values";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils/ui";
import {
RiCheckLine,
RiDeleteBinLine,
RiEyeLine,
RiMoreLine,
} from "@remixicon/react";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
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 amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
const isReceita = item.parsedTransactionType === "Receita";
// O timestamp vem do app Android em horário local mas salvo como UTC
// Precisamos interpretar o valor UTC como se fosse horário de Brasília
const rawDate = new Date(item.notificationTimestamp);
// Ajusta adicionando o offset de Brasília (3 horas) para corrigir o cálculo do "há X tempo"
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
const notificationDate = new Date(rawDate.getTime() + BRASILIA_OFFSET_MS);
const timeAgo = formatDistanceToNow(notificationDate, {
addSuffix: true,
locale: ptBR,
});
// Para exibição, usa UTC pois o valor já representa horário de Brasília
const formattedTime = new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
timeZone: "UTC",
}).format(rawDate);
return (
<Card className="flex flex-col gap-0 py-0 h-54">
{/* Header com app e valor */}
<CardHeader className="pt-4">
<div className="flex items-center justify-between">
<CardTitle className="text-md">
{item.sourceAppName || item.sourceApp}
{" "}
<span className="text-xs font-normal text-muted-foreground">
{timeAgo}
</span>
</CardTitle>
{amount !== null && (
<MoneyValues
amount={isReceita ? amount : -amount}
showPositiveSign={isReceita}
className={cn(
"text-sm",
isReceita
? "text-green-600 dark:text-green-400"
: "text-foreground"
)}
/>
)}
</div>
<CardAction>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 -mr-2 -mt-1"
>
<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>
</CardAction>
</CardHeader>
{/* Conteúdo da notificação */}
<CardContent className="flex-1 py-2">
{item.originalTitle && (
<p className="mb-1 text-sm font-bold">{item.originalTitle}</p>
)}
<p className="whitespace-pre-wrap text-sm text-muted-foreground line-clamp-4">
{item.originalText}
</p>
</CardContent>
{/* Botões de ação */}
<CardFooter className="gap-2 pt-3 pb-4">
<Button size="sm" className="flex-1" onClick={() => onProcess(item)}>
<RiCheckLine className="mr-1.5 size-4" />
Processar
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onDiscard(item)}
className="text-muted-foreground hover:text-destructive hover:border-destructive"
>
<RiDeleteBinLine className="size-4" />
</Button>
</CardFooter>
</Card>
);
}

View File

@@ -1,5 +1,7 @@
"use client"; "use client";
import MoneyValues from "@/components/money-values";
import { TypeBadge } from "@/components/type-badge";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -11,6 +13,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils/ui";
import { format } from "date-fns"; import { format } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import type { InboxItem } from "./types"; import type { InboxItem } from "./types";
@@ -28,12 +31,8 @@ export function InboxDetailsDialog({
}: InboxDetailsDialogProps) { }: InboxDetailsDialogProps) {
if (!item) return null; if (!item) return null;
const formattedAmount = item.parsedAmount const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
? new Intl.NumberFormat("pt-BR", { const isReceita = item.parsedTransactionType === "Receita";
style: "currency",
currency: "BRL",
}).format(parseFloat(item.parsedAmount))
: "Não extraído";
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -45,10 +44,11 @@ export function InboxDetailsDialog({
<div className="space-y-4"> <div className="space-y-4">
{/* Dados da fonte */} {/* Dados da fonte */}
<div> <div>
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
Fonte
</h4>
<div className="grid gap-2 text-sm"> <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"> <div className="flex justify-between">
<span className="text-muted-foreground">App</span> <span className="text-muted-foreground">App</span>
<span>{item.sourceAppName || item.sourceApp}</span> <span>{item.sourceAppName || item.sourceApp}</span>
@@ -57,12 +57,6 @@ export function InboxDetailsDialog({
<span className="text-muted-foreground">Package</span> <span className="text-muted-foreground">Package</span>
<span className="font-mono text-xs">{item.sourceApp}</span> <span className="font-mono text-xs">{item.sourceApp}</span>
</div> </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>
</div> </div>
@@ -70,58 +64,51 @@ export function InboxDetailsDialog({
{/* Texto original */} {/* Texto original */}
<div> <div>
<h4 className="mb-2 text-sm font-medium text-muted-foreground"> <h4 className="mb-1 text-sm font-medium text-muted-foreground">
Notificação Original Notificação Original
</h4> </h4>
{item.originalTitle && ( {item.originalTitle && (
<p className="mb-1 font-medium">{item.originalTitle}</p> <p className="mb-1 font-medium">{item.originalTitle}</p>
)} )}
<p className="text-sm">{item.originalText}</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> </div>
<Separator /> <Separator />
{/* Dados parseados */} {/* Dados parseados */}
<div> <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="grid gap-2 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Estabelecimento</span> <span className="text-muted-foreground">Estabelecimento</span>
<span>{item.parsedName || "Não extraído"}</span> <span>{item.parsedName || "Não extraído"}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between items-center">
<span className="text-muted-foreground">Valor</span> <span className="text-muted-foreground">Valor</span>
<Badge {amount !== null ? (
variant={ <MoneyValues
item.parsedTransactionType === "Receita" amount={isReceita ? amount : -amount}
? "success" showPositiveSign={isReceita}
: "destructive" className={cn(
} "text-sm",
> isReceita
{formattedAmount} ? "text-green-600 dark:text-green-400"
</Badge> : "text-foreground",
)}
/>
) : (
<span className="text-muted-foreground">Não extraído</span>
)}
</div> </div>
{item.parsedDate && ( <div className="flex justify-between items-center">
<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>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Tipo</span> <span className="text-muted-foreground">Tipo</span>
<span>{item.parsedTransactionType || "Não identificado"}</span> {item.parsedTransactionType ? (
<TypeBadge type={item.parsedTransactionType} />
) : (
<span className="text-muted-foreground">
Não identificado
</span>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -130,14 +117,7 @@ export function InboxDetailsDialog({
{/* Metadados */} {/* Metadados */}
<div> <div>
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
Metadados
</h4>
<div className="grid gap-2 text-sm"> <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"> <div className="flex justify-between">
<span className="text-muted-foreground">Status</span> <span className="text-muted-foreground">Status</span>
<Badge variant="outline">{item.status}</Badge> <Badge variant="outline">{item.status}</Badge>
@@ -154,7 +134,9 @@ export function InboxDetailsDialog({
<DialogFooter> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
<Button>Fechar</Button> <Button className="w-full mt-2" type="button">
Entendi
</Button>
</DialogClose> </DialogClose>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -3,7 +3,7 @@
import { import {
discardInboxItemAction, discardInboxItemAction,
markInboxAsProcessedAction, markInboxAsProcessedAction,
} from "@/app/(dashboard)/caixa-de-entrada/actions"; } from "@/app/(dashboard)/pre-lancamentos/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog"; import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
@@ -122,17 +122,16 @@ export function InboxPage({
}, [itemToProcess]); }, [itemToProcess]);
// Prepare default values from inbox item // Prepare default values from inbox item
// Use parsedDate if available, otherwise fall back to notificationTimestamp const getDateString = (
const getDateString = (date: Date | string | null | undefined): string | null => { date: Date | string | null | undefined,
): string | null => {
if (!date) return null; if (!date) return null;
if (typeof date === "string") return date.slice(0, 10); if (typeof date === "string") return date.slice(0, 10);
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
}; };
const defaultPurchaseDate = const defaultPurchaseDate =
getDateString(itemToProcess?.parsedDate) ?? getDateString(itemToProcess?.notificationTimestamp) ?? null;
getDateString(itemToProcess?.notificationTimestamp) ??
null;
const defaultName = itemToProcess?.parsedName ?? null; const defaultName = itemToProcess?.parsedName ?? null;
@@ -150,7 +149,7 @@ export function InboxPage({
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12"> <Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState <EmptyState
media={<RiInboxLine className="size-6 text-primary" />} media={<RiInboxLine className="size-6 text-primary" />}
title="Caixa de entrada vazia" title="Nenhum pré-lançamento"
description="As notificações capturadas pelo app OpenSheets Companion aparecerão aqui para você processar." description="As notificações capturadas pelo app OpenSheets Companion aparecerão aqui para você processar."
/> />
</Card> </Card>

View File

@@ -1,5 +1,5 @@
/** /**
* Types for Caixa de Entrada (Inbox) feature * Types for Pré-Lançamentos feature
*/ */
import type { SelectOption as LancamentoSelectOption } from "@/components/lancamentos/types"; import type { SelectOption as LancamentoSelectOption } from "@/components/lancamentos/types";
@@ -8,19 +8,16 @@ export interface InboxItem {
id: string; id: string;
sourceApp: string; sourceApp: string;
sourceAppName: string | null; sourceAppName: string | null;
deviceId: string | null;
originalTitle: string | null; originalTitle: string | null;
originalText: string; originalText: string;
notificationTimestamp: Date; notificationTimestamp: Date;
parsedName: string | null; parsedName: string | null;
parsedAmount: string | null; parsedAmount: string | null;
parsedDate: Date | null;
parsedTransactionType: string | null; parsedTransactionType: string | null;
status: string; status: string;
lancamentoId: string | null; lancamentoId: string | null;
processedAt: Date | null; processedAt: Date | null;
discardedAt: Date | null; discardedAt: Date | null;
discardReason: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -41,7 +38,6 @@ export interface ProcessInboxInput {
export interface DiscardInboxInput { export interface DiscardInboxInput {
inboxItemId: string; inboxItemId: string;
reason?: string;
} }
// Re-export the lancamentos SelectOption for use in inbox components // Re-export the lancamentos SelectOption for use in inbox components

View File

@@ -14,7 +14,7 @@ import {
useSidebar, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import * as React from "react"; import * as React from "react";
import { createSidebarNavData, type PagadorLike } from "./nav-link"; import { createSidebarNavData, type PagadorLike, type SidebarNavOptions } from "./nav-link";
type AppUser = { type AppUser = {
id: string; id: string;
@@ -27,12 +27,14 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
user: AppUser; user: AppUser;
pagadorAvatarUrl: string | null; pagadorAvatarUrl: string | null;
pagadores: PagadorLike[]; pagadores: PagadorLike[];
preLancamentosCount?: number;
} }
export function AppSidebar({ export function AppSidebar({
user, user,
pagadorAvatarUrl, pagadorAvatarUrl,
pagadores, pagadores,
preLancamentosCount = 0,
...props ...props
}: AppSidebarProps) { }: AppSidebarProps) {
if (!user) { if (!user) {
@@ -40,8 +42,8 @@ export function AppSidebar({
} }
const navigation = React.useMemo( const navigation = React.useMemo(
() => createSidebarNavData(pagadores), () => createSidebarNavData({ pagadores, preLancamentosCount }),
[pagadores] [pagadores, preLancamentosCount]
); );
return ( return (

View File

@@ -25,6 +25,7 @@ export type SidebarSubItem = {
isShared?: boolean; isShared?: boolean;
key?: string; key?: string;
icon?: RemixiconComponentType; icon?: RemixiconComponentType;
badge?: number;
}; };
export type SidebarItem = { export type SidebarItem = {
@@ -56,7 +57,13 @@ export interface PagadorLike {
canEdit?: boolean; canEdit?: boolean;
} }
export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData { export interface SidebarNavOptions {
pagadores: PagadorLike[];
preLancamentosCount?: number;
}
export function createSidebarNavData(options: SidebarNavOptions): SidebarNavData {
const { pagadores, preLancamentosCount = 0 } = options;
const pagadorItems = pagadores const pagadorItems = pagadores
.map((pagador) => ({ .map((pagador) => ({
title: pagador.name?.trim().length title: pagador.name?.trim().length
@@ -88,15 +95,19 @@ 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",
icon: RiArrowLeftRightLine, icon: RiArrowLeftRightLine,
items: [
{
title: "Pré-Lançamentos",
url: "/pre-lancamentos",
key: "pre-lancamentos",
icon: RiInboxLine,
badge: preLancamentosCount > 0 ? preLancamentosCount : undefined,
},
],
}, },
{ {
title: "Calendário", title: "Calendário",

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
@@ -40,6 +41,7 @@ type NavItem = {
isShared?: boolean; isShared?: boolean;
key?: string; key?: string;
icon?: RemixiconComponentType; icon?: RemixiconComponentType;
badge?: number;
}[]; }[];
}; };
@@ -181,6 +183,11 @@ export function NavMain({ sections }: { sections: NavSection[] }) {
</Avatar> </Avatar>
) : null} ) : null}
<span>{subItem.title}</span> <span>{subItem.title}</span>
{subItem.badge ? (
<Badge variant="destructive" className="ml-auto h-5 min-w-5 px-1.5 text-xs">
{subItem.badge}
</Badge>
) : null}
{subItem.isShared ? ( {subItem.isShared ? (
<RiUserSharedLine className="size-3.5 text-muted-foreground" /> <RiUserSharedLine className="size-3.5 text-muted-foreground" />
) : null} ) : null}

View File

@@ -464,7 +464,6 @@ export const inboxItems = pgTable(
// Informações da fonte // Informações da fonte
sourceApp: text("source_app").notNull(), // Ex: "com.nu.production" sourceApp: text("source_app").notNull(), // Ex: "com.nu.production"
sourceAppName: text("source_app_name"), // Ex: "Nubank" sourceAppName: text("source_app_name"), // Ex: "Nubank"
deviceId: text("device_id"), // Identificador do dispositivo
// Dados originais da notificação // Dados originais da notificação
originalTitle: text("original_title"), originalTitle: text("original_title"),
@@ -477,7 +476,6 @@ export const inboxItems = pgTable(
// Dados parseados (editáveis pelo usuário antes de processar) // Dados parseados (editáveis pelo usuário antes de processar)
parsedName: text("parsed_name"), // Nome do estabelecimento parsedName: text("parsed_name"), // Nome do estabelecimento
parsedAmount: numeric("parsed_amount", { precision: 12, scale: 2 }), parsedAmount: numeric("parsed_amount", { precision: 12, scale: 2 }),
parsedDate: date("parsed_date", { mode: "date" }),
parsedTransactionType: text("parsed_transaction_type"), // Despesa, Receita parsedTransactionType: text("parsed_transaction_type"), // Despesa, Receita
// Status de processamento // Status de processamento
@@ -489,9 +487,14 @@ export const inboxItems = pgTable(
}), }),
// Metadados de processamento // Metadados de processamento
processedAt: timestamp("processed_at", { mode: "date", withTimezone: true }), processedAt: timestamp("processed_at", {
discardedAt: timestamp("discarded_at", { mode: "date", withTimezone: true }), mode: "date",
discardReason: text("discard_reason"), withTimezone: true,
}),
discardedAt: timestamp("discarded_at", {
mode: "date",
withTimezone: true,
}),
// Timestamps // Timestamps
createdAt: timestamp("created_at", { mode: "date", withTimezone: true }) createdAt: timestamp("created_at", { mode: "date", withTimezone: true })

View File

@@ -0,0 +1,39 @@
CREATE TABLE "api_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"name" text NOT NULL,
"token_hash" text NOT NULL,
"token_prefix" text NOT NULL,
"last_used_at" timestamp with time zone,
"last_used_ip" text,
"expires_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "inbox_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"source_app" text NOT NULL,
"source_app_name" text,
"original_title" text,
"original_text" text NOT NULL,
"notification_timestamp" timestamp with time zone NOT NULL,
"parsed_name" text,
"parsed_amount" numeric(12, 2),
"parsed_transaction_type" text,
"status" text DEFAULT 'pending' NOT NULL,
"lancamento_id" uuid,
"processed_at" timestamp with time zone,
"discarded_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "inbox_items" ADD CONSTRAINT "inbox_items_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "inbox_items" ADD CONSTRAINT "inbox_items_lancamento_id_lancamentos_id_fk" FOREIGN KEY ("lancamento_id") REFERENCES "public"."lancamentos"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "api_tokens_token_hash_idx" ON "api_tokens" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "inbox_items_user_id_status_idx" ON "inbox_items" USING btree ("user_id","status");--> statement-breakpoint
CREATE INDEX "inbox_items_user_id_created_at_idx" ON "inbox_items" USING btree ("user_id","created_at");

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,13 @@
"when": 1769369834242, "when": 1769369834242,
"tag": "0010_lame_psynapse", "tag": "0010_lame_psynapse",
"breakpoints": true "breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns",
"breakpoints": true
} }
] ]
} }

View File

@@ -32,7 +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"], inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"],
} as const; } as const;
/** /**

View File

@@ -7,13 +7,11 @@ import { z } from "zod";
export const inboxItemSchema = z.object({ export const inboxItemSchema = z.object({
sourceApp: z.string().min(1, "sourceApp é obrigatório"), sourceApp: z.string().min(1, "sourceApp é obrigatório"),
sourceAppName: z.string().optional(), sourceAppName: z.string().optional(),
deviceId: z.string().optional(),
originalTitle: z.string().optional(), originalTitle: z.string().optional(),
originalText: z.string().min(1, "originalText é obrigatório"), originalText: z.string().min(1, "originalText é obrigatório"),
notificationTimestamp: z.string().transform((val) => new Date(val)), notificationTimestamp: z.string().transform((val) => new Date(val)),
parsedName: z.string().optional(), parsedName: z.string().optional(),
parsedAmount: z.coerce.number().optional(), parsedAmount: z.coerce.number().optional(),
parsedDate: z.string().optional().transform((val) => (val ? new Date(val) : undefined)),
parsedTransactionType: z.enum(["Despesa", "Receita"]).optional(), parsedTransactionType: z.enum(["Despesa", "Receita"]).optional(),
clientId: z.string().optional(), // ID local do app para rastreamento clientId: z.string().optional(), // ID local do app para rastreamento
}); });