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

View File

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

View File

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

View File

@@ -5,12 +5,12 @@
* Requer autenticação via API token (formato os_xxx).
*/
import { apiTokens, inboxItems } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
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 { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
// Rate limiting simples em memória
@@ -51,7 +51,7 @@ export async function POST(request: Request) {
if (!token) {
return NextResponse.json(
{ error: "Token não fornecido" },
{ status: 401 }
{ status: 401 },
);
}
@@ -59,7 +59,7 @@ export async function POST(request: Request) {
if (!token.startsWith("os_")) {
return NextResponse.json(
{ 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({
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt)
isNull(apiTokens.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token inválido ou revogado" },
{ status: 401 }
{ status: 401 },
);
}
@@ -84,7 +84,7 @@ export async function POST(request: Request) {
if (!checkRateLimit(tokenRecord.userId)) {
return NextResponse.json(
{ 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,
sourceApp: item.sourceApp,
sourceAppName: item.sourceAppName,
deviceId: item.deviceId,
originalTitle: item.originalTitle,
originalText: item.originalText,
notificationTimestamp: item.notificationTimestamp,
parsedName: item.parsedName,
parsedAmount: item.parsedAmount?.toString(),
parsedDate: item.parsedDate,
parsedTransactionType: item.parsedTransactionType,
status: "pending",
})
@@ -130,9 +128,10 @@ export async function POST(request: Request) {
}
// Atualizar último uso do token
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|| request.headers.get("x-real-ip")
|| null;
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null;
await db
.update(apiTokens)
@@ -153,20 +152,20 @@ export async function POST(request: Request) {
failed: failCount,
results,
},
{ status: 201 }
{ status: 201 },
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 }
{ status: 400 },
);
}
console.error("[API] Error creating batch inbox items:", error);
return NextResponse.json(
{ 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).
*/
import { apiTokens, inboxItems } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
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 { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
// Rate limiting simples em memória (em produção, use Redis)
@@ -44,7 +44,7 @@ export async function POST(request: Request) {
if (!token) {
return NextResponse.json(
{ error: "Token não fornecido" },
{ status: 401 }
{ status: 401 },
);
}
@@ -52,7 +52,7 @@ export async function POST(request: Request) {
if (!token.startsWith("os_")) {
return NextResponse.json(
{ 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({
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt)
isNull(apiTokens.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token inválido ou revogado" },
{ status: 401 }
{ status: 401 },
);
}
@@ -77,7 +77,7 @@ export async function POST(request: Request) {
if (!checkRateLimit(tokenRecord.userId)) {
return NextResponse.json(
{ 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,
sourceApp: data.sourceApp,
sourceAppName: data.sourceAppName,
deviceId: data.deviceId,
originalTitle: data.originalTitle,
originalText: data.originalText,
notificationTimestamp: data.notificationTimestamp,
parsedName: data.parsedName,
parsedAmount: data.parsedAmount?.toString(),
parsedDate: data.parsedDate,
parsedTransactionType: data.parsedTransactionType,
status: "pending",
})
.returning({ id: inboxItems.id });
// Atualizar último uso do token
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|| request.headers.get("x-real-ip")
|| null;
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null;
await db
.update(apiTokens)
@@ -123,20 +122,20 @@ export async function POST(request: Request) {
clientId: data.clientId,
message: "Notificação recebida",
},
{ status: 201 }
{ status: 201 },
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 }
{ status: 400 },
);
}
console.error("[API] Error creating inbox item:", error);
return NextResponse.json(
{ 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 { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { CardContent, CardDescription, CardHeader } from "@/components/ui/card";
import {
Dialog,
DialogClose,
@@ -16,8 +11,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { getPaymentMethodIcon } from "@/lib/utils/icons";
import { parseLocalDateString } from "@/lib/utils/date";
import {
currencyFormatter,
formatCondition,
@@ -25,6 +18,8 @@ import {
formatPeriod,
getTransactionBadgeVariant,
} from "@/lib/lancamentos/formatting-helpers";
import { parseLocalDateString } from "@/lib/utils/date";
import { getPaymentMethodIcon } from "@/lib/utils/icons";
import { InstallmentTimeline } from "../shared/installment-timeline";
import type { LancamentoItem } from "../types";
@@ -59,7 +54,7 @@ export function LancamentoDetailsDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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">
<div>
<DialogTitle className="group flex items-center gap-2 text-lg">
@@ -112,7 +107,7 @@ export function LancamentoDetailsDialog({
variant={getTransactionBadgeVariant(
lancamento.categoriaName === "Saldo inicial"
? "Saldo inicial"
: lancamento.transactionType
: lancamento.transactionType,
)}
>
{lancamento.categoriaName === "Saldo inicial"
@@ -148,7 +143,9 @@ export function LancamentoDetailsDialog({
{isInstallment && (
<li className="mt-4">
<InstallmentTimeline
purchaseDate={parseLocalDateString(lancamento.purchaseDate)}
purchaseDate={parseLocalDateString(
lancamento.purchaseDate,
)}
currentInstallment={parcelaAtual}
totalInstallments={totalParcelas}
period={lancamento.period}
@@ -194,7 +191,7 @@ export function LancamentoDetailsDialog({
</DialogClose>
</DialogFooter>
</CardContent>
</Card>
</div>
</DialogContent>
</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";
import MoneyValues from "@/components/money-values";
import { TypeBadge } from "@/components/type-badge";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -11,6 +13,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils/ui";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import type { InboxItem } from "./types";
@@ -28,12 +31,8 @@ export function InboxDetailsDialog({
}: 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";
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
const isReceita = item.parsedTransactionType === "Receita";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -45,10 +44,11 @@ export function InboxDetailsDialog({
<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">ID</span>
<span className="font-mono text-xs">{item.id}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">App</span>
<span>{item.sourceAppName || item.sourceApp}</span>
@@ -57,12 +57,6 @@ export function InboxDetailsDialog({
<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>
@@ -70,58 +64,51 @@ export function InboxDetailsDialog({
{/* Texto original */}
<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
</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">
<div className="flex justify-between items-center">
<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>
{amount !== null ? (
<MoneyValues
amount={isReceita ? amount : -amount}
showPositiveSign={isReceita}
className={cn(
"text-sm",
isReceita
? "text-green-600 dark:text-green-400"
: "text-foreground",
)}
<div className="flex justify-between">
/>
) : (
<span className="text-muted-foreground">Não extraído</span>
)}
</div>
<div className="flex justify-between items-center">
<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>
@@ -130,14 +117,7 @@ export function InboxDetailsDialog({
{/* 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>
@@ -154,7 +134,9 @@ export function InboxDetailsDialog({
<DialogFooter>
<DialogClose asChild>
<Button>Fechar</Button>
<Button className="w-full mt-2" type="button">
Entendi
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>

View File

@@ -3,7 +3,7 @@
import {
discardInboxItemAction,
markInboxAsProcessedAction,
} from "@/app/(dashboard)/caixa-de-entrada/actions";
} from "@/app/(dashboard)/pre-lancamentos/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { EmptyState } from "@/components/empty-state";
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
@@ -122,17 +122,16 @@ export function InboxPage({
}, [itemToProcess]);
// Prepare default values from inbox item
// Use parsedDate if available, otherwise fall back to notificationTimestamp
const getDateString = (date: Date | string | null | undefined): string | null => {
const getDateString = (
date: Date | string | null | undefined,
): string | null => {
if (!date) return null;
if (typeof date === "string") return date.slice(0, 10);
return date.toISOString().slice(0, 10);
};
const defaultPurchaseDate =
getDateString(itemToProcess?.parsedDate) ??
getDateString(itemToProcess?.notificationTimestamp) ??
null;
getDateString(itemToProcess?.notificationTimestamp) ?? 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">
<EmptyState
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."
/>
</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";
@@ -8,19 +8,16 @@ 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;
parsedTransactionType: string | null;
status: string;
lancamentoId: string | null;
processedAt: Date | null;
discardedAt: Date | null;
discardReason: string | null;
createdAt: Date;
updatedAt: Date;
}
@@ -41,7 +38,6 @@ export interface ProcessInboxInput {
export interface DiscardInboxInput {
inboxItemId: string;
reason?: string;
}
// Re-export the lancamentos SelectOption for use in inbox components

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
@@ -40,6 +41,7 @@ type NavItem = {
isShared?: boolean;
key?: string;
icon?: RemixiconComponentType;
badge?: number;
}[];
};
@@ -181,6 +183,11 @@ export function NavMain({ sections }: { sections: NavSection[] }) {
</Avatar>
) : null}
<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 ? (
<RiUserSharedLine className="size-3.5 text-muted-foreground" />
) : null}

View File

@@ -464,7 +464,6 @@ export const inboxItems = pgTable(
// Informações da fonte
sourceApp: text("source_app").notNull(), // Ex: "com.nu.production"
sourceAppName: text("source_app_name"), // Ex: "Nubank"
deviceId: text("device_id"), // Identificador do dispositivo
// Dados originais da notificação
originalTitle: text("original_title"),
@@ -477,7 +476,6 @@ export const inboxItems = pgTable(
// Dados parseados (editáveis pelo usuário antes de processar)
parsedName: text("parsed_name"), // Nome do estabelecimento
parsedAmount: numeric("parsed_amount", { precision: 12, scale: 2 }),
parsedDate: date("parsed_date", { mode: "date" }),
parsedTransactionType: text("parsed_transaction_type"), // Despesa, Receita
// Status de processamento
@@ -489,9 +487,14 @@ export const inboxItems = pgTable(
}),
// Metadados de processamento
processedAt: timestamp("processed_at", { mode: "date", withTimezone: true }),
discardedAt: timestamp("discarded_at", { mode: "date", withTimezone: true }),
discardReason: text("discard_reason"),
processedAt: timestamp("processed_at", {
mode: "date",
withTimezone: true,
}),
discardedAt: timestamp("discarded_at", {
mode: "date",
withTimezone: true,
}),
// Timestamps
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,
"tag": "0010_lame_psynapse",
"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"],
anotacoes: ["/anotacoes", "/anotacoes/arquivadas"],
lancamentos: ["/lancamentos", "/contas"],
inbox: ["/caixa-de-entrada", "/lancamentos", "/dashboard"],
inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"],
} as const;
/**

View File

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