refactor(lista): componentizar inbox e tabela de lançamentos

This commit is contained in:
Felipe Coutinho
2026-04-03 18:10:58 +00:00
parent 1b4dfaaba7
commit 5c4995961c
10 changed files with 1522 additions and 1414 deletions

View File

@@ -0,0 +1,153 @@
import { RiDeleteBinLine } from "@remixicon/react";
import Image from "next/image";
import { Button } from "@/shared/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { resolveLogoSrc } from "@/shared/lib/logo";
import type { InboxItem, InboxStatus } from "./types";
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
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;
}
type InboxBulkActionsProps = {
status: InboxStatus;
items: InboxItem[];
activeApp: string | null;
appFilterOptions: string[];
selectedIds: string[];
allSelected: boolean;
appLogoMap: Record<string, string>;
onAppChange: (app: string) => void;
onToggleSelectAll: () => void;
onSelectionBulkRequest: (status: InboxStatus) => void;
onBulkDeleteRequest: (status: "processed" | "discarded") => void;
};
export function InboxBulkActions({
status,
items,
activeApp,
appFilterOptions,
selectedIds,
allSelected,
appLogoMap,
onAppChange,
onToggleSelectAll,
onSelectionBulkRequest,
onBulkDeleteRequest,
}: InboxBulkActionsProps) {
const getAppLogo = (appName: string | null) =>
findMatchingLogo(appName, appLogoMap) ?? DEFAULT_INBOX_APP_LOGO;
const appFilter =
appFilterOptions.length > 0 ? (
<Select value={activeApp ?? "all"} onValueChange={onAppChange}>
<SelectTrigger className="w-[190px]">
<SelectValue>
<span className="flex min-w-0 items-center gap-2">
<div className="relative size-5 shrink-0 overflow-hidden rounded-full">
<Image
src={
activeApp ? getAppLogo(activeApp) : DEFAULT_INBOX_APP_LOGO
}
alt=""
fill
sizes="20px"
className="object-cover"
/>
</div>
<span className="truncate">{activeApp ?? "Todos"}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<span className="flex items-center gap-2">
<div className="relative size-5 shrink-0 overflow-hidden rounded-full">
<Image
src={DEFAULT_INBOX_APP_LOGO}
alt=""
fill
sizes="20px"
className="object-cover"
/>
</div>
<span>Todos</span>
</span>
</SelectItem>
{appFilterOptions.map((app) => (
<SelectItem key={app} value={app}>
<span className="flex min-w-0 items-center gap-2">
<div className="relative size-5 shrink-0 overflow-hidden rounded-full">
<Image
src={getAppLogo(app)}
alt=""
fill
sizes="20px"
className="object-cover"
/>
</div>
<span className="truncate">{app}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : null;
return (
<div className="mb-4 flex flex-wrap items-center gap-2">
{appFilter}
{items.length > 0 ? (
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onToggleSelectAll}>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => onSelectionBulkRequest(status)}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
{status === "pending"
? `Descartar selecionados (${selectedIds.length})`
: `Excluir selecionados (${selectedIds.length})`}
</Button>
)}
{(status === "processed" || status === "discarded") && (
<Button
variant="outline"
size="sm"
onClick={() => onBulkDeleteRequest(status)}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
{status === "processed"
? "Limpar processados"
: "Limpar descartados"}
</Button>
)}
</div>
) : null}
</div>
);
}

View File

@@ -117,13 +117,15 @@ export const InboxCard = memo(function InboxCard({
className="shrink-0"
/>
)}
<Image
src={displayLogo}
alt=""
width={32}
height={32}
className="shrink-0 rounded-full"
/>
<div className="relative size-8 shrink-0 overflow-hidden rounded-full">
<Image
src={displayLogo}
alt=""
fill
sizes="32px"
className="object-cover"
/>
</div>
<span className="truncate">
{item.sourceAppName || item.sourceApp}
</span>

View File

@@ -0,0 +1,133 @@
import { RiAtLine, RiCalendarEventLine } from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { EmptyState } from "@/shared/components/empty-state";
import { Card } from "@/shared/components/ui/card";
import { InboxCard } from "./inbox-card";
import type { InboxItem } from "./types";
// O Companion envia hora local de Brasília com 'Z' literal (não converte para UTC).
// Por isso, o timestamp armazenado no DB já tem a data correta de Brasília como componente UTC.
// Basta fatiar o ISO string sem nenhum ajuste para obter a data de Brasília do item.
function getItemDateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
// Para "hoje" e "ontem", precisamos da data real de Brasília (UTC-3).
function getBrasiliaDateKey(date: Date): string {
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
return new Date(date.getTime() - BRASILIA_OFFSET_MS)
.toISOString()
.slice(0, 10);
}
function getGroupLabel(dateKey: string): string {
const now = new Date();
const todayKey = getBrasiliaDateKey(now);
const yesterdayKey = getBrasiliaDateKey(
new Date(now.getTime() - 24 * 60 * 60 * 1000),
);
if (dateKey === todayKey) return "Hoje";
if (dateKey === yesterdayKey) return "Ontem";
const [year, month, day] = dateKey.split("-").map(Number);
return format(new Date(year, (month ?? 1) - 1, day), "d 'de' MMMM", {
locale: ptBR,
});
}
function groupItemsByDay(
items: InboxItem[],
): { label: string; items: InboxItem[] }[] {
const groups = new Map<string, InboxItem[]>();
for (const item of items) {
const key = getItemDateKey(new Date(item.notificationTimestamp));
const group = groups.get(key);
if (group) {
group.push(item);
} else {
groups.set(key, [item]);
}
}
const sortedKeys = [...groups.keys()].sort((a, b) => b.localeCompare(a));
return sortedKeys.map((key) => ({
label: getGroupLabel(key),
items: groups.get(key) ?? [],
}));
}
type InboxItemsListProps = {
items: InboxItem[];
readonly?: boolean;
activeApp: string | null;
appLogoMap: Record<string, string>;
selectedIds: string[];
onProcess?: (item: InboxItem) => void;
onDiscard?: (item: InboxItem) => void;
onViewDetails?: (item: InboxItem) => void;
onDelete?: (item: InboxItem) => void;
onRestoreToPending?: (item: InboxItem) => void;
onSelectToggle: (id: string) => void;
};
export function InboxItemsList({
items,
readonly,
activeApp,
appLogoMap,
selectedIds,
onProcess,
onDiscard,
onViewDetails,
onDelete,
onRestoreToPending,
onSelectToggle,
}: InboxItemsListProps) {
if (items.length === 0) {
const message = activeApp
? "Nenhuma notificação deste app"
: readonly
? "Nenhuma notificação nesta aba"
: "Nenhum pré-lançamento pendente";
return (
<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 groups = groupItemsByDay(items);
return (
<div className="space-y-6">
{groups.map((group) => (
<div key={group.label}>
<div className="mb-3 flex items-center gap-1 text-muted-foreground">
<RiCalendarEventLine className="size-3.5 shrink-0" />
<p className="text-sm font-medium">{group.label}</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{group.items.map((item) => (
<InboxCard
key={item.id}
item={item}
readonly={readonly}
appLogoMap={appLogoMap}
onProcess={readonly ? undefined : onProcess}
onDiscard={readonly ? undefined : onDiscard}
onViewDetails={readonly ? undefined : onViewDetails}
onDelete={readonly ? onDelete : undefined}
onRestoreToPending={readonly ? onRestoreToPending : undefined}
selected={selectedIds.includes(item.id)}
onSelectToggle={onSelectToggle}
/>
))}
</div>
</div>
))}
</div>
);
}

View File

@@ -1,17 +1,5 @@
"use client";
import {
RiArrowLeftDoubleLine,
RiArrowLeftSLine,
RiArrowRightDoubleLine,
RiArrowRightSLine,
RiAtLine,
RiCalendarEventLine,
RiDeleteBinLine,
} from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import Image from "next/image";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
useCallback,
@@ -30,31 +18,15 @@ import {
markInboxAsProcessedAction,
restoreDiscardedInboxItemAction,
} from "@/features/inbox/actions";
import {
INBOX_DEFAULT_PAGE_SIZE,
INBOX_PAGE_SIZE_OPTIONS,
} from "@/features/inbox/page-helpers";
import { INBOX_DEFAULT_PAGE_SIZE } from "@/features/inbox/page-helpers";
import { TransactionDialog } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { InboxCard } from "./inbox-card";
import { Tabs, TabsContent } from "@/shared/components/ui/tabs";
import { InboxBulkActions } from "./inbox-bulk-actions";
import { InboxDetailsDialog } from "./inbox-details-dialog";
import { InboxItemsList } from "./inbox-items-list";
import { InboxPagination } from "./inbox-pagination";
import { InboxTabs } from "./inbox-tabs";
import type {
InboxItem,
InboxPaginationState,
@@ -63,76 +35,6 @@ import type {
SelectOption,
} from "./types";
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
// O Companion envia hora local de Brasília com 'Z' literal (não converte para UTC).
// Por isso, o timestamp armazenado no DB já tem a data correta de Brasília como componente UTC.
// Basta fatiar o ISO string sem nenhum ajuste para obter a data de Brasília do item.
function getItemDateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
// Para "hoje" e "ontem", precisamos da data real de Brasília (UTC-3).
function getBrasiliaDateKey(date: Date): string {
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
return new Date(date.getTime() - BRASILIA_OFFSET_MS)
.toISOString()
.slice(0, 10);
}
function getGroupLabel(dateKey: string): string {
const now = new Date();
const todayKey = getBrasiliaDateKey(now);
const yesterdayKey = getBrasiliaDateKey(
new Date(now.getTime() - 24 * 60 * 60 * 1000),
);
if (dateKey === todayKey) return "Hoje";
if (dateKey === yesterdayKey) return "Ontem";
const [year, month, day] = dateKey.split("-").map(Number);
return format(new Date(year, month - 1, day), "d 'de' MMMM", {
locale: ptBR,
});
}
function groupItemsByDay(
items: InboxItem[],
): { label: string; items: InboxItem[] }[] {
const groups = new Map<string, InboxItem[]>();
for (const item of items) {
const key = getItemDateKey(new Date(item.notificationTimestamp));
const group = groups.get(key);
if (group) {
group.push(item);
} else {
groups.set(key, [item]);
}
}
const sortedKeys = [...groups.keys()].sort((a, b) => b.localeCompare(a));
return sortedKeys.map((key) => ({
label: getGroupLabel(key),
items: groups.get(key) ?? [],
}));
}
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 InboxPageProps {
activeStatus: InboxStatus;
activeApp: string | null;
@@ -197,24 +99,14 @@ export function InboxPage({
useState<InboxStatus>("pending");
const normalizedSourceApps = useMemo(() => {
if (!Array.isArray(sourceApps)) {
return [];
}
if (!Array.isArray(sourceApps)) return [];
const uniqueApps = new Set<string>();
for (const app of sourceApps) {
if (typeof app !== "string") {
continue;
}
if (typeof app !== "string") continue;
const trimmedApp = app.trim();
if (!trimmedApp) {
continue;
}
if (!trimmedApp) continue;
uniqueApps.add(trimmedApp);
}
return [...uniqueApps].sort((left, right) =>
left.localeCompare(right, "pt-BR"),
);
@@ -225,28 +117,19 @@ export function InboxPage({
? [activeApp, ...normalizedSourceApps]
: normalizedSourceApps;
const getAppLogo = (appName: string | null) =>
findMatchingLogo(appName, appLogoMap) ?? DEFAULT_INBOX_APP_LOGO;
const handleProcessOpenChange = (open: boolean) => {
setProcessOpen(open);
if (!open) {
setItemToProcess(null);
}
if (!open) setItemToProcess(null);
};
const handleDetailsOpenChange = (open: boolean) => {
setDetailsOpen(open);
if (!open) {
setItemDetails(null);
}
if (!open) setItemDetails(null);
};
const handleDiscardOpenChange = (open: boolean) => {
setDiscardOpen(open);
if (!open) {
setItemToDiscard(null);
}
if (!open) setItemToDiscard(null);
};
const handleProcessRequest = useCallback((item: InboxItem) => {
@@ -266,25 +149,20 @@ export function InboxPage({
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);
}
if (!open) setItemToDelete(null);
};
const handleDeleteRequest = useCallback((item: InboxItem) => {
@@ -294,25 +172,20 @@ export function InboxPage({
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);
}
if (!open) setItemToRestore(null);
};
const handleRestoreRequest = useCallback((item: InboxItem) => {
@@ -322,16 +195,13 @@ export function InboxPage({
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);
};
@@ -365,25 +235,21 @@ export function InboxPage({
nextPageSize: number,
) => {
const nextParams = new URLSearchParams(searchParams.toString());
if (nextStatus === "pending") {
nextParams.delete("status");
} else {
nextParams.set("status", nextStatus);
}
if (nextPage <= 1) {
nextParams.delete("page");
} else {
nextParams.set("page", nextPage.toString());
}
if (nextPageSize === INBOX_DEFAULT_PAGE_SIZE) {
nextParams.delete("pageSize");
} else {
nextParams.set("pageSize", nextPageSize.toString());
}
startTransition(() => {
const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}`
@@ -431,10 +297,7 @@ export function InboxPage({
};
const handleSelectionBulkRequest = (status: InboxStatus) => {
if (selectedIds.length === 0) {
return;
}
if (selectedIds.length === 0) return;
setSelectionBulkStatus(status);
setSelectionBulkOpen(true);
};
@@ -465,10 +328,6 @@ export function InboxPage({
}
};
const handleBulkDeleteOpenChange = (open: boolean) => {
setBulkDeleteOpen(open);
};
const handleBulkDeleteRequest = (status: "processed" | "discarded") => {
setBulkDeleteStatus(status);
setBulkDeleteOpen(true);
@@ -478,23 +337,19 @@ export function InboxPage({
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 {
@@ -502,10 +357,6 @@ export function InboxPage({
}
};
const canPreviousPage = pagination.page > 1;
const canNextPage = pagination.page < pagination.totalPages;
// Prepare default values from inbox item
const getDateString = (
date: Date | string | null | undefined,
): string | null => {
@@ -516,140 +367,29 @@ export function InboxPage({
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 cardOptions) {
const label = option.label.toLowerCase();
if (label.includes(appName) || appName.includes(label)) {
if (label.includes(appName) || appName.includes(label))
return option.value;
}
}
return null;
}, [itemToProcess?.sourceAppName, cardOptions]);
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 renderGroupedGrid = (list: InboxItem[], readonly?: boolean) => {
if (list.length === 0) {
if (activeApp) {
return renderEmptyState("Nenhuma notificação deste app");
}
return renderEmptyState(
readonly
? "Nenhuma notificação nesta aba"
: "Nenhum pré-lançamento pendente",
);
}
const groups = groupItemsByDay(list);
return (
<div className="space-y-6">
{groups.map((group) => (
<div key={group.label}>
<div className="mb-3 flex items-center gap-1 text-muted-foreground">
<RiCalendarEventLine className="size-3.5 shrink-0" />
<p className="text-sm font-medium">{group.label}</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{group.items.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
}
selected={selectedIds.includes(item.id)}
onSelectToggle={toggleSelection}
/>
))}
</div>
</div>
))}
</div>
);
};
const renderAppFilter = () => {
if (appFilterOptions.length === 0) {
return null;
}
return (
<Select value={activeApp ?? "all"} onValueChange={handleAppChange}>
<SelectTrigger className="w-[190px]">
<SelectValue>
<span className="flex min-w-0 items-center gap-2">
<Image
src={activeApp ? getAppLogo(activeApp) : DEFAULT_INBOX_APP_LOGO}
alt=""
width={20}
height={20}
className="shrink-0 rounded-full"
/>
<span className="truncate">{activeApp ?? "Todos"}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<span className="flex items-center gap-2">
<Image
src={DEFAULT_INBOX_APP_LOGO}
alt=""
width={20}
height={20}
className="shrink-0 rounded-full"
/>
<span>Todos</span>
</span>
</SelectItem>
{appFilterOptions.map((app) => (
<SelectItem key={app} value={app}>
<span className="flex min-w-0 items-center gap-2">
<Image
src={getAppLogo(app)}
alt=""
width={20}
height={20}
className="shrink-0 rounded-full"
/>
<span className="truncate">{app}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
};
const showTabActions = (status: InboxStatus) =>
activeStatus === status &&
(appFilterOptions.length > 0 || items.length > 0);
return (
<>
@@ -658,229 +398,106 @@ export function InboxPage({
onValueChange={handleTabChange}
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"
disabled={isPending}
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>({counts.pending})</span>
</TabsTrigger>
<TabsTrigger
value="processed"
disabled={isPending}
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>({counts.processed})</span>
</TabsTrigger>
<TabsTrigger
value="discarded"
disabled={isPending}
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>({counts.discarded})</span>
</TabsTrigger>
</TabsList>
<InboxTabs counts={counts} isPending={isPending} />
<TabsContent value="pending" className="mt-4">
{activeStatus === "pending" &&
(appFilterOptions.length > 0 || items.length > 0) && (
<div className="mb-4 flex flex-wrap items-center gap-2">
{renderAppFilter()}
{items.length > 0 ? (
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("pending")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Descartar selecionados ({selectedIds.length})
</Button>
)}
</div>
) : null}
</div>
)}
{activeStatus === "pending" ? renderGroupedGrid(items, false) : null}
{showTabActions("pending") && (
<InboxBulkActions
status="pending"
items={items}
activeApp={activeApp}
appFilterOptions={appFilterOptions}
selectedIds={selectedIds}
allSelected={allSelected}
appLogoMap={appLogoMap}
onAppChange={handleAppChange}
onToggleSelectAll={toggleSelectAll}
onSelectionBulkRequest={handleSelectionBulkRequest}
onBulkDeleteRequest={handleBulkDeleteRequest}
/>
)}
{activeStatus === "pending" && (
<InboxItemsList
items={items}
readonly={false}
activeApp={activeApp}
appLogoMap={appLogoMap}
selectedIds={selectedIds}
onProcess={handleProcessRequest}
onDiscard={handleDiscardRequest}
onViewDetails={handleDetailsRequest}
onSelectToggle={toggleSelection}
/>
)}
</TabsContent>
<TabsContent value="processed" className="mt-4">
{activeStatus === "processed" &&
(appFilterOptions.length > 0 || items.length > 0) && (
<div className="mb-4 flex flex-wrap items-center gap-2">
{renderAppFilter()}
{items.length > 0 ? (
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("processed")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Excluir selecionados ({selectedIds.length})
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleBulkDeleteRequest("processed")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Limpar processados
</Button>
</div>
) : null}
</div>
)}
{activeStatus === "processed" ? renderGroupedGrid(items, true) : null}
{showTabActions("processed") && (
<InboxBulkActions
status="processed"
items={items}
activeApp={activeApp}
appFilterOptions={appFilterOptions}
selectedIds={selectedIds}
allSelected={allSelected}
appLogoMap={appLogoMap}
onAppChange={handleAppChange}
onToggleSelectAll={toggleSelectAll}
onSelectionBulkRequest={handleSelectionBulkRequest}
onBulkDeleteRequest={handleBulkDeleteRequest}
/>
)}
{activeStatus === "processed" && (
<InboxItemsList
items={items}
readonly
activeApp={activeApp}
appLogoMap={appLogoMap}
selectedIds={selectedIds}
onDelete={handleDeleteRequest}
onRestoreToPending={handleRestoreRequest}
onSelectToggle={toggleSelection}
/>
)}
</TabsContent>
<TabsContent value="discarded" className="mt-4">
{activeStatus === "discarded" &&
(appFilterOptions.length > 0 || items.length > 0) && (
<div className="mb-4 flex flex-wrap items-center gap-2">
{renderAppFilter()}
{items.length > 0 ? (
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("discarded")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Excluir selecionados ({selectedIds.length})
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleBulkDeleteRequest("discarded")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Limpar descartados
</Button>
</div>
) : null}
</div>
)}
{activeStatus === "discarded" ? renderGroupedGrid(items, true) : null}
{showTabActions("discarded") && (
<InboxBulkActions
status="discarded"
items={items}
activeApp={activeApp}
appFilterOptions={appFilterOptions}
selectedIds={selectedIds}
allSelected={allSelected}
appLogoMap={appLogoMap}
onAppChange={handleAppChange}
onToggleSelectAll={toggleSelectAll}
onSelectionBulkRequest={handleSelectionBulkRequest}
onBulkDeleteRequest={handleBulkDeleteRequest}
/>
)}
{activeStatus === "discarded" && (
<InboxItemsList
items={items}
readonly
activeApp={activeApp}
appLogoMap={appLogoMap}
selectedIds={selectedIds}
onDelete={handleDeleteRequest}
onRestoreToPending={handleRestoreRequest}
onSelectToggle={toggleSelection}
/>
)}
</TabsContent>
</Tabs>
{pagination.totalItems > 0 ? (
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{pagination.totalItems} notificações
</span>
<Select
disabled={isPending}
value={pagination.pageSize.toString()}
onValueChange={(value) => {
updateUrl(activeStatus, 1, Number(value));
}}
>
<SelectTrigger className="w-max">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INBOX_PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={option.toString()}>
{option} itens
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Página {pagination.page} de {pagination.totalPages}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
onClick={() => updateUrl(activeStatus, 1, pagination.pageSize)}
disabled={!canPreviousPage || isPending}
aria-label="Primeira página"
>
<RiArrowLeftDoubleLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
updateUrl(
activeStatus,
pagination.page - 1,
pagination.pageSize,
)
}
disabled={!canPreviousPage || isPending}
aria-label="Página anterior"
>
<RiArrowLeftSLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
updateUrl(
activeStatus,
pagination.page + 1,
pagination.pageSize,
)
}
disabled={!canNextPage || isPending}
aria-label="Próxima página"
>
<RiArrowRightSLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
updateUrl(
activeStatus,
pagination.totalPages,
pagination.pageSize,
)
}
disabled={!canNextPage || isPending}
aria-label="Última página"
>
<RiArrowRightDoubleLine className="size-4" />
</Button>
</div>
</div>
</div>
) : null}
<InboxPagination
pagination={pagination}
activeStatus={activeStatus}
isPending={isPending}
onNavigate={updateUrl}
/>
<TransactionDialog
mode="create"
@@ -944,7 +561,7 @@ export function InboxPage({
<ConfirmActionDialog
open={bulkDeleteOpen}
onOpenChange={handleBulkDeleteOpenChange}
onOpenChange={setBulkDeleteOpen}
title={`Limpar ${bulkDeleteStatus === "processed" ? "processados" : "descartados"}?`}
description={`Todos os itens ${bulkDeleteStatus === "processed" ? "processados" : "descartados"} serão excluídos permanentemente.`}
confirmLabel="Limpar tudo"

View File

@@ -0,0 +1,122 @@
import {
RiArrowLeftDoubleLine,
RiArrowLeftSLine,
RiArrowRightDoubleLine,
RiArrowRightSLine,
} from "@remixicon/react";
import {
INBOX_DEFAULT_PAGE_SIZE,
INBOX_PAGE_SIZE_OPTIONS,
} from "@/features/inbox/page-helpers";
import { Button } from "@/shared/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import type { InboxPaginationState, InboxStatus } from "./types";
type InboxPaginationProps = {
pagination: InboxPaginationState;
activeStatus: InboxStatus;
isPending: boolean;
onNavigate: (status: InboxStatus, page: number, pageSize: number) => void;
};
export function InboxPagination({
pagination,
activeStatus,
isPending,
onNavigate,
}: InboxPaginationProps) {
if (pagination.totalItems === 0) return null;
const canPreviousPage = pagination.page > 1;
const canNextPage = pagination.page < pagination.totalPages;
return (
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{pagination.totalItems} notificações
</span>
<Select
disabled={isPending}
value={pagination.pageSize.toString()}
onValueChange={(value) => {
onNavigate(activeStatus, 1, Number(value));
}}
>
<SelectTrigger className="w-max">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INBOX_PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={option.toString()}>
{option} itens
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Página {pagination.page} de {pagination.totalPages}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
onClick={() => onNavigate(activeStatus, 1, pagination.pageSize)}
disabled={!canPreviousPage || isPending}
aria-label="Primeira página"
>
<RiArrowLeftDoubleLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
onNavigate(activeStatus, pagination.page - 1, pagination.pageSize)
}
disabled={!canPreviousPage || isPending}
aria-label="Página anterior"
>
<RiArrowLeftSLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
onNavigate(activeStatus, pagination.page + 1, pagination.pageSize)
}
disabled={!canNextPage || isPending}
aria-label="Próxima página"
>
<RiArrowRightSLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
onNavigate(
activeStatus,
pagination.totalPages,
pagination.pageSize,
)
}
disabled={!canNextPage || isPending}
aria-label="Última página"
>
<RiArrowRightDoubleLine className="size-4" />
</Button>
</div>
</div>
</div>
);
}
// Re-export para facilitar uso externo
export { INBOX_DEFAULT_PAGE_SIZE };

View File

@@ -0,0 +1,40 @@
import { TabsList, TabsTrigger } from "@/shared/components/ui/tabs";
import type { InboxStatus, InboxStatusCounts } from "./types";
type InboxTabsProps = {
counts: InboxStatusCounts;
isPending: boolean;
};
export function InboxTabs({ counts, isPending }: InboxTabsProps) {
return (
<TabsList className="grid h-auto w-full grid-cols-3 sm:inline-flex sm:h-9 sm:grid-cols-none">
<TabsTrigger
value="pending"
disabled={isPending}
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>({counts.pending})</span>
</TabsTrigger>
<TabsTrigger
value="processed"
disabled={isPending}
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>({counts.processed})</span>
</TabsTrigger>
<TabsTrigger
value="discarded"
disabled={isPending}
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>({counts.discarded})</span>
</TabsTrigger>
</TabsList>
);
}
export type { InboxStatus, InboxStatusCounts };