feat(v1.4.1): tabs de histórico, logo matching e melhorias nos pré-lançamentos

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-02-15 00:06:54 +00:00
parent f50261208a
commit 4b442a907a
10 changed files with 305 additions and 98 deletions

View File

@@ -5,6 +5,27 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/), O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [1.4.1] - 2026-02-15
### Adicionado
- Abas "Pendentes", "Processados" e "Descartados" na página de pré-lançamentos (antes exibia apenas pendentes)
- Logo do cartão/conta exibido automaticamente nos cards de pré-lançamento via matching por nome do app
- Pre-fill automático do cartão de crédito ao processar pré-lançamento (match pelo nome do app)
- Badge de status e data nos cards de itens já processados/descartados (modo readonly)
### Corrigido
- `revalidateTag("dashboard", "max")` para invalidar todas as entradas de cache da tag (antes invalidava apenas a mais recente)
- Cor `--warning` ajustada para melhor contraste (mais alaranjada)
- `EstabelecimentoLogo` não precisava de `"use client"` — removido
- Fallback no cálculo de `fontSize` em `EstabelecimentoLogo`
### Alterado
- Nome do estabelecimento formatado em Title Case ao processar pré-lançamento
- Subtítulo da página de pré-lançamentos atualizado
## [1.4.0] - 2026-02-07 ## [1.4.0] - 2026-02-07
### Corrigido ### Corrigido

View File

@@ -88,6 +88,31 @@ export async function fetchCartoesForSelect(
return items; return items;
} }
export async function fetchAppLogoMap(
userId: string,
): Promise<Record<string, string>> {
const [userCartoes, userContas] = await Promise.all([
db
.select({ name: cartoes.name, logo: cartoes.logo })
.from(cartoes)
.where(eq(cartoes.userId, userId)),
db
.select({ name: contas.name, logo: contas.logo })
.from(contas)
.where(eq(contas.userId, userId)),
]);
const logoMap: Record<string, string> = {};
for (const item of [...userCartoes, ...userContas]) {
if (item.logo) {
logoMap[item.name.toLowerCase()] = item.logo;
}
}
return logoMap;
}
export async function fetchPendingInboxCount(userId: string): Promise<number> { export async function fetchPendingInboxCount(userId: string): Promise<number> {
const items = await db const items = await db
.select({ id: preLancamentos.id }) .select({ id: preLancamentos.id })

View File

@@ -15,7 +15,7 @@ export default function RootLayout({
<PageDescription <PageDescription
icon={<RiInboxLine />} icon={<RiInboxLine />}
title="Pré-Lançamentos" title="Pré-Lançamentos"
subtitle="Notificações capturadas aguardando processamento" subtitle="Notificações capturadas pelo Companion"
/> />
{children} {children}
</section> </section>

View File

@@ -1,19 +1,25 @@
import { InboxPage } from "@/components/pre-lancamentos/inbox-page"; import { InboxPage } from "@/components/pre-lancamentos/inbox-page";
import { getUserId } from "@/lib/auth/server"; import { getUserId } from "@/lib/auth/server";
import { fetchInboxDialogData, fetchInboxItems } from "./data"; import { fetchAppLogoMap, fetchInboxDialogData, fetchInboxItems } from "./data";
export default async function Page() { export default async function Page() {
const userId = await getUserId(); const userId = await getUserId();
const [items, dialogData] = await Promise.all([ const [pendingItems, processedItems, discardedItems, dialogData, appLogoMap] =
fetchInboxItems(userId, "pending"), await Promise.all([
fetchInboxDialogData(userId), fetchInboxItems(userId, "pending"),
]); fetchInboxItems(userId, "processed"),
fetchInboxItems(userId, "discarded"),
fetchInboxDialogData(userId),
fetchAppLogoMap(userId),
]);
return ( return (
<main className="flex flex-col items-start gap-6"> <main className="flex flex-col items-start gap-6">
<InboxPage <InboxPage
items={items} pendingItems={pendingItems}
processedItems={processedItems}
discardedItems={discardedItems}
pagadorOptions={dialogData.pagadorOptions} pagadorOptions={dialogData.pagadorOptions}
splitPagadorOptions={dialogData.splitPagadorOptions} splitPagadorOptions={dialogData.splitPagadorOptions}
defaultPagadorId={dialogData.defaultPagadorId} defaultPagadorId={dialogData.defaultPagadorId}
@@ -21,6 +27,7 @@ export default async function Page() {
cartaoOptions={dialogData.cartaoOptions} cartaoOptions={dialogData.cartaoOptions}
categoriaOptions={dialogData.categoriaOptions} categoriaOptions={dialogData.categoriaOptions}
estabelecimentos={dialogData.estabelecimentos} estabelecimentos={dialogData.estabelecimentos}
appLogoMap={appLogoMap}
/> />
</main> </main>
); );

View File

@@ -34,7 +34,7 @@
/* Semantic states */ /* Semantic states */
--success: oklch(55% 0.17 150); --success: oklch(55% 0.17 150);
--success-foreground: oklch(98% 0.01 150); --success-foreground: oklch(98% 0.01 150);
--warning: oklch(75.976% 0.16034 71.493); --warning: oklch(69.913% 0.1798 49.649);
--warning-foreground: oklch(20% 0.04 85); --warning-foreground: oklch(20% 0.04 85);
--info: oklch(55% 0.17 250); --info: oklch(55% 0.17 250);
--info-foreground: oklch(98% 0.01 250); --info-foreground: oklch(98% 0.01 250);
@@ -123,7 +123,7 @@
/* Semantic states */ /* Semantic states */
--success: oklch(65% 0.19 150); --success: oklch(65% 0.19 150);
--success-foreground: oklch(15% 0.02 150); --success-foreground: oklch(15% 0.02 150);
--warning: oklch(75.976% 0.16034 71.493); --warning: oklch(69.913% 0.1798 49.649);
--warning-foreground: oklch(15% 0.04 85); --warning-foreground: oklch(15% 0.04 85);
--info: oklch(65% 0.17 250); --info: oklch(65% 0.17 250);
--info-foreground: oklch(15% 0.02 250); --info-foreground: oklch(15% 0.02 250);

View File

@@ -1,5 +1,3 @@
"use client";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
interface EstabelecimentoLogoProps { interface EstabelecimentoLogoProps {
@@ -63,7 +61,7 @@ export function EstabelecimentoLogo({
style={{ style={{
width: size, width: size,
height: size, height: size,
fontSize: size * 0.4, fontSize: (size ?? 32) * 0.4,
}} }}
> >
{initials} {initials}

View File

@@ -6,9 +6,12 @@ import {
RiEyeLine, RiEyeLine,
RiMoreLine, RiMoreLine,
} from "@remixicon/react"; } from "@remixicon/react";
import { formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import Image from "next/image";
import { useMemo } from "react";
import MoneyValues from "@/components/money-values"; import MoneyValues from "@/components/money-values";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -28,17 +31,59 @@ import type { InboxItem } from "./types";
interface InboxCardProps { interface InboxCardProps {
item: InboxItem; item: InboxItem;
onProcess: (item: InboxItem) => void; readonly?: boolean;
onDiscard: (item: InboxItem) => void; appLogoMap?: Record<string, string>;
onViewDetails: (item: InboxItem) => void; onProcess?: (item: InboxItem) => void;
onDiscard?: (item: InboxItem) => void;
onViewDetails?: (item: InboxItem) => void;
}
function resolveLogoPath(logo: string): string {
if (
logo.startsWith("http") ||
logo.startsWith("data:") ||
logo.startsWith("/")
) {
return logo;
}
return `/logos/${logo}`;
}
function findMatchingLogo(
sourceAppName: string | null,
appLogoMap: Record<string, string>,
): string | null {
if (!sourceAppName) return null;
const appName = sourceAppName.toLowerCase();
// Exact match first
if (appLogoMap[appName]) return resolveLogoPath(appLogoMap[appName]);
// Partial match: card/account name contains app name or vice versa
for (const [name, logo] of Object.entries(appLogoMap)) {
if (name.includes(appName) || appName.includes(name)) {
return resolveLogoPath(logo);
}
}
return null;
} }
export function InboxCard({ export function InboxCard({
item, item,
readonly,
appLogoMap,
onProcess, onProcess,
onDiscard, onDiscard,
onViewDetails, onViewDetails,
}: InboxCardProps) { }: InboxCardProps) {
const matchedLogo = useMemo(
() =>
appLogoMap ? findMatchingLogo(item.sourceAppName, appLogoMap) : null,
[item.sourceAppName, appLogoMap],
);
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null; const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
// O timestamp vem do app Android em horário local mas salvo como UTC // O timestamp vem do app Android em horário local mas salvo como UTC
@@ -63,12 +108,32 @@ export function InboxCard({
timeZone: "UTC", timeZone: "UTC",
}).format(rawDate); }).format(rawDate);
const statusDate =
item.status === "processed"
? item.processedAt
: item.status === "discarded"
? item.discardedAt
: null;
const formattedStatusDate = statusDate
? format(new Date(statusDate), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })
: null;
return ( return (
<Card className="flex flex-col gap-0 py-0 h-54"> <Card className="flex flex-col gap-0 py-0 h-54">
{/* Header com app e valor */} {/* Header com app e valor */}
<CardHeader className="pt-4"> <CardHeader className="pt-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-md"> <CardTitle className="flex items-center gap-1.5 text-md">
{matchedLogo && (
<Image
src={matchedLogo}
alt=""
width={24}
height={24}
className="shrink-0 rounded-sm"
/>
)}
{item.sourceAppName || item.sourceApp} {item.sourceAppName || item.sourceApp}
{" "} {" "}
<span className="text-xs font-normal text-muted-foreground"> <span className="text-xs font-normal text-muted-foreground">
@@ -80,36 +145,38 @@ export function InboxCard({
)} )}
</div> </div>
<CardAction> {!readonly && (
<DropdownMenu> <CardAction>
<DropdownMenuTrigger asChild> <DropdownMenu>
<Button <DropdownMenuTrigger asChild>
variant="ghost" <Button
size="icon" variant="ghost"
className="size-7 -mr-2 -mt-1" size="icon"
> className="size-7 -mr-2 -mt-1"
<RiMoreLine className="size-4" /> >
</Button> <RiMoreLine className="size-4" />
</DropdownMenuTrigger> </Button>
<DropdownMenuContent align="end"> </DropdownMenuTrigger>
<DropdownMenuItem onClick={() => onViewDetails(item)}> <DropdownMenuContent align="end">
<RiEyeLine className="mr-2 size-4" /> <DropdownMenuItem onClick={() => onViewDetails?.(item)}>
Ver detalhes <RiEyeLine className="mr-2 size-4" />
</DropdownMenuItem> Ver detalhes
<DropdownMenuItem onClick={() => onProcess(item)}> </DropdownMenuItem>
<RiCheckLine className="mr-2 size-4" /> <DropdownMenuItem onClick={() => onProcess?.(item)}>
Processar <RiCheckLine className="mr-2 size-4" />
</DropdownMenuItem> Processar
<DropdownMenuItem </DropdownMenuItem>
onClick={() => onDiscard(item)} <DropdownMenuItem
className="text-destructive" onClick={() => onDiscard?.(item)}
> className="text-destructive"
<RiDeleteBinLine className="mr-2 size-4" /> >
Descartar <RiDeleteBinLine className="mr-2 size-4" />
</DropdownMenuItem> Descartar
</DropdownMenuContent> </DropdownMenuItem>
</DropdownMenu> </DropdownMenuContent>
</CardAction> </DropdownMenu>
</CardAction>
)}
</CardHeader> </CardHeader>
{/* Conteúdo da notificação */} {/* Conteúdo da notificação */}
@@ -122,21 +189,40 @@ export function InboxCard({
</p> </p>
</CardContent> </CardContent>
{/* Botões de ação */} {/* Botões de ação ou badge de status */}
<CardFooter className="gap-2 pt-3 pb-4"> {readonly ? (
<Button size="sm" className="flex-1" onClick={() => onProcess(item)}> <CardFooter className="gap-2 pt-3 pb-4">
<RiCheckLine className="mr-1.5 size-4" /> <Badge
Processar variant={item.status === "processed" ? "default" : "secondary"}
</Button> >
<Button {item.status === "processed" ? "Processado" : "Descartado"}
size="sm" </Badge>
variant="outline" {formattedStatusDate && (
onClick={() => onDiscard(item)} <span className="text-xs text-muted-foreground">
className="text-muted-foreground hover:text-destructive hover:border-destructive" {formattedStatusDate}
> </span>
<RiDeleteBinLine className="size-4" /> )}
</Button> </CardFooter>
</CardFooter> ) : (
<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> </Card>
); );
} }

View File

@@ -11,12 +11,15 @@ 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";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { InboxCard } from "./inbox-card"; import { InboxCard } from "./inbox-card";
import { InboxDetailsDialog } from "./inbox-details-dialog"; import { InboxDetailsDialog } from "./inbox-details-dialog";
import type { InboxItem, SelectOption } from "./types"; import type { InboxItem, SelectOption } from "./types";
interface InboxPageProps { interface InboxPageProps {
items: InboxItem[]; pendingItems: InboxItem[];
processedItems: InboxItem[];
discardedItems: InboxItem[];
pagadorOptions: SelectOption[]; pagadorOptions: SelectOption[];
splitPagadorOptions: SelectOption[]; splitPagadorOptions: SelectOption[];
defaultPagadorId: string | null; defaultPagadorId: string | null;
@@ -24,10 +27,13 @@ interface InboxPageProps {
cartaoOptions: SelectOption[]; cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[]; categoriaOptions: SelectOption[];
estabelecimentos: string[]; estabelecimentos: string[];
appLogoMap: Record<string, string>;
} }
export function InboxPage({ export function InboxPage({
items, pendingItems,
processedItems,
discardedItems,
pagadorOptions, pagadorOptions,
splitPagadorOptions, splitPagadorOptions,
defaultPagadorId, defaultPagadorId,
@@ -35,6 +41,7 @@ export function InboxPage({
cartaoOptions, cartaoOptions,
categoriaOptions, categoriaOptions,
estabelecimentos, estabelecimentos,
appLogoMap,
}: InboxPageProps) { }: InboxPageProps) {
const [processOpen, setProcessOpen] = useState(false); const [processOpen, setProcessOpen] = useState(false);
const [itemToProcess, setItemToProcess] = useState<InboxItem | null>(null); const [itemToProcess, setItemToProcess] = useState<InboxItem | null>(null);
@@ -45,14 +52,24 @@ export function InboxPage({
const [discardOpen, setDiscardOpen] = useState(false); const [discardOpen, setDiscardOpen] = useState(false);
const [itemToDiscard, setItemToDiscard] = useState<InboxItem | null>(null); const [itemToDiscard, setItemToDiscard] = useState<InboxItem | null>(null);
const sortedItems = useMemo( const sortByTimestamp = (list: InboxItem[]) =>
() => [...list].sort(
[...items].sort( (a, b) =>
(a, b) => new Date(b.notificationTimestamp).getTime() -
new Date(b.notificationTimestamp).getTime() - new Date(a.notificationTimestamp).getTime(),
new Date(a.notificationTimestamp).getTime(), );
),
[items], const sortedPending = useMemo(
() => sortByTimestamp(pendingItems),
[pendingItems],
);
const sortedProcessed = useMemo(
() => sortByTimestamp(processedItems),
[processedItems],
);
const sortedDiscarded = useMemo(
() => sortByTimestamp(discardedItems),
[discardedItems],
); );
const handleProcessOpenChange = useCallback((open: boolean) => { const handleProcessOpenChange = useCallback((open: boolean) => {
@@ -133,37 +150,88 @@ export function InboxPage({
const defaultPurchaseDate = const defaultPurchaseDate =
getDateString(itemToProcess?.notificationTimestamp) ?? null; getDateString(itemToProcess?.notificationTimestamp) ?? null;
const defaultName = itemToProcess?.parsedName ?? null; const defaultName = itemToProcess?.parsedName
? itemToProcess.parsedName
.toLowerCase()
.replace(/\b\w/g, (char) => char.toUpperCase())
: null;
const defaultAmount = itemToProcess?.parsedAmount const defaultAmount = itemToProcess?.parsedAmount
? String(Math.abs(Number(itemToProcess.parsedAmount))) ? String(Math.abs(Number(itemToProcess.parsedAmount)))
: null; : null;
// Match sourceAppName with a cartão to pre-fill card select
const matchedCartaoId = useMemo(() => {
const appName = itemToProcess?.sourceAppName?.toLowerCase();
if (!appName) return null;
for (const option of cartaoOptions) {
const label = option.label.toLowerCase();
if (label.includes(appName) || appName.includes(label)) {
return option.value;
}
}
return null;
}, [itemToProcess?.sourceAppName, cartaoOptions]);
const renderEmptyState = (message: string) => (
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState
media={<RiInboxLine className="size-6 text-primary" />}
title={message}
description="As notificações capturadas pelo app OpenSheets Companion aparecerão aqui. Saiba mais em Ajustes > Companion."
/>
</Card>
);
const renderGrid = (list: InboxItem[], readonly?: boolean) =>
list.length === 0 ? (
renderEmptyState(
readonly
? "Nenhuma notificação nesta aba"
: "Nenhum pré-lançamento pendente",
)
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{list.map((item) => (
<InboxCard
key={item.id}
item={item}
readonly={readonly}
appLogoMap={appLogoMap}
onProcess={readonly ? undefined : handleProcessRequest}
onDiscard={readonly ? undefined : handleDiscardRequest}
onViewDetails={readonly ? undefined : handleDetailsRequest}
/>
))}
</div>
);
return ( return (
<> <>
<div className="flex w-full flex-col gap-6"> <Tabs defaultValue="pending" className="w-full">
{sortedItems.length === 0 ? ( <TabsList>
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12"> <TabsTrigger value="pending">
<EmptyState Pendentes ({pendingItems.length})
media={<RiInboxLine className="size-6 text-primary" />} </TabsTrigger>
title="Nenhum pré-lançamento" <TabsTrigger value="processed">
description="As notificações capturadas pelo app OpenSheets Companion aparecerão aqui para você processar. Saiba mais sobre o app em Ajustes > Companion." Processados ({processedItems.length})
/> </TabsTrigger>
</Card> <TabsTrigger value="discarded">
) : ( Descartados ({discardedItems.length})
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> </TabsTrigger>
{sortedItems.map((item) => ( </TabsList>
<InboxCard
key={item.id} <TabsContent value="pending" className="mt-4">
item={item} {renderGrid(sortedPending)}
onProcess={handleProcessRequest} </TabsContent>
onDiscard={handleDiscardRequest} <TabsContent value="processed" className="mt-4">
onViewDetails={handleDetailsRequest} {renderGrid(sortedProcessed, true)}
/> </TabsContent>
))} <TabsContent value="discarded" className="mt-4">
</div> {renderGrid(sortedDiscarded, true)}
)} </TabsContent>
</div> </Tabs>
<LancamentoDialog <LancamentoDialog
mode="create" mode="create"
@@ -179,6 +247,8 @@ export function InboxPage({
defaultPurchaseDate={defaultPurchaseDate} defaultPurchaseDate={defaultPurchaseDate}
defaultName={defaultName} defaultName={defaultName}
defaultAmount={defaultAmount} defaultAmount={defaultAmount}
defaultCartaoId={matchedCartaoId}
defaultPaymentMethod={matchedCartaoId ? "Cartão de crédito" : null}
forceShowTransactionType forceShowTransactionType
onSuccess={handleLancamentoSuccess} onSuccess={handleLancamentoSuccess}
/> />

View File

@@ -57,7 +57,7 @@ export function revalidateForEntity(
// Invalidate dashboard cache for financial mutations // Invalidate dashboard cache for financial mutations
if (DASHBOARD_ENTITIES.has(entity)) { if (DASHBOARD_ENTITIES.has(entity)) {
revalidateTag("dashboard"); revalidateTag("dashboard", "max");
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "opensheets", "name": "opensheets",
"version": "1.4.0", "version": "1.4.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",