feat: amplia ações e seleção em lote no inbox

This commit is contained in:
Felipe Coutinho
2026-03-16 01:14:47 +00:00
parent f4e7108119
commit 959db963b8
4 changed files with 158 additions and 129 deletions

View File

@@ -4,26 +4,30 @@ import { Skeleton } from "@/shared/components/ui/skeleton";
export default function Loading() {
return (
<main className="flex flex-col items-start gap-6">
<div className="flex w-full flex-col gap-6">
<div className="flex justify-between">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-10 w-32" />
</div>
<div className="w-full space-y-4">
{/* Tabs */}
<Skeleton className="h-9 w-72 rounded-md bg-foreground/10" />
{/* Grid de cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="p-4">
<div className="space-y-3">
<div className="flex justify-between">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-16" />
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<div className="flex gap-2 pt-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
<Card key={i} className="flex h-54 flex-col gap-0 py-0">
<div className="px-4 pt-4 pb-2">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 w-32 bg-foreground/10" />
<Skeleton className="h-4 w-16 bg-foreground/10" />
</div>
</div>
<div className="flex-1 space-y-2 px-4 py-2">
<Skeleton className="h-3 w-full bg-foreground/10" />
<Skeleton className="h-3 w-full bg-foreground/10" />
<Skeleton className="h-3 w-3/4 bg-foreground/10" />
</div>
<div className="flex gap-2 px-4 pb-4 pt-3">
<Skeleton className="h-8 flex-1 bg-foreground/10" />
<Skeleton className="h-8 w-8 bg-foreground/10" />
<Skeleton className="h-8 w-8 bg-foreground/10" />
</div>
</Card>
))}
</div>

View File

@@ -5,7 +5,6 @@ import {
RiCheckLine,
RiDeleteBinLine,
RiFileList2Line,
RiMoreLine,
} from "@remixicon/react";
import { format, formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
@@ -15,22 +14,42 @@ import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Checkbox } from "@/shared/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { resolveLogoSrc } from "@/shared/lib/logo";
import type { InboxItem } from "./types";
// O timestamp vem do app Android em horário local mas salvo como UTC.
// Adicionamos o offset de Brasília para corrigir o cálculo de "há X tempo".
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
function adjustToBrasilia(date: Date): Date {
return new Date(date.getTime() + BRASILIA_OFFSET_MS);
}
function findMatchingLogo(
sourceAppName: string | null,
appLogoMap: Record<string, string>,
): string | null {
if (!sourceAppName) return null;
const appName = sourceAppName.toLowerCase();
if (appLogoMap[appName]) return resolveLogoSrc(appLogoMap[appName]);
for (const [name, logo] of Object.entries(appLogoMap)) {
if (name.includes(appName) || appName.includes(name)) {
return resolveLogoSrc(logo);
}
}
return null;
}
interface InboxCardProps {
item: InboxItem;
readonly?: boolean;
@@ -44,27 +63,6 @@ interface InboxCardProps {
onSelectToggle?: (id: string) => void;
}
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 resolveLogoSrc(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 resolveLogoSrc(logo);
}
}
return null;
}
export function InboxCard({
item,
readonly,
@@ -83,28 +81,14 @@ export function InboxCard({
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
// 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 notificationDate = adjustToBrasilia(rawDate);
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);
const statusDate =
item.status === "processed"
? item.processedAt
@@ -118,12 +102,11 @@ export function InboxCard({
return (
<Card
className={`flex flex-col gap-0 py-0 h-54 transition-colors ${selected ? "ring-2 ring-primary" : ""}`}
className={`flex h-54 flex-col gap-0 py-0 transition-colors ${selected ? "ring-2 ring-primary" : ""}`}
>
{/* Header com app e valor */}
<CardHeader className="pt-4">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-1.5 text-md">
<div className="flex items-center justify-between gap-2">
<CardTitle className="flex min-w-0 items-center gap-1.5 text-sm">
{matchedLogo && (
<Image
src={matchedLogo}
@@ -133,64 +116,30 @@ export function InboxCard({
className="shrink-0 rounded-full"
/>
)}
{item.sourceAppName || item.sourceApp}
{" "}
<span className="text-xs font-normal text-muted-foreground">
<span className="truncate">
{item.sourceAppName || item.sourceApp}
</span>
<span className="shrink-0 text-xs font-normal text-muted-foreground">
{timeAgo}
</span>
</CardTitle>
{amount !== null && (
<MoneyValues amount={amount} className="text-sm" />
<MoneyValues amount={amount} className="shrink-0 text-sm" />
)}
</div>
{!readonly && (
<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)}>
<RiFileList2Line 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">
<p className="line-clamp-4 whitespace-pre-wrap text-sm text-muted-foreground">
{item.originalText}
</p>
</CardContent>
{/* Botões de ação ou badge de status */}
{readonly ? (
<CardFooter className="gap-2 pt-3 pb-4">
<CardFooter className="gap-2 pb-4 pt-3">
<Badge
variant={item.status === "processed" ? "default" : "secondary"}
>
@@ -201,7 +150,7 @@ export function InboxCard({
{formattedStatusDate}
</span>
)}
<div className="ml-auto flex items-center gap-1">
<div className="ml-auto flex items-center gap-2">
{item.status === "discarded" && onRestoreToPending && (
<Button
variant="ghost"
@@ -220,6 +169,7 @@ export function InboxCard({
size="icon-sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => onDelete(item)}
aria-label="Excluir notificação"
>
<RiDeleteBinLine className="size-4" />
</Button>
@@ -234,7 +184,7 @@ export function InboxCard({
</div>
</CardFooter>
) : (
<CardFooter className="gap-2 pt-3 pb-4">
<CardFooter className="gap-2 pb-4 pt-3">
<Button
size="sm"
className="flex-1"
@@ -243,6 +193,16 @@ export function InboxCard({
<RiCheckLine className="mr-1.5 size-4" />
Processar
</Button>
<Button
size="icon-sm"
variant="ghost"
onClick={() => onViewDetails?.(item)}
className="text-muted-foreground hover:text-foreground"
aria-label="Ver detalhes"
title="Ver detalhes"
>
<RiFileList2Line className="size-4" />
</Button>
<Button
size="icon-sm"
variant="ghost"

View File

@@ -16,20 +16,29 @@ import {
import { Separator } from "@/shared/components/ui/separator";
import type { InboxItem } from "./types";
const STATUS_LABELS: Record<string, string> = {
pending: "Pendente",
processed: "Processado",
discarded: "Descartado",
};
interface InboxDetailsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: InboxItem | null;
onProcess?: (item: InboxItem) => void;
}
export function InboxDetailsDialog({
open,
onOpenChange,
item,
onProcess,
}: InboxDetailsDialogProps) {
if (!item) return null;
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
const isPending = item.status === "pending";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -39,32 +48,21 @@ export function InboxDetailsDialog({
</DialogHeader>
<div className="space-y-4">
{/* Dados da fonte */}
<div>
<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>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Package</span>
<span className="font-mono text-xs">{item.sourceApp}</span>
</div>
</div>
</div>
<Separator />
{/* Texto original */}
<div>
<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>
)}
@@ -73,14 +71,13 @@ export function InboxDetailsDialog({
<Separator />
{/* Dados parseados */}
<div>
<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 items-center">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Valor</span>
{amount !== null ? (
<MoneyValues amount={amount} className="text-sm" />
@@ -93,12 +90,13 @@ export function InboxDetailsDialog({
<Separator />
{/* Metadados */}
<div>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Status</span>
<Badge variant="outline">{item.status}</Badge>
<Badge variant="outline">
{STATUS_LABELS[item.status] ?? item.status}
</Badge>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Criado em</span>
@@ -111,8 +109,21 @@ export function InboxDetailsDialog({
</div>
<DialogFooter>
{isPending && onProcess && (
<Button
type="button"
onClick={() => {
onOpenChange(false);
onProcess(item);
}}
>
Processar
</Button>
)}
<DialogClose asChild>
<Button type="button">Entendi</Button>
<Button type="button" variant="outline">
Fechar
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>

View File

@@ -231,6 +231,29 @@ export function InboxPage({
setIds(ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]);
};
const allPendingSelected =
sortedPending.length > 0 &&
selectedPendingIds.length === sortedPending.length;
const allProcessedSelected =
sortedProcessed.length > 0 &&
selectedProcessedIds.length === sortedProcessed.length;
const allDiscardedSelected =
sortedDiscarded.length > 0 &&
selectedDiscardedIds.length === sortedDiscarded.length;
const toggleSelectAllPending = () => {
if (allPendingSelected) setSelectedPendingIds([]);
else setSelectedPendingIds(sortedPending.map((item) => item.id));
};
const toggleSelectAllProcessed = () => {
if (allProcessedSelected) setSelectedProcessedIds([]);
else setSelectedProcessedIds(sortedProcessed.map((item) => item.id));
};
const toggleSelectAllDiscarded = () => {
if (allDiscardedSelected) setSelectedDiscardedIds([]);
else setSelectedDiscardedIds(sortedDiscarded.map((item) => item.id));
};
const handleSelectionBulkRequest = (
status: "pending" | "processed" | "discarded",
) => {
@@ -412,16 +435,27 @@ export function InboxPage({
</TabsList>
<TabsContent value="pending" className="mt-4">
{selectedPendingIds.length > 0 && (
<div className="mb-4 flex justify-end">
{sortedPending.length > 0 && (
<div className="mb-4 flex items-center justify-end gap-2">
<Button
variant="destructive"
variant="outline"
size="sm"
onClick={() => handleSelectionBulkRequest("pending")}
onClick={toggleSelectAllPending}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Descartar selecionados ({selectedPendingIds.length})
{allPendingSelected
? "Desselecionar todos"
: "Selecionar todos"}
</Button>
{selectedPendingIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("pending")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Descartar selecionados ({selectedPendingIds.length})
</Button>
)}
</div>
)}
{renderGrid(sortedPending, false, selectedPendingIds, (id) =>
@@ -431,6 +465,15 @@ export function InboxPage({
<TabsContent value="processed" className="mt-4">
{sortedProcessed.length > 0 && (
<div className="mb-4 flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAllProcessed}
>
{allProcessedSelected
? "Desselecionar todos"
: "Selecionar todos"}
</Button>
{selectedProcessedIds.length > 0 && (
<Button
variant="destructive"
@@ -458,6 +501,15 @@ export function InboxPage({
<TabsContent value="discarded" className="mt-4">
{sortedDiscarded.length > 0 && (
<div className="mb-4 flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAllDiscarded}
>
{allDiscardedSelected
? "Desselecionar todos"
: "Selecionar todos"}
</Button>
{selectedDiscardedIds.length > 0 && (
<Button
variant="destructive"
@@ -500,6 +552,7 @@ export function InboxPage({
defaultAmount={defaultAmount}
defaultCardId={matchedCartaoId}
defaultPaymentMethod={matchedCartaoId ? "Cartão de crédito" : null}
defaultTransactionType="Despesa"
forceShowTransactionType
onSuccess={handleLancamentoSuccess}
/>
@@ -508,6 +561,7 @@ export function InboxPage({
open={detailsOpen}
onOpenChange={handleDetailsOpenChange}
item={itemDetails}
onProcess={handleProcessRequest}
/>
<ConfirmActionDialog