diff --git a/src/app/(dashboard)/inbox/page.tsx b/src/app/(dashboard)/inbox/page.tsx index a464b9c..47e9c6a 100644 --- a/src/app/(dashboard)/inbox/page.tsx +++ b/src/app/(dashboard)/inbox/page.tsx @@ -1,29 +1,55 @@ import { InboxPage } from "@/features/inbox/components/inbox-page"; +import { + type ResolvedInboxSearchParams, + resolveInboxPagination, + resolveInboxStatus, +} from "@/features/inbox/page-helpers"; import { fetchAppLogoMap, fetchInboxDialogData, - fetchInboxItems, + fetchInboxItemsPage, + fetchInboxStatusCounts, } from "@/features/inbox/queries"; import { getUserId } from "@/shared/lib/auth/server"; -export default async function Page() { - const userId = await getUserId(); +type PageSearchParams = Promise; - const [pendingItems, processedItems, discardedItems, dialogData, appLogoMap] = - await Promise.all([ - fetchInboxItems(userId, "pending"), - fetchInboxItems(userId, "processed"), - fetchInboxItems(userId, "discarded"), - fetchInboxDialogData(userId), - fetchAppLogoMap(userId), - ]); +type PageProps = { + searchParams?: PageSearchParams; +}; + +const EMPTY_DIALOG_DATA = { + payerOptions: [], + splitPayerOptions: [], + defaultPayerId: null, + accountOptions: [], + cardOptions: [], + categoryOptions: [], + estabelecimentos: [], +}; + +export default async function Page({ searchParams }: PageProps) { + const userId = await getUserId(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const activeStatus = resolveInboxStatus(resolvedSearchParams); + const paginationInput = resolveInboxPagination(resolvedSearchParams); + + const [itemsPage, counts, dialogData, appLogoMap] = await Promise.all([ + fetchInboxItemsPage(userId, activeStatus, paginationInput), + fetchInboxStatusCounts(userId), + activeStatus === "pending" + ? fetchInboxDialogData(userId) + : Promise.resolve(EMPTY_DIALOG_DATA), + fetchAppLogoMap(userId), + ]); return (
(null); @@ -74,46 +105,11 @@ export function InboxPage({ "processed" | "discarded" >("processed"); - const [selectedPendingIds, setSelectedPendingIds] = useState([]); - const [selectedProcessedIds, setSelectedProcessedIds] = useState( - [], - ); - const [selectedDiscardedIds, setSelectedDiscardedIds] = useState( - [], - ); + const [selectedIds, setSelectedIds] = useState([]); const [selectionBulkOpen, setSelectionBulkOpen] = useState(false); - const [selectionBulkStatus, setSelectionBulkStatus] = useState< - "pending" | "processed" | "discarded" - >("pending"); - - 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 [selectionBulkStatus, setSelectionBulkStatus] = + useState("pending"); const handleProcessOpenChange = (open: boolean) => { setProcessOpen(open); @@ -223,40 +219,72 @@ export function InboxPage({ throw new Error(result.error); }; - const toggleSelection = ( - ids: string[], - setIds: (v: string[]) => void, - id: string, + useEffect(() => { + const visibleIds = new Set(items.map((item) => item.id)); + setSelectedIds((current) => current.filter((id) => visibleIds.has(id))); + }, [items]); + + const toggleSelection = (id: string) => { + setSelectedIds((current) => + current.includes(id) + ? current.filter((value) => value !== id) + : [...current, id], + ); + }; + + const allSelected = items.length > 0 && selectedIds.length === items.length; + + const toggleSelectAll = () => { + if (allSelected) { + setSelectedIds([]); + return; + } + + setSelectedIds(items.map((item) => item.id)); + }; + + const updateUrl = ( + nextStatus: InboxStatus, + nextPage: number, + nextPageSize: number, ) => { - setIds(ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]); + 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()}` + : pathname; + router.replace(target, { scroll: false }); + }); }; - const allPendingSelected = - sortedPending.length > 0 && - selectedPendingIds.length === sortedPending.length; - const allProcessedSelected = - sortedProcessed.length > 0 && - selectedProcessedIds.length === sortedProcessed.length; - const allDiscardedSelected = - sortedDiscarded.length > 0 && - selectedDiscardedIds.length === sortedDiscarded.length; - - const toggleSelectAllPending = () => { - if (allPendingSelected) setSelectedPendingIds([]); - else setSelectedPendingIds(sortedPending.map((item) => item.id)); - }; - const toggleSelectAllProcessed = () => { - if (allProcessedSelected) setSelectedProcessedIds([]); - else setSelectedProcessedIds(sortedProcessed.map((item) => item.id)); - }; - const toggleSelectAllDiscarded = () => { - if (allDiscardedSelected) setSelectedDiscardedIds([]); - else setSelectedDiscardedIds(sortedDiscarded.map((item) => item.id)); + const handleTabChange = (nextStatus: string) => { + updateUrl(nextStatus as InboxStatus, 1, pagination.pageSize); }; - const handleSelectionBulkRequest = ( - status: "pending" | "processed" | "discarded", - ) => { + const handleSelectionBulkRequest = (status: InboxStatus) => { + if (selectedIds.length === 0) { + return; + } + setSelectionBulkStatus(status); setSelectionBulkOpen(true); }; @@ -264,27 +292,22 @@ export function InboxPage({ const handleSelectionBulkConfirm = async () => { if (selectionBulkStatus === "pending") { const result = await bulkDiscardInboxItemsAction({ - inboxItemIds: selectedPendingIds, + inboxItemIds: selectedIds, }); if (result.success) { toast.success(result.message); - setSelectedPendingIds([]); + setSelectedIds([]); return; } toast.error(result.error); throw new Error(result.error); } else { - const ids = - selectionBulkStatus === "processed" - ? selectedProcessedIds - : selectedDiscardedIds; const result = await bulkDeleteSelectedInboxItemsAction({ - inboxItemIds: ids, + inboxItemIds: selectedIds, }); if (result.success) { toast.success(result.message); - if (selectionBulkStatus === "processed") setSelectedProcessedIds([]); - else setSelectedDiscardedIds([]); + setSelectedIds([]); return; } toast.error(result.error); @@ -329,6 +352,9 @@ 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, @@ -375,12 +401,7 @@ export function InboxPage({ ); - const renderGrid = ( - list: InboxItem[], - readonly?: boolean, - selectedIds?: string[], - onToggle?: (id: string) => void, - ) => + const renderGrid = (list: InboxItem[], readonly?: boolean) => list.length === 0 ? ( renderEmptyState( readonly @@ -400,8 +421,8 @@ export function InboxPage({ onViewDetails={readonly ? undefined : handleDetailsRequest} onDelete={readonly ? handleDeleteRequest : undefined} onRestoreToPending={readonly ? handleRestoreRequest : undefined} - selected={selectedIds?.includes(item.id)} - onSelectToggle={onToggle} + selected={selectedIds.includes(item.id)} + onSelectToggle={toggleSelection} /> ))} @@ -409,79 +430,72 @@ export function InboxPage({ return ( <> - + Pendentes - ({pendingItems.length}) + ({counts.pending}) Processados - ({processedItems.length}) + ({counts.processed}) Descartados - ({discardedItems.length}) + ({counts.discarded}) - {sortedPending.length > 0 && ( + {activeStatus === "pending" && items.length > 0 && (
- - {selectedPendingIds.length > 0 && ( + {selectedIds.length > 0 && ( )}
)} - {renderGrid(sortedPending, false, selectedPendingIds, (id) => - toggleSelection(selectedPendingIds, setSelectedPendingIds, id), - )} + {activeStatus === "pending" ? renderGrid(items, false) : null}
- {sortedProcessed.length > 0 && ( + {activeStatus === "processed" && items.length > 0 && (
- - {selectedProcessedIds.length > 0 && ( + {selectedIds.length > 0 && ( )}
)} - {renderGrid(sortedProcessed, true, selectedProcessedIds, (id) => - toggleSelection(selectedProcessedIds, setSelectedProcessedIds, id), - )} + {activeStatus === "processed" ? renderGrid(items, true) : null}
- {sortedDiscarded.length > 0 && ( + {activeStatus === "discarded" && items.length > 0 && (
- - {selectedDiscardedIds.length > 0 && ( + {selectedIds.length > 0 && ( )}
)} - {renderGrid(sortedDiscarded, true, selectedDiscardedIds, (id) => - toggleSelection(selectedDiscardedIds, setSelectedDiscardedIds, id), - )} + {activeStatus === "discarded" ? renderGrid(items, true) : null}
+ {pagination.totalItems > 0 ? ( +
+
+ + {pagination.totalItems} notificações + + +
+
+ + Página {pagination.page} de {pagination.totalPages} + +
+ + + + +
+
+
+ ) : null} + ; + +export type InboxPaginationState = { + page: number; + pageSize: number; + totalItems: number; + totalPages: number; +}; + // Re-export the lancamentos SelectOption for use in inbox components export type SelectOption = LancamentoSelectOption; diff --git a/src/features/inbox/page-helpers.ts b/src/features/inbox/page-helpers.ts new file mode 100644 index 0000000..2f23f81 --- /dev/null +++ b/src/features/inbox/page-helpers.ts @@ -0,0 +1,49 @@ +import type { InboxPaginationState, InboxStatus } from "./components/types"; + +export type ResolvedInboxSearchParams = + | Record + | undefined; + +export const INBOX_DEFAULT_PAGE_SIZE = 12; +export const INBOX_PAGE_SIZE_OPTIONS = [12, 24, 48]; + +export const INBOX_STATUSES = ["pending", "processed", "discarded"] as const; + +export const getSingleParam = ( + params: ResolvedInboxSearchParams, + key: string, +): string | null => { + const value = params?.[key]; + if (!value) { + return null; + } + + return Array.isArray(value) ? (value[0] ?? null) : value; +}; + +export const resolveInboxStatus = ( + params: ResolvedInboxSearchParams, +): InboxStatus => { + const status = getSingleParam(params, "status"); + + return INBOX_STATUSES.includes(status as InboxStatus) + ? (status as InboxStatus) + : "pending"; +}; + +export const resolveInboxPagination = ( + params: ResolvedInboxSearchParams, +): Pick => { + const pageParam = Number.parseInt(getSingleParam(params, "page") ?? "", 10); + const pageSizeParam = Number.parseInt( + getSingleParam(params, "pageSize") ?? "", + 10, + ); + + return { + page: Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1, + pageSize: INBOX_PAGE_SIZE_OPTIONS.includes(pageSizeParam) + ? pageSizeParam + : INBOX_DEFAULT_PAGE_SIZE, + }; +}; diff --git a/src/features/inbox/queries.ts b/src/features/inbox/queries.ts index ebc1d16..4cbc3dc 100644 --- a/src/features/inbox/queries.ts +++ b/src/features/inbox/queries.ts @@ -1,7 +1,10 @@ -import { and, desc, eq } from "drizzle-orm"; +import { and, count, desc, eq } from "drizzle-orm"; import { cards, categories, financialAccounts, inboxItems } from "@/db/schema"; import type { InboxItem, + InboxPaginationState, + InboxStatus, + InboxStatusCounts, SelectOption, } from "@/features/inbox/components/types"; import { @@ -16,17 +19,90 @@ import { db } from "@/shared/lib/db"; export async function fetchInboxItems( userId: string, - status: "pending" | "processed" | "discarded" = "pending", + status: InboxStatus = "pending", ): Promise { const items = await db .select() .from(inboxItems) .where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status))) - .orderBy(desc(inboxItems.createdAt)); + .orderBy( + desc(inboxItems.notificationTimestamp), + desc(inboxItems.createdAt), + ); return items; } +export async function fetchInboxItemsPage( + userId: string, + status: InboxStatus, + { + page, + pageSize, + }: { + page: number; + pageSize: number; + }, +): Promise<{ + items: InboxItem[]; + pagination: InboxPaginationState; +}> { + const [countRow] = await db + .select({ total: count() }) + .from(inboxItems) + .where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status))); + + const totalItems = Number(countRow?.total ?? 0); + const totalPages = Math.max(Math.ceil(totalItems / pageSize), 1); + const currentPage = Math.min(page, totalPages); + const offset = (currentPage - 1) * pageSize; + + const items = await db + .select() + .from(inboxItems) + .where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status))) + .orderBy(desc(inboxItems.notificationTimestamp), desc(inboxItems.createdAt)) + .limit(pageSize) + .offset(offset); + + return { + items, + pagination: { + page: currentPage, + pageSize, + totalItems, + totalPages, + }, + }; +} + +export async function fetchInboxStatusCounts( + userId: string, +): Promise { + const rows = await db + .select({ + status: inboxItems.status, + total: count(), + }) + .from(inboxItems) + .where(eq(inboxItems.userId, userId)) + .groupBy(inboxItems.status); + + const counts: InboxStatusCounts = { + pending: 0, + processed: 0, + discarded: 0, + }; + + for (const row of rows) { + if (row.status in counts) { + counts[row.status as InboxStatus] = Number(row.total ?? 0); + } + } + + return counts; +} + export async function fetchInboxItemById( userId: string, itemId: string, @@ -112,14 +188,14 @@ export async function fetchAppLogoMap( } export async function fetchPendingInboxCount(userId: string): Promise { - const items = await db - .select({ id: inboxItems.id }) + const [result] = await db + .select({ total: count() }) .from(inboxItems) .where( and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")), ); - return items.length; + return Number(result?.total ?? 0); } /** diff --git a/src/shared/lib/schemas/inbox.ts b/src/shared/lib/schemas/inbox.ts index 1c66c34..c7e9263 100644 --- a/src/shared/lib/schemas/inbox.ts +++ b/src/shared/lib/schemas/inbox.ts @@ -1,14 +1,17 @@ import { z } from "zod"; export const inboxItemSchema = z.object({ - sourceApp: z.string().min(1, "sourceApp é obrigatório"), - sourceAppName: z.string().optional(), - originalTitle: z.string().optional(), - originalText: z.string().min(1, "originalText é obrigatório"), - notificationTimestamp: z.string().transform((val) => new Date(val)), - parsedName: z.string().optional(), + sourceApp: z.string().min(1, "sourceApp é obrigatório").max(255), + sourceAppName: z.string().max(255).optional(), + originalTitle: z.string().max(500).optional(), + originalText: z.string().min(1, "originalText é obrigatório").max(5000), + notificationTimestamp: z + .string() + .transform((val) => new Date(val)) + .refine((d) => !Number.isNaN(d.getTime()), "Data de notificação inválida"), + parsedName: z.string().max(500).optional(), parsedAmount: z.coerce.number().optional(), - clientId: z.string().optional(), // ID local do app para rastreamento + clientId: z.string().max(255).optional(), // ID local do app para rastreamento }); export const inboxBatchSchema = z.object({