mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
refactor(lista): componentizar inbox e tabela de lançamentos
This commit is contained in:
153
src/features/inbox/components/inbox-bulk-actions.tsx
Normal file
153
src/features/inbox/components/inbox-bulk-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
133
src/features/inbox/components/inbox-items-list.tsx
Normal file
133
src/features/inbox/components/inbox-items-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
122
src/features/inbox/components/inbox-pagination.tsx
Normal file
122
src/features/inbox/components/inbox-pagination.tsx
Normal 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 };
|
||||
40
src/features/inbox/components/inbox-tabs.tsx
Normal file
40
src/features/inbox/components/inbox-tabs.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user