refactor(core): move app para src e padroniza estrutura

This commit is contained in:
Felipe Coutinho
2026-03-12 19:22:50 +00:00
parent d92e70f1b9
commit b0fbb1062a
567 changed files with 8981 additions and 5014 deletions

View File

@@ -0,0 +1,246 @@
"use client";
import {
RiArrowGoBackLine,
RiCheckLine,
RiDeleteBinLine,
RiFileList2Line,
RiMoreLine,
} from "@remixicon/react";
import { format, formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import Image from "next/image";
import MoneyValues from "@/shared/components/money-values";
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { resolveLogoSrc } from "@/shared/lib/logo";
import type { InboxItem } from "./types";
interface InboxCardProps {
item: InboxItem;
readonly?: boolean;
appLogoMap?: Record<string, string>;
onProcess?: (item: InboxItem) => void;
onDiscard?: (item: InboxItem) => void;
onViewDetails?: (item: InboxItem) => void;
onDelete?: (item: InboxItem) => void;
onRestoreToPending?: (item: InboxItem) => void | Promise<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,
appLogoMap,
onProcess,
onDiscard,
onViewDetails,
onDelete,
onRestoreToPending,
}: InboxCardProps) {
const matchedLogo = appLogoMap
? findMatchingLogo(item.sourceAppName, appLogoMap)
: null;
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 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
: 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="flex items-center gap-1.5 text-md">
{matchedLogo && (
<Image
src={matchedLogo}
alt=""
width={24}
height={24}
className="shrink-0 rounded-full"
/>
)}
{item.sourceAppName || item.sourceApp}
{" "}
<span className="text-xs font-normal text-muted-foreground">
{timeAgo}
</span>
</CardTitle>
{amount !== null && (
<MoneyValues amount={amount} className="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">
{item.originalText}
</p>
</CardContent>
{/* 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>
)}
<div className="ml-auto flex items-center gap-1">
{item.status === "discarded" && onRestoreToPending && (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onRestoreToPending(item)}
aria-label="Voltar para pendente"
title="Voltar para pendente"
>
<RiArrowGoBackLine className="size-4" />
</Button>
)}
{onDelete && (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => onDelete(item)}
>
<RiDeleteBinLine className="size-4" />
</Button>
)}
</div>
</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="icon-sm"
variant="ghost"
onClick={() => onDiscard?.(item)}
className="text-muted-foreground hover:text-destructive"
aria-label="Descartar notificação"
title="Descartar notificação"
>
<RiDeleteBinLine className="size-4" />
</Button>
</CardFooter>
)}
</Card>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Separator } from "@/shared/components/ui/separator";
import type { InboxItem } from "./types";
interface InboxDetailsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: InboxItem | null;
}
export function InboxDetailsDialog({
open,
onOpenChange,
item,
}: InboxDetailsDialogProps) {
if (!item) return null;
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Detalhes da Notificação</DialogTitle>
</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>
)}
<p className="text-sm">{item.originalText}</p>
</div>
<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">
<span className="text-muted-foreground">Valor</span>
{amount !== null ? (
<MoneyValues amount={amount} className="text-sm" />
) : (
<span className="text-muted-foreground">Não extraído</span>
)}
</div>
</div>
</div>
<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>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Criado em</span>
<span>
{format(new Date(item.createdAt), "PPpp", { locale: ptBR })}
</span>
</div>
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button">Entendi</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,451 @@
"use client";
import { RiAtLine, RiDeleteBinLine } from "@remixicon/react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import {
bulkDeleteInboxItemsAction,
deleteInboxItemAction,
discardInboxItemAction,
markInboxAsProcessedAction,
restoreDiscardedInboxItemAction,
} from "@/features/inbox/actions";
import { LancamentoDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { EmptyState } from "@/shared/components/empty-state";
import { Button } from "@/shared/components/ui/button";
import { Card } from "@/shared/components/ui/card";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
import { InboxCard } from "./inbox-card";
import { InboxDetailsDialog } from "./inbox-details-dialog";
import type { InboxItem, SelectOption } from "./types";
interface InboxPageProps {
pendingItems: InboxItem[];
processedItems: InboxItem[];
discardedItems: InboxItem[];
pagadorOptions: SelectOption[];
splitPagadorOptions: SelectOption[];
defaultPagadorId: string | null;
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
estabelecimentos: string[];
appLogoMap: Record<string, string>;
}
export function InboxPage({
pendingItems,
processedItems,
discardedItems,
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
estabelecimentos,
appLogoMap,
}: InboxPageProps) {
const [processOpen, setProcessOpen] = useState(false);
const [itemToProcess, setItemToProcess] = useState<InboxItem | null>(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const [itemDetails, setItemDetails] = useState<InboxItem | null>(null);
const [discardOpen, setDiscardOpen] = useState(false);
const [itemToDiscard, setItemToDiscard] = useState<InboxItem | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<InboxItem | null>(null);
const [restoreOpen, setRestoreOpen] = useState(false);
const [itemToRestore, setItemToRestore] = useState<InboxItem | null>(null);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [bulkDeleteStatus, setBulkDeleteStatus] = useState<
"processed" | "discarded"
>("processed");
const sortedPending = useMemo(
() =>
[...pendingItems].sort(
(a, b) =>
new Date(b.notificationTimestamp).getTime() -
new Date(a.notificationTimestamp).getTime(),
),
[pendingItems],
);
const sortedProcessed = useMemo(
() =>
[...processedItems].sort(
(a, b) =>
new Date(b.notificationTimestamp).getTime() -
new Date(a.notificationTimestamp).getTime(),
),
[processedItems],
);
const sortedDiscarded = useMemo(
() =>
[...discardedItems].sort(
(a, b) =>
new Date(b.notificationTimestamp).getTime() -
new Date(a.notificationTimestamp).getTime(),
),
[discardedItems],
);
const handleProcessOpenChange = (open: boolean) => {
setProcessOpen(open);
if (!open) {
setItemToProcess(null);
}
};
const handleDetailsOpenChange = (open: boolean) => {
setDetailsOpen(open);
if (!open) {
setItemDetails(null);
}
};
const handleDiscardOpenChange = (open: boolean) => {
setDiscardOpen(open);
if (!open) {
setItemToDiscard(null);
}
};
const handleProcessRequest = (item: InboxItem) => {
setItemToProcess(item);
setProcessOpen(true);
};
const handleDetailsRequest = (item: InboxItem) => {
setItemDetails(item);
setDetailsOpen(true);
};
const handleDiscardRequest = (item: InboxItem) => {
setItemToDiscard(item);
setDiscardOpen(true);
};
const handleDiscardConfirm = async () => {
if (!itemToDiscard) return;
const result = await discardInboxItemAction({
inboxItemId: itemToDiscard.id,
});
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
};
const handleDeleteOpenChange = (open: boolean) => {
setDeleteOpen(open);
if (!open) {
setItemToDelete(null);
}
};
const handleDeleteRequest = (item: InboxItem) => {
setItemToDelete(item);
setDeleteOpen(true);
};
const handleDeleteConfirm = async () => {
if (!itemToDelete) return;
const result = await deleteInboxItemAction({
inboxItemId: itemToDelete.id,
});
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
};
const handleRestoreOpenChange = (open: boolean) => {
setRestoreOpen(open);
if (!open) {
setItemToRestore(null);
}
};
const handleRestoreRequest = (item: InboxItem) => {
setItemToRestore(item);
setRestoreOpen(true);
};
const handleRestoreToPendingConfirm = async () => {
if (!itemToRestore) return;
const result = await restoreDiscardedInboxItemAction({
inboxItemId: itemToRestore.id,
});
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
};
const handleBulkDeleteOpenChange = (open: boolean) => {
setBulkDeleteOpen(open);
};
const handleBulkDeleteRequest = (status: "processed" | "discarded") => {
setBulkDeleteStatus(status);
setBulkDeleteOpen(true);
};
const handleBulkDeleteConfirm = async () => {
const result = await bulkDeleteInboxItemsAction({
status: bulkDeleteStatus,
});
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
};
const handleLancamentoSuccess = async () => {
if (!itemToProcess) return;
const result = await markInboxAsProcessedAction({
inboxItemId: itemToProcess.id,
});
if (result.success) {
toast.success("Notificação processada!");
} else {
toast.error(result.error);
}
};
// Prepare default values from inbox item
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?.notificationTimestamp) ?? 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={<RiAtLine className="size-6 text-primary" />}
title={message}
description="As notificações capturadas pelo app OpenMonetis 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}
onDelete={readonly ? handleDeleteRequest : undefined}
onRestoreToPending={readonly ? handleRestoreRequest : undefined}
/>
))}
</div>
);
return (
<>
<Tabs defaultValue="pending" className="w-full">
<TabsList className="grid h-auto w-full grid-cols-3 sm:inline-flex sm:h-9 sm:grid-cols-none">
<TabsTrigger
value="pending"
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
>
<span>Pendentes</span>
<span>({pendingItems.length})</span>
</TabsTrigger>
<TabsTrigger
value="processed"
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
>
<span>Processados</span>
<span>({processedItems.length})</span>
</TabsTrigger>
<TabsTrigger
value="discarded"
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
>
<span>Descartados</span>
<span>({discardedItems.length})</span>
</TabsTrigger>
</TabsList>
<TabsContent value="pending" className="mt-4">
{renderGrid(sortedPending)}
</TabsContent>
<TabsContent value="processed" className="mt-4">
{sortedProcessed.length > 0 && (
<div className="mb-4 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => handleBulkDeleteRequest("processed")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Limpar processados
</Button>
</div>
)}
{renderGrid(sortedProcessed, true)}
</TabsContent>
<TabsContent value="discarded" className="mt-4">
{sortedDiscarded.length > 0 && (
<div className="mb-4 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => handleBulkDeleteRequest("discarded")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Limpar descartados
</Button>
</div>
)}
{renderGrid(sortedDiscarded, true)}
</TabsContent>
</Tabs>
<LancamentoDialog
mode="create"
open={processOpen}
onOpenChange={handleProcessOpenChange}
pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions}
estabelecimentos={estabelecimentos}
defaultPurchaseDate={defaultPurchaseDate}
defaultName={defaultName}
defaultAmount={defaultAmount}
defaultCartaoId={matchedCartaoId}
defaultPaymentMethod={matchedCartaoId ? "Cartão de crédito" : null}
forceShowTransactionType
onSuccess={handleLancamentoSuccess}
/>
<InboxDetailsDialog
open={detailsOpen}
onOpenChange={handleDetailsOpenChange}
item={itemDetails}
/>
<ConfirmActionDialog
open={discardOpen}
onOpenChange={handleDiscardOpenChange}
title="Descartar notificação?"
description="A notificação será marcada como descartada e não aparecerá mais na lista de pendentes."
confirmLabel="Descartar"
confirmVariant="destructive"
pendingLabel="Descartando..."
onConfirm={handleDiscardConfirm}
/>
<ConfirmActionDialog
open={deleteOpen}
onOpenChange={handleDeleteOpenChange}
title="Excluir notificação?"
description="A notificação será excluída permanentemente."
confirmLabel="Excluir"
confirmVariant="destructive"
pendingLabel="Excluindo..."
onConfirm={handleDeleteConfirm}
/>
<ConfirmActionDialog
open={restoreOpen}
onOpenChange={handleRestoreOpenChange}
title="Retornar para pendentes?"
description="A notificação voltará para a lista de pendentes e poderá ser processada depois."
confirmLabel="Retornar"
pendingLabel="Retornando..."
onConfirm={handleRestoreToPendingConfirm}
/>
<ConfirmActionDialog
open={bulkDeleteOpen}
onOpenChange={handleBulkDeleteOpenChange}
title={`Limpar ${bulkDeleteStatus === "processed" ? "processados" : "descartados"}?`}
description={`Todos os itens ${bulkDeleteStatus === "processed" ? "processados" : "descartados"} serão excluídos permanentemente.`}
confirmLabel="Limpar tudo"
confirmVariant="destructive"
pendingLabel="Excluindo..."
onConfirm={handleBulkDeleteConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,21 @@
import type { SelectOption as LancamentoSelectOption } from "@/features/transactions/components/types";
export interface InboxItem {
id: string;
sourceApp: string;
sourceAppName: string | null;
originalTitle: string | null;
originalText: string;
notificationTimestamp: Date;
parsedName: string | null;
parsedAmount: string | null;
status: string;
lancamentoId: string | null;
processedAt: Date | null;
discardedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
// Re-export the lancamentos SelectOption for use in inbox components
export type SelectOption = LancamentoSelectOption;