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:
@@ -6,9 +6,12 @@ import {
|
||||
RiEyeLine,
|
||||
RiMoreLine,
|
||||
} from "@remixicon/react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import Image from "next/image";
|
||||
import { useMemo } from "react";
|
||||
import MoneyValues from "@/components/money-values";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -28,17 +31,59 @@ import type { InboxItem } from "./types";
|
||||
|
||||
interface InboxCardProps {
|
||||
item: InboxItem;
|
||||
onProcess: (item: InboxItem) => void;
|
||||
onDiscard: (item: InboxItem) => void;
|
||||
onViewDetails: (item: InboxItem) => void;
|
||||
readonly?: boolean;
|
||||
appLogoMap?: Record<string, string>;
|
||||
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({
|
||||
item,
|
||||
readonly,
|
||||
appLogoMap,
|
||||
onProcess,
|
||||
onDiscard,
|
||||
onViewDetails,
|
||||
}: InboxCardProps) {
|
||||
const matchedLogo = useMemo(
|
||||
() =>
|
||||
appLogoMap ? findMatchingLogo(item.sourceAppName, appLogoMap) : null,
|
||||
[item.sourceAppName, appLogoMap],
|
||||
);
|
||||
|
||||
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
|
||||
|
||||
// O timestamp vem do app Android em horário local mas salvo como UTC
|
||||
@@ -63,12 +108,32 @@ export function InboxCard({
|
||||
timeZone: "UTC",
|
||||
}).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 (
|
||||
<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">
|
||||
<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}
|
||||
{" "}
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
@@ -80,36 +145,38 @@ export function InboxCard({
|
||||
)}
|
||||
</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>
|
||||
{!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)}>
|
||||
<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 */}
|
||||
@@ -122,21 +189,40 @@ export function InboxCard({
|
||||
</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>
|
||||
{/* Botões de ação ou badge de status */}
|
||||
{readonly ? (
|
||||
<CardFooter className="gap-2 pt-3 pb-4">
|
||||
<Badge
|
||||
variant={item.status === "processed" ? "default" : "secondary"}
|
||||
>
|
||||
{item.status === "processed" ? "Processado" : "Descartado"}
|
||||
</Badge>
|
||||
{formattedStatusDate && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formattedStatusDate}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,15 @@ import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { InboxCard } from "./inbox-card";
|
||||
import { InboxDetailsDialog } from "./inbox-details-dialog";
|
||||
import type { InboxItem, SelectOption } from "./types";
|
||||
|
||||
interface InboxPageProps {
|
||||
items: InboxItem[];
|
||||
pendingItems: InboxItem[];
|
||||
processedItems: InboxItem[];
|
||||
discardedItems: InboxItem[];
|
||||
pagadorOptions: SelectOption[];
|
||||
splitPagadorOptions: SelectOption[];
|
||||
defaultPagadorId: string | null;
|
||||
@@ -24,10 +27,13 @@ interface InboxPageProps {
|
||||
cartaoOptions: SelectOption[];
|
||||
categoriaOptions: SelectOption[];
|
||||
estabelecimentos: string[];
|
||||
appLogoMap: Record<string, string>;
|
||||
}
|
||||
|
||||
export function InboxPage({
|
||||
items,
|
||||
pendingItems,
|
||||
processedItems,
|
||||
discardedItems,
|
||||
pagadorOptions,
|
||||
splitPagadorOptions,
|
||||
defaultPagadorId,
|
||||
@@ -35,6 +41,7 @@ export function InboxPage({
|
||||
cartaoOptions,
|
||||
categoriaOptions,
|
||||
estabelecimentos,
|
||||
appLogoMap,
|
||||
}: InboxPageProps) {
|
||||
const [processOpen, setProcessOpen] = useState(false);
|
||||
const [itemToProcess, setItemToProcess] = useState<InboxItem | null>(null);
|
||||
@@ -45,14 +52,24 @@ export function InboxPage({
|
||||
const [discardOpen, setDiscardOpen] = useState(false);
|
||||
const [itemToDiscard, setItemToDiscard] = useState<InboxItem | null>(null);
|
||||
|
||||
const sortedItems = useMemo(
|
||||
() =>
|
||||
[...items].sort(
|
||||
(a, b) =>
|
||||
new Date(b.notificationTimestamp).getTime() -
|
||||
new Date(a.notificationTimestamp).getTime(),
|
||||
),
|
||||
[items],
|
||||
const sortByTimestamp = (list: InboxItem[]) =>
|
||||
[...list].sort(
|
||||
(a, b) =>
|
||||
new Date(b.notificationTimestamp).getTime() -
|
||||
new Date(a.notificationTimestamp).getTime(),
|
||||
);
|
||||
|
||||
const sortedPending = useMemo(
|
||||
() => sortByTimestamp(pendingItems),
|
||||
[pendingItems],
|
||||
);
|
||||
const sortedProcessed = useMemo(
|
||||
() => sortByTimestamp(processedItems),
|
||||
[processedItems],
|
||||
);
|
||||
const sortedDiscarded = useMemo(
|
||||
() => sortByTimestamp(discardedItems),
|
||||
[discardedItems],
|
||||
);
|
||||
|
||||
const handleProcessOpenChange = useCallback((open: boolean) => {
|
||||
@@ -133,37 +150,88 @@ export function InboxPage({
|
||||
const defaultPurchaseDate =
|
||||
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
|
||||
? String(Math.abs(Number(itemToProcess.parsedAmount)))
|
||||
: 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 (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{sortedItems.length === 0 ? (
|
||||
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
||||
<EmptyState
|
||||
media={<RiInboxLine className="size-6 text-primary" />}
|
||||
title="Nenhum pré-lançamento"
|
||||
description="As notificações capturadas pelo app OpenSheets Companion aparecerão aqui para você processar. Saiba mais sobre o app em Ajustes > Companion."
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{sortedItems.map((item) => (
|
||||
<InboxCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onProcess={handleProcessRequest}
|
||||
onDiscard={handleDiscardRequest}
|
||||
onViewDetails={handleDetailsRequest}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Tabs defaultValue="pending" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="pending">
|
||||
Pendentes ({pendingItems.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="processed">
|
||||
Processados ({processedItems.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="discarded">
|
||||
Descartados ({discardedItems.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pending" className="mt-4">
|
||||
{renderGrid(sortedPending)}
|
||||
</TabsContent>
|
||||
<TabsContent value="processed" className="mt-4">
|
||||
{renderGrid(sortedProcessed, true)}
|
||||
</TabsContent>
|
||||
<TabsContent value="discarded" className="mt-4">
|
||||
{renderGrid(sortedDiscarded, true)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<LancamentoDialog
|
||||
mode="create"
|
||||
@@ -179,6 +247,8 @@ export function InboxPage({
|
||||
defaultPurchaseDate={defaultPurchaseDate}
|
||||
defaultName={defaultName}
|
||||
defaultAmount={defaultAmount}
|
||||
defaultCartaoId={matchedCartaoId}
|
||||
defaultPaymentMethod={matchedCartaoId ? "Cartão de crédito" : null}
|
||||
forceShowTransactionType
|
||||
onSuccess={handleLancamentoSuccess}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user