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

@@ -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>
{amount !== null ? (
<MoneyValues
amount={isReceita ? amount : -amount}
showPositiveSign={isReceita}
className={cn(
"text-sm",
isReceita
? "text-green-600 dark:text-green-400"
: "text-foreground",
)}
/>
) : (
<span className="text-muted-foreground">Não extraído</span>
)}
</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>
)}
<div className="flex justify-between">
<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}