diff --git a/src/components/AccessKeysColumns/AccessKeysColumnsMobile.tsx b/src/components/AccessKeysColumns/AccessKeysColumnsMobile.tsx index 2b3abb2a..e368ecd9 100644 --- a/src/components/AccessKeysColumns/AccessKeysColumnsMobile.tsx +++ b/src/components/AccessKeysColumns/AccessKeysColumnsMobile.tsx @@ -1,8 +1,8 @@ -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; +import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; -import { EllipsisVertical, ChevronDown, ChevronUp } from 'lucide-react'; +import { EllipsisVertical } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, @@ -12,14 +12,13 @@ import { } from '@/components/ui/dropdown-menu'; import { ViewDialogMobile } from '@/components/ViewDialog/ViewDialogMobile'; import { RevokeKeyDialog } from '@/components'; -import { useState } from 'react'; import { Row } from '@tanstack/react-table'; -import { getCoreRowModel, getPaginationRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'; -import { MobileDataTablePagination } from '@/components/DataTable/MobileDataTablePagination'; +import { MobileDataTable } from '@/components/DataTable/MobileDataTable'; import { AccessKeysColumns } from './AccessKeysColumns'; import { useIsUser } from '@/store/clientStore'; import { truncateId } from '@/utils/string'; -import { createToggleExpandAll } from '@/utils/expandUtils'; +import { PaginationProps } from '../DataTable/DataTable'; +import { AccessKey } from '@bsv/spv-wallet-js-client'; const onClickCopy = (value: string, label: string) => async () => { if (!value) { @@ -31,6 +30,7 @@ const onClickCopy = (value: string, label: string) => async () => { interface AccessKeyMobileItemProps { accessKey: AccessKeysColumns; + expandedState: { expandedItems: string[]; setExpandedItems: (value: string[]) => void }; } export const AccessKeyMobileItem = ({ accessKey }: AccessKeyMobileItemProps) => { @@ -55,10 +55,10 @@ export const AccessKeyMobileItem = ({ accessKey }: AccessKeyMobileItemProps) => } }; - const mobileRow: Row = { + const mobileRow = { original: accessKey, getValue: (key: string) => (key === 'status' ? accessKey.status : undefined), - } as Row; + } as unknown as Row; return ( @@ -69,7 +69,7 @@ export const AccessKeyMobileItem = ({ accessKey }: AccessKeyMobileItemProps) => ID: {truncateId(accessKey.id)}

-

{getStatusBadge()}

+
{getStatusBadge()}
@@ -123,66 +123,28 @@ export const AccessKeyMobileItem = ({ accessKey }: AccessKeyMobileItemProps) => export interface AccessKeysMobileListProps { accessKeys: AccessKeysColumns[]; - value?: string[]; - onValueChange?: (value: string[]) => void; + pagination?: PaginationProps; } -export const AccessKeysMobileList = ({ accessKeys, value, onValueChange }: AccessKeysMobileListProps) => { - const [expandedItems, setExpandedItems] = useState(value || []); - const [isAllExpanded, setIsAllExpanded] = useState(false); - - const table = useReactTable({ - data: accessKeys, - columns: [], - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - }); - - const currentPageData = table.getRowModel().rows.map((row) => row.original); - - const toggleExpandAll = () => { - createToggleExpandAll( - currentPageData, - isAllExpanded, - (ids) => { - setExpandedItems(ids); - onValueChange?.(ids); - }, - setIsAllExpanded, - (accessKey) => accessKey.id, - ); - }; - - const handleValueChange = (newValue: string[]) => { - setExpandedItems(newValue); - onValueChange?.(newValue); - setIsAllExpanded(newValue.length === currentPageData.length); - }; - +export const AccessKeysMobileList = ({ accessKeys, pagination }: AccessKeysMobileListProps) => { + // Use the MobileDataTable component to handle pagination return ( -
-
- -
-
- - {currentPageData.map((accessKey) => ( - - ))} - -
- -
+ ( + + )} + pagination={pagination} + /> ); }; diff --git a/src/components/AccessKeysTabContent/AccessKeysTabContent.tsx b/src/components/AccessKeysTabContent/AccessKeysTabContent.tsx index 25a6f156..12f97176 100644 --- a/src/components/AccessKeysTabContent/AccessKeysTabContent.tsx +++ b/src/components/AccessKeysTabContent/AccessKeysTabContent.tsx @@ -11,13 +11,15 @@ import { } from '@/components'; import { AccessKeysMobileList } from '@/components/AccessKeysColumns/AccessKeysColumnsMobile'; import { AccessKeyExtended } from '@/interfaces'; +import { PaginationProps } from '@/components/DataTable/DataTable'; export interface AccessKeysTabContentProps { accessKeys: AccessKeyExtended[]; hasRevokeKeyDialog?: boolean; + pagination?: PaginationProps; } -export const AccessKeysTabContent = ({ accessKeys, hasRevokeKeyDialog }: AccessKeysTabContentProps) => { +export const AccessKeysTabContent = ({ accessKeys, hasRevokeKeyDialog, pagination }: AccessKeysTabContentProps) => { return ( @@ -36,10 +38,11 @@ export const AccessKeysTabContent = ({ accessKeys, hasRevokeKeyDialog }: AccessK {hasRevokeKeyDialog && } )} + pagination={pagination} />
- +
) : ( diff --git a/src/components/AddAccessKeyDialog/AddAccessKeyDialog.tsx b/src/components/AddAccessKeyDialog/AddAccessKeyDialog.tsx index d42ec8e2..9f4159a8 100644 --- a/src/components/AddAccessKeyDialog/AddAccessKeyDialog.tsx +++ b/src/components/AddAccessKeyDialog/AddAccessKeyDialog.tsx @@ -12,6 +12,7 @@ import { TooltipContent, TooltipProvider, TooltipTrigger, + DialogDescription, } from '@/components'; import { errorWrapper } from '@/utils'; import { Metadata } from '@bsv/spv-wallet-js-client'; @@ -136,6 +137,7 @@ export const AddAccessKeyDialog = ({ className }: AddAccessKeyDialogProps) => { <> Add Access Key + Create a new access key for your account
diff --git a/src/components/ContactEditDialog/ContactEditDialog.tsx b/src/components/ContactEditDialog/ContactEditDialog.tsx index 42fe5672..298690c4 100644 --- a/src/components/ContactEditDialog/ContactEditDialog.tsx +++ b/src/components/ContactEditDialog/ContactEditDialog.tsx @@ -10,6 +10,7 @@ import { Label, LoadingSpinner, Textarea, + DialogDescription, } from '@/components'; import { errorWrapper } from '@/utils'; import { Contact, Metadata } from '@bsv/spv-wallet-js-client'; @@ -76,6 +77,7 @@ export const ContactEditDialog = ({ row }: ContactEditDialogProps) => { Edit Contact Information + Update the details for this contact diff --git a/src/components/ContactsColumns/ContactsColumnsMobile.tsx b/src/components/ContactsColumns/ContactsColumnsMobile.tsx index d86b8414..7bbb1724 100644 --- a/src/components/ContactsColumns/ContactsColumnsMobile.tsx +++ b/src/components/ContactsColumns/ContactsColumnsMobile.tsx @@ -1,11 +1,11 @@ import { Contact } from '@bsv/spv-wallet-js-client'; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; +import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { ContactStatus } from './ContactsColumns'; import { ContactAcceptDialog, ContactRejectDialog, ContactEditDialog, ContactDeleteDialog } from '@/components'; import { Button } from '@/components/ui/button'; -import { EllipsisVertical, ChevronDown, ChevronUp } from 'lucide-react'; +import { EllipsisVertical } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, @@ -14,12 +14,10 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ViewDialogMobile } from '@/components/ViewDialog/ViewDialogMobile'; -import { useState } from 'react'; import { Row } from '@tanstack/react-table'; -import { getCoreRowModel, getPaginationRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'; -import { MobileDataTablePagination } from '@/components/DataTable/MobileDataTablePagination'; import { truncateId } from '@/utils/string'; -import { createToggleExpandAll } from '@/utils/expandUtils'; +import { MobileDataTable } from '@/components/DataTable/MobileDataTable'; +import { PaginationProps } from '@/components/DataTable/DataTable'; const onClickCopy = (value: string, label: string) => async () => { if (!value) { @@ -31,6 +29,7 @@ const onClickCopy = (value: string, label: string) => async () => { interface ContactMobileItemProps { contact: Contact; + expandedState?: { expandedItems: string[]; setExpandedItems: (value: string[]) => void }; } export const ContactMobileItem = ({ contact }: ContactMobileItemProps) => { @@ -74,7 +73,7 @@ export const ContactMobileItem = ({ contact }: ContactMobileItemProps) => { Name: {truncateId(contact.paymail)}

-

{getStatusBadge()}

+
{getStatusBadge()}
@@ -147,66 +146,24 @@ export const ContactMobileItem = ({ contact }: ContactMobileItemProps) => { export interface ContactsMobileListProps { contacts: Contact[]; - value?: string[]; - onValueChange?: (value: string[]) => void; + pagination?: PaginationProps; } -export const ContactsMobileList = ({ contacts, value, onValueChange }: ContactsMobileListProps) => { - const [expandedItems, setExpandedItems] = useState(value || []); - const [isAllExpanded, setIsAllExpanded] = useState(false); - - const table = useReactTable({ - data: contacts, - columns: [], - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - }); - - const currentPageData = table.getRowModel().rows.map((row) => row.original); - - const toggleExpandAll = () => { - createToggleExpandAll( - currentPageData, - isAllExpanded, - (ids) => { - setExpandedItems(ids); - onValueChange?.(ids); - }, - setIsAllExpanded, - (contact) => contact.id, - ); - }; - - const handleValueChange = (newValue: string[]) => { - setExpandedItems(newValue); - onValueChange?.(newValue); - setIsAllExpanded(newValue.length === currentPageData.length); - }; - +export const ContactsMobileList = ({ contacts, pagination }: ContactsMobileListProps) => { + // Use MobileDataTable for pagination support return ( -
-
- -
-
- - {currentPageData.map((contact) => ( - - ))} - -
- -
+ ( + + )} + pagination={pagination} + /> ); }; diff --git a/src/components/ContactsTabContent/ContactsTabContent.tsx b/src/components/ContactsTabContent/ContactsTabContent.tsx index 0392fa1b..cf4ed629 100644 --- a/src/components/ContactsTabContent/ContactsTabContent.tsx +++ b/src/components/ContactsTabContent/ContactsTabContent.tsx @@ -16,36 +16,18 @@ import { import { ContactsMobileList } from '@/components/ContactsColumns/ContactsColumnsMobile'; import { ContactExtended } from '@/interfaces/contacts.ts'; import { useState } from 'react'; +import { PaginationProps } from '@/components/DataTable/DataTable'; export interface ContactsTabContentProps { contacts: ContactExtended[]; + pagination?: PaginationProps; } -export const ContactsTabContent = ({ contacts }: ContactsTabContentProps) => { - const [currentTab] = useState('all'); +export const ContactsTabContent = ({ contacts, pagination }: ContactsTabContentProps) => { const [searchQuery] = useState(''); const filteredContacts = contacts.filter((contact) => { - // First apply status filter - if (currentTab !== 'all') { - if (currentTab === 'unconfirmed' && contact.status !== ContactStatus.Unconfirmed) { - return false; - } - if (currentTab === 'awaiting' && contact.status !== ContactStatus.Awaiting) { - return false; - } - if (currentTab === 'confirmed' && contact.status !== ContactStatus.Confirmed) { - return false; - } - if (currentTab === 'rejected' && contact.status !== ContactStatus.Rejected) { - return false; - } - if (currentTab === 'deleted' && !contact.deletedAt) { - return false; - } - } - - // Then apply search filter + // Apply search filter if (searchQuery) { const searchLower = searchQuery.toLowerCase(); return ( @@ -88,10 +70,11 @@ export const ContactsTabContent = ({ contacts }: ContactsTabContentProps) => { {row.original.deletedAt == null && } )} + pagination={pagination} />
- +
) : ( diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx index 6089234e..86de4406 100644 --- a/src/components/DataTable/DataTable.tsx +++ b/src/components/DataTable/DataTable.tsx @@ -13,21 +13,34 @@ import { TableRow, } from '@/components'; import { AccessKey, Contact, PaymailAddress, Tx, XPub } from '@bsv/spv-wallet-js-client'; -import { - ColumnDef, - ColumnSort, - flexRender, - getCoreRowModel, - getPaginationRowModel, - getSortedRowModel, - Row, - useReactTable, -} from '@tanstack/react-table'; +import { ColumnDef, ColumnSort, flexRender, Row } from '@tanstack/react-table'; import { EllipsisVertical } from 'lucide-react'; import React from 'react'; +import { useTable } from './useTable'; +import { ContactExtended } from '@/interfaces/contacts'; +import { PaymailExtended } from '@/interfaces/paymail'; +import { XpubExtended } from '@/interfaces'; + +export type RowType = + | XPub + | Contact + | AccessKey + | PaymailAddress + | Tx + | ContactExtended + | PaymailExtended + | XpubExtended + | { id?: string; url?: string; status?: string }; -export type RowType = XPub | Contact | AccessKey | PaymailAddress | Tx; +export interface PaginationProps { + currentPage: number; + pageSize: number; + totalPages: number; + totalElements: number; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; +} interface DataTableProps { columns: ColumnDef[]; @@ -35,10 +48,9 @@ interface DataTableProps { renderItem?: (row: Row) => React.ReactNode; renderInlineItem?: (row: Row) => React.ReactNode; initialSorting?: ColumnSort[]; + pagination?: PaginationProps; } -const defaultInitialSorting: ColumnSort[] = [{ id: 'id', desc: false }]; - const getColumns = ( columns: ColumnDef[], renderItem?: (row: Row) => React.ReactNode, @@ -81,24 +93,29 @@ const getColumns = ( return columns; }; -export function DataTable({ + +export function DataTable({ columns, data, renderItem, renderInlineItem, initialSorting, + pagination, }: DataTableProps) { - const table = useReactTable({ + // Use the enhanced columns with actions + const enhancedColumns = getColumns(columns, renderItem, renderInlineItem); + + // Use our shared table hook + const { table } = useTable({ + columns: enhancedColumns, data, - columns: getColumns(columns, renderItem, renderInlineItem), - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - initialState: { - sorting: initialSorting ? initialSorting : defaultInitialSorting, - }, + initialSorting, + pagination, }); + // Deduce manualPagination from the presence of pagination prop + const isManualPagination = !!pagination; + return (
@@ -133,7 +150,11 @@ export function DataTable({ )}
- +
); } diff --git a/src/components/DataTable/DataTablePagination.tsx b/src/components/DataTable/DataTablePagination.tsx index 8f0c7834..964a5014 100644 --- a/src/components/DataTable/DataTablePagination.tsx +++ b/src/components/DataTable/DataTablePagination.tsx @@ -9,28 +9,37 @@ import { Table } from '@tanstack/react-table'; import { Button } from '@/components/ui'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.tsx'; import { PAGE_SIZES } from '@/constants'; +import { usePagination } from './usePagination'; interface DataTablePaginationProps { table: Table; + manualPagination?: boolean; + totalRecords?: number; } -export function DataTablePagination({ table }: DataTablePaginationProps) { +export function DataTablePagination({ + table, + manualPagination = false, + totalRecords, +}: DataTablePaginationProps) { + const { total, currentPageSize, canPreviousPage, canNextPage, handlePageSizeChange, handlePageChange } = + usePagination({ + table, + manualPagination, + totalRecords, + }); + return (
- Total records: {table.getFilteredRowModel().rows.length} + Total records: {total}

Rows per page

- - + {PAGE_SIZES.map((pageSize) => ( @@ -48,8 +57,8 @@ export function DataTablePagination({ table }: DataTablePaginationProps table.setPageIndex(0)} - disabled={!table.getCanPreviousPage()} + onClick={() => handlePageChange(0)} + disabled={!canPreviousPage} > Go to first page @@ -57,8 +66,10 @@ export function DataTablePagination({ table }: DataTablePaginationProps table.previousPage()} - disabled={!table.getCanPreviousPage()} + onClick={() => { + handlePageChange(Math.max(0, table.getState().pagination.pageIndex - 1)); + }} + disabled={!canPreviousPage} > Go to previous page @@ -66,8 +77,10 @@ export function DataTablePagination({ table }: DataTablePaginationProps table.nextPage()} - disabled={!table.getCanNextPage()} + onClick={() => { + handlePageChange(Math.min(table.getPageCount() - 1, table.getState().pagination.pageIndex + 1)); + }} + disabled={!canNextPage} > Go to next page @@ -75,8 +88,8 @@ export function DataTablePagination({ table }: DataTablePaginationProps table.setPageIndex(table.getPageCount() - 1)} - disabled={!table.getCanNextPage()} + onClick={() => handlePageChange(table.getPageCount() - 1)} + disabled={!canNextPage} > Go to last page diff --git a/src/components/DataTable/MobileDataTable.tsx b/src/components/DataTable/MobileDataTable.tsx index 05270014..59ed7b1f 100644 --- a/src/components/DataTable/MobileDataTable.tsx +++ b/src/components/DataTable/MobileDataTable.tsx @@ -1,20 +1,12 @@ import { Accordion } from '@/components/ui/accordion'; import { Button } from '@/components/ui/button'; import { ChevronDown, ChevronUp } from 'lucide-react'; -import { AccessKey, Contact, PaymailAddress, Tx, XPub } from '@bsv/spv-wallet-js-client'; import { createToggleExpandAll } from '@/utils/expandUtils'; -import { - ColumnDef, - ColumnSort, - getCoreRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable, -} from '@tanstack/react-table'; -import React, { useState } from 'react'; +import { ColumnDef, ColumnSort } from '@tanstack/react-table'; +import React, { useState, useEffect } from 'react'; import { MobileDataTablePagination } from './MobileDataTablePagination'; - -export type RowType = XPub | Contact | AccessKey | PaymailAddress | Tx | { id?: string; url?: string }; +import { RowType, PaginationProps } from './DataTable'; +import { useTable } from './useTable'; interface MobileDataTableProps { columns: ColumnDef[]; @@ -24,31 +16,34 @@ interface MobileDataTableProps { expandedState: { expandedItems: string[]; setExpandedItems: (value: string[]) => void }, ) => React.ReactNode; initialSorting?: ColumnSort[]; + pagination?: PaginationProps; } -const defaultInitialSorting: ColumnSort[] = [{ id: 'id', desc: false }]; - export function MobileDataTable({ columns, data, renderMobileItem, initialSorting, + pagination, }: MobileDataTableProps) { const [expandedItems, setExpandedItems] = useState([]); const [isAllExpanded, setIsAllExpanded] = useState(false); - const table = useReactTable({ - data, + const { table, currentPageData } = useTable({ columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - initialState: { - sorting: initialSorting ? initialSorting : defaultInitialSorting, - }, + data, + initialSorting, + pagination, }); - const currentPageData = table.getRowModel().rows.map((row) => row.original); + // Deduce manualPagination from the presence of pagination prop + const isManualPagination = !!pagination; + + // Reset expanded state when page or page size changes + useEffect(() => { + setExpandedItems([]); + setIsAllExpanded(false); + }, [table.getState().pagination.pageIndex, table.getState().pagination.pageSize]); const toggleExpandAll = () => { createToggleExpandAll(currentPageData, isAllExpanded, setExpandedItems, setIsAllExpanded); @@ -78,7 +73,11 @@ export function MobileDataTable({ ))}
- +
); } diff --git a/src/components/DataTable/MobileDataTablePagination.tsx b/src/components/DataTable/MobileDataTablePagination.tsx index c6c28558..d78ccea5 100644 --- a/src/components/DataTable/MobileDataTablePagination.tsx +++ b/src/components/DataTable/MobileDataTablePagination.tsx @@ -4,28 +4,37 @@ import { Table } from '@tanstack/react-table'; import { Button } from '@/components/ui'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.tsx'; import { PAGE_SIZES } from '@/constants'; +import { usePagination } from './usePagination'; interface MobileDataTablePaginationProps { table: Table; + manualPagination?: boolean; + totalRecords?: number; } -export function MobileDataTablePagination({ table }: MobileDataTablePaginationProps) { +export function MobileDataTablePagination({ + table, + manualPagination = false, + totalRecords, +}: MobileDataTablePaginationProps) { + const { total, currentPageSize, canPreviousPage, canNextPage, handlePageSizeChange, handlePageChange } = + usePagination({ + table, + manualPagination, + totalRecords, + }); + return (
- Total records: {table.getFilteredRowModel().rows.length} + Total records: {total}

Rows per page

- - + {PAGE_SIZES.map((pageSize) => ( @@ -44,8 +53,10 @@ export function MobileDataTablePagination({ table }: MobileDataTablePagin
-

+

{paymail.status === 'deleted' ? ( Deleted ) : paymail.status === 'revoked' ? ( @@ -72,7 +73,7 @@ export const PaymailMobileItem = ({ paymail }: PaymailMobileItemProps) => { ) : ( Active )} -

+
@@ -136,38 +137,23 @@ export const PaymailMobileItem = ({ paymail }: PaymailMobileItemProps) => { export interface PaymailsMobileListProps { paymails: PaymailColumnsMobile[]; + pagination?: PaginationProps; } -export const PaymailsMobileList = ({ paymails }: PaymailsMobileListProps) => { - const [expandedItems, setExpandedItems] = useState([]); - const [isAllExpanded, setIsAllExpanded] = useState(false); - - const toggleExpandAll = () => { - createToggleExpandAll(paymails, isAllExpanded, setExpandedItems, setIsAllExpanded, (paymail) => paymail.id); - }; - +export const PaymailsMobileList = ({ paymails, pagination }: PaymailsMobileListProps) => { return ( -
-
- -
-
- - {paymails.map((paymail) => ( - - ))} - -
-
+ ( + + )} + pagination={pagination} + /> ); }; diff --git a/src/components/PaymailsTabContent/PaymailsTabContent.tsx b/src/components/PaymailsTabContent/PaymailsTabContent.tsx index 23b63dc1..8ff0495b 100644 --- a/src/components/PaymailsTabContent/PaymailsTabContent.tsx +++ b/src/components/PaymailsTabContent/PaymailsTabContent.tsx @@ -7,20 +7,35 @@ import { MobileDataTable, NoRecordsText, paymailColumns, - PaymailDeleteDialog, - ViewDialog, + PaymailDeleteDialog as BasePaymailDeleteDialog, + ViewDialog as BaseViewDialog, } from '@/components'; import { PaymailMobileItem } from '@/components/PaymailsColumns/PaymailColumnsMobile'; import { PaymailExtended } from '@/interfaces/paymail.ts'; import { useIsAdmin } from '@/store/clientStore'; +import { PaginationProps } from '@/components/DataTable/DataTable'; +import { Row } from '@tanstack/react-table'; +import { RowType } from '../DataTable/DataTable'; +import { PaymailAddress } from '@bsv/spv-wallet-js-client'; + +// Create wrapper components that handle the type conversion +const ViewDialog = ({ row }: { row: Row }) => { + return } />; +}; + +const PaymailDeleteDialog = ({ row }: { row: Row }) => { + return } />; +}; export interface PaymailsTabContentProps { paymails: PaymailExtended[]; hasPaymailDeleteDialog?: boolean; + pagination?: PaginationProps; } -export const PaymailsTabContent = ({ paymails, hasPaymailDeleteDialog }: PaymailsTabContentProps) => { +export const PaymailsTabContent = ({ paymails, hasPaymailDeleteDialog, pagination }: PaymailsTabContentProps) => { const isAdminUser = useIsAdmin(); + return ( @@ -43,13 +58,17 @@ export const PaymailsTabContent = ({ paymails, hasPaymailDeleteDialog }: Paymail ); }} + pagination={pagination} />
} + renderMobileItem={(item: PaymailExtended, expandedState) => ( + + )} + pagination={pagination} />
diff --git a/src/components/TransactionsColumns/columns.tsx b/src/components/TransactionsColumns/TransactionColumns.tsx similarity index 100% rename from src/components/TransactionsColumns/columns.tsx rename to src/components/TransactionsColumns/TransactionColumns.tsx diff --git a/src/components/TransactionsColumns/TransactionColumnsMobile.tsx b/src/components/TransactionsColumns/TransactionColumnsMobile.tsx index 81caec16..b6e70afb 100644 --- a/src/components/TransactionsColumns/TransactionColumnsMobile.tsx +++ b/src/components/TransactionsColumns/TransactionColumnsMobile.tsx @@ -1,7 +1,7 @@ -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; +import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; -import { EllipsisVertical, ChevronDown, ChevronUp } from 'lucide-react'; +import { EllipsisVertical } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, @@ -10,12 +10,12 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ViewDialogMobile } from '@/components/ViewDialog/ViewDialogMobile'; -import { useState } from 'react'; import { truncateId } from '@/utils/string'; -import { createToggleExpandAll } from '@/utils/expandUtils'; import { renderTransactionStatusBadge } from '@/utils'; import { TransactionExtended } from '@/interfaces/transaction'; import { TransactionStatusValue } from '@/constants'; +import { MobileDataTable } from '@/components/DataTable/MobileDataTable'; +import { PaginationProps } from '@/components/DataTable/DataTable'; const onClickCopy = (value: string, label: string) => async () => { if (!value) { @@ -27,6 +27,7 @@ const onClickCopy = (value: string, label: string) => async () => { interface TransactionMobileItemProps { transaction: TransactionExtended; + expandedState?: { expandedItems: string[]; setExpandedItems: (value: string[]) => void }; } export const TransactionMobileItem = ({ transaction }: TransactionMobileItemProps) => { @@ -51,7 +52,7 @@ export const TransactionMobileItem = ({ transaction }: TransactionMobileItemProp ID: {truncateId(transaction.id)}

-

{renderStatusBadge(transaction.status)}

+
{renderStatusBadge(transaction.status)}
@@ -98,53 +99,24 @@ export interface TransactionsMobileListProps { transactions: TransactionExtended[]; value?: string[]; onValueChange?: (value: string[]) => void; + pagination?: PaginationProps; } -export const TransactionsMobileList = ({ transactions, value, onValueChange }: TransactionsMobileListProps) => { - const [expandedItems, setExpandedItems] = useState(value || []); - const [isAllExpanded, setIsAllExpanded] = useState(false); - - const toggleExpandAll = () => { - createToggleExpandAll( - transactions, - isAllExpanded, - (ids) => { - setExpandedItems(ids); - onValueChange?.(ids); - }, - setIsAllExpanded, - (transaction) => transaction.id, - ); - }; - - const handleValueChange = (newValue: string[]) => { - setExpandedItems(newValue); - onValueChange?.(newValue); - setIsAllExpanded(newValue.length === transactions.length); - }; - +export const TransactionsMobileList = ({ transactions, pagination }: TransactionsMobileListProps) => { + // Use MobileDataTable for pagination support return ( -
-
- -
-
- - {transactions.map((transaction) => ( - - ))} - -
-
+ ( + + )} + pagination={pagination} + /> ); }; diff --git a/src/components/TransactionsTabContent/TransactionsTabContent.tsx b/src/components/TransactionsTabContent/TransactionsTabContent.tsx index 6d340a06..5aa082fb 100644 --- a/src/components/TransactionsTabContent/TransactionsTabContent.tsx +++ b/src/components/TransactionsTabContent/TransactionsTabContent.tsx @@ -1,14 +1,36 @@ -import { Card, CardContent, CardHeader, CardTitle, DataTable, TransactionEditDialog, ViewDialog } from '@/components'; -import { columns } from '@/components/TransactionsColumns/columns.tsx'; +import { + Card, + CardContent, + CardHeader, + CardTitle, + DataTable, + TransactionEditDialog as BaseTransactionEditDialog, + ViewDialog as BaseViewDialog, +} from '@/components'; +import { columns } from '@/components/TransactionsColumns/TransactionColumns'; import { TransactionsMobileList } from '@/components/TransactionsColumns/TransactionColumnsMobile'; +import { Tx } from '@bsv/spv-wallet-js-client'; import React from 'react'; +import { PaginationProps } from '@/components/DataTable/DataTable'; +import { Row } from '@tanstack/react-table'; +import { RowType } from '../DataTable/DataTable'; import { TransactionExtended } from '@/interfaces/transaction'; +// Create wrapper components that handle the type conversion +const ViewDialog = ({ row }: { row: Row }) => { + return } />; +}; + +const TransactionEditDialog = ({ row }: { row: Row }) => { + return } />; +}; + export interface TransactionsTabContentProps { transactions: TransactionExtended[]; hasRecordTransaction?: boolean; hasTransactionEditDialog?: boolean; TxDialog: React.ComponentType; + pagination?: PaginationProps; } export const TransactionsTabContent = ({ @@ -16,6 +38,7 @@ export const TransactionsTabContent = ({ hasRecordTransaction, hasTransactionEditDialog, TxDialog, + pagination, }: TransactionsTabContentProps) => { return ( @@ -35,10 +58,11 @@ export const TransactionsTabContent = ({ {hasTransactionEditDialog && } )} + pagination={pagination} />
- +
) : ( diff --git a/src/components/XPubColumns/XPubColumnsMobile.tsx b/src/components/XPubColumns/XPubColumnsMobile.tsx index 809bda0d..08e9720d 100644 --- a/src/components/XPubColumns/XPubColumnsMobile.tsx +++ b/src/components/XPubColumns/XPubColumnsMobile.tsx @@ -1,8 +1,10 @@ import { XPub } from '@bsv/spv-wallet-js-client'; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; +import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { truncateId } from '@/utils/string'; +import { MobileDataTable } from '@/components/DataTable/MobileDataTable'; +import { PaginationProps } from '@/components/DataTable/DataTable'; export interface XPubColumnsMobile extends XPub { status: string; @@ -19,6 +21,7 @@ const onClickCopy = (value: string, label: string) => async () => { export interface XPubMobileItemProps { xpub: XPubColumnsMobile; + expandedState?: { expandedItems: string[]; setExpandedItems: (value: string[]) => void }; } export const XPubMobileItem = ({ xpub }: XPubMobileItemProps) => { @@ -76,14 +79,24 @@ export const XPubMobileItem = ({ xpub }: XPubMobileItemProps) => { export interface XPubMobileListProps { xpubs: XPubColumnsMobile[]; + pagination?: PaginationProps; } -export const XPubMobileList = ({ xpubs }: XPubMobileListProps) => { +export const XPubMobileList = ({ xpubs, pagination }: XPubMobileListProps) => { + // Use MobileDataTable for pagination support return ( - - {xpubs.map((xpub) => ( - - ))} - + ( + + )} + pagination={pagination} + /> ); }; diff --git a/src/components/XpubsTabContent/XpubsTabContent.tsx b/src/components/XpubsTabContent/XpubsTabContent.tsx index aa1a3fc9..229e21b4 100644 --- a/src/components/XpubsTabContent/XpubsTabContent.tsx +++ b/src/components/XpubsTabContent/XpubsTabContent.tsx @@ -6,17 +6,26 @@ import { CardTitle, DataTable, MobileDataTable, - ViewDialog, + ViewDialog as BaseViewDialog, xPubsColumns, } from '@/components'; import { XPubMobileItem } from '@/components/XPubColumns/XPubColumnsMobile'; import { XpubExtended } from '@/interfaces'; +import { PaginationProps } from '@/components/DataTable/DataTable'; +import { Row } from '@tanstack/react-table'; +import { RowType } from '../DataTable/DataTable'; + +// Create wrapper component that handles the type conversion +const ViewDialog = ({ row }: { row: Row }) => { + return } />; +}; export interface XpubsTabContentProps { xpubs: XpubExtended[]; + pagination?: PaginationProps; } -export const XpubsTabContent = ({ xpubs }: XpubsTabContentProps) => { +export const XpubsTabContent = ({ xpubs, pagination }: XpubsTabContentProps) => { return ( @@ -26,13 +35,21 @@ export const XpubsTabContent = ({ xpubs }: XpubsTabContentProps) => { {xpubs.length > 0 ? ( <>
- } /> + } + pagination={pagination} + />
} + renderMobileItem={(item: XpubExtended, expandedState) => ( + + )} + pagination={pagination} />
diff --git a/src/constants/index.ts b/src/constants/index.ts index 94b555d6..8aed4e25 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -2,3 +2,4 @@ export * from './keys'; export * from './pageSize'; export * from './contacts'; export * from './transactions'; +export * from './pagination'; diff --git a/src/constants/pagination.ts b/src/constants/pagination.ts new file mode 100644 index 00000000..43f70d8f --- /dev/null +++ b/src/constants/pagination.ts @@ -0,0 +1,20 @@ +export const DEFAULT_PAGE_INDEX = 0; +export const DEFAULT_PAGE_SIZE = 10; +export const DEFAULT_API_PAGE = 1; // API pages often start at 1 instead of 0 + +export const DEFAULT_PAGINATION = { + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, +}; + +// Utility functions for pagination conversion +export const convertToApiPage = (uiPage: number): number => uiPage + 1; +export const convertFromApiPage = (apiPage: number): number => apiPage - 1; + +// Interface for API pagination response +export interface ApiPaginationResponse { + size: number; + number: number; + totalElements: number; + totalPages: number; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 00000000..533a4976 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './useSearchParam'; diff --git a/src/routes/admin/_admin.access-keys.tsx b/src/routes/admin/_admin.access-keys.tsx index 3823d7fb..9dfde759 100644 --- a/src/routes/admin/_admin.access-keys.tsx +++ b/src/routes/admin/_admin.access-keys.tsx @@ -1,7 +1,6 @@ import { AccessKeysTabContent, CustomErrorComponent, - DateRangeFilter, Searchbar, Tabs, TabsContent, @@ -13,58 +12,101 @@ import { import { accessKeysAdminQueryOptions, addStatusField, getDeletedElements, getRevokedElements } from '@/utils'; import { useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'; +import { AccessKey } from '@bsv/spv-wallet-js-client'; -import { useEffect, useState } from 'react'; - +import { useEffect, useState, useCallback, useMemo } from 'react'; +import { useDebounce } from 'use-debounce'; import { z } from 'zod'; -import { useSearchParam } from '@/hooks/useSearchParam.ts'; +import { ApiPaginationResponse, DEFAULT_PAGE_SIZE, DEFAULT_API_PAGE, convertFromApiPage } from '@/constants/pagination'; +import { useRoutePagination } from '@/components/DataTable'; + +interface AccessKeysApiResponse { + content: AccessKey[]; + page: ApiPaginationResponse; +} export const Route = createFileRoute('/admin/_admin/access-keys')({ component: AccessKeys, validateSearch: z.object({ - createdRange: z.object({ from: z.string(), to: z.string() }).optional().catch(undefined), + xpubId: z.string().optional(), sortBy: z.string().optional().catch('id'), - revokedRange: z.object({ from: z.string(), to: z.string() }).optional().catch(undefined), - sort: z.string().optional().catch('desc'), - updatedRange: z.object({ from: z.string(), to: z.string() }).optional().catch(undefined), - xpubId: z.string().optional().catch(''), + sort: z.string().optional().catch('asc'), + createdRange: z.object({ from: z.string(), to: z.string() }).optional(), + updatedRange: z.object({ from: z.string(), to: z.string() }).optional(), + revokedRange: z.object({ from: z.string(), to: z.string() }).optional(), + page: z.coerce.number().optional().default(DEFAULT_API_PAGE).catch(DEFAULT_API_PAGE), + size: z.coerce.number().optional().default(DEFAULT_PAGE_SIZE).catch(DEFAULT_PAGE_SIZE), }), - errorComponent: ({ error }) => , - loaderDeps: ({ search: { sortBy, sort, xpubId, createdRange, updatedRange, revokedRange } }) => ({ + loaderDeps: ({ search: { xpubId, sortBy, sort, createdRange, updatedRange, revokedRange, page, size } }) => ({ + xpubId, sortBy, sort, - xpubId, createdRange, updatedRange, revokedRange, + page, + size, }), + errorComponent: ({ error }) => , loader: async ({ context: { queryClient }, - deps: { sortBy, sort, xpubId, createdRange, revokedRange, updatedRange }, - }) => { + deps: { xpubId, sortBy, sort, createdRange, updatedRange, revokedRange, page, size }, + }) => await queryClient.ensureQueryData( accessKeysAdminQueryOptions({ xpubId, + sortBy, + sort, createdRange, updatedRange, revokedRange, - sort, - sortBy, + page, + size, }), - ); - }, + ), }); export function AccessKeys() { const [tab, setTab] = useState('all'); - const navigate = useNavigate({ from: Route.fullPath }); - const { sortBy, sort, createdRange, updatedRange, revokedRange } = useSearch({ + const { + xpubId, + sortBy, + sort, + createdRange, + updatedRange, + revokedRange, + page = DEFAULT_API_PAGE, + size = DEFAULT_PAGE_SIZE, + } = useSearch({ from: '/admin/_admin/access-keys', }); - const [xpubId, setXPubId] = useSearchParam('/admin/_admin/access-keys', 'xpubId'); - const { data: accessKeys } = useSuspenseQuery( + const navigate = useNavigate({ from: Route.fullPath }); + + const pagination = useRoutePagination('/admin/_admin/access-keys'); + + // Local filter state for input field + const [filter, setFilter] = useState(xpubId || ''); + const [debouncedFilter] = useDebounce(filter, 500); + + // Update URL when the debounced filter changes + useEffect(() => { + navigate({ + search: (old) => ({ + ...old, + xpubId: debouncedFilter || undefined, + }), + replace: true, + }); + }, [debouncedFilter, navigate]); + + // Sync filter input when URL xpubId changes + useEffect(() => { + setFilter(xpubId || ''); + }, [xpubId]); + + const { data } = useSuspenseQuery( accessKeysAdminQueryOptions({ xpubId, sortBy, @@ -72,83 +114,112 @@ export function AccessKeys() { createdRange, updatedRange, revokedRange, + page, + size, }), ); - const mappedAccessKeys = addStatusField(accessKeys.content); - const revokedKeys = getRevokedElements(mappedAccessKeys); - const deletedKeys = getDeletedElements(mappedAccessKeys); + const accessKeysResponse = data as AccessKeysApiResponse; - useEffect(() => { - if (tab !== 'all') { - navigate({ - search: () => ({}), - replace: false, - }).catch(console.error); - } - }, [tab]); + // Memoize data transformations + const mappedAccessKeys = useMemo(() => addStatusField(accessKeysResponse.content), [accessKeysResponse.content]); + const revokedKeys = useMemo(() => getRevokedElements(mappedAccessKeys), [mappedAccessKeys]); + const deletedKeys = useMemo(() => getDeletedElements(mappedAccessKeys), [mappedAccessKeys]); + + const { totalElements, totalPages } = accessKeysResponse.page; + // Adjust for 0-indexed UI pagination + const currentPage = convertFromApiPage(accessKeysResponse.page.number); + const pageSize = accessKeysResponse.page.size || DEFAULT_PAGE_SIZE; + + // Memoize tab change handler + const handleTabChange = useCallback( + (value: string) => { + setTab(value); + if (value !== 'all') { + navigate({ + search: () => ({}), + replace: true, + }); + } + }, + [navigate], + ); + + // Helper component for TabsTrigger to reduce duplication + const TabButton = ({ value, children }: { value: string; children: React.ReactNode }) => ( + + {children} + + ); return ( <> - +
- {/* Desktop version */} + {/* Desktop Tabs */} - - All - - - Revoked - - - Deleted - + All + Revoked + Deleted - {/* Mobile version */} - - - All - - - Revoked - - - Deleted - + {/* Mobile Tabs */} + + All + Revoked + Deleted -
- - +
+
+ - + - + {}, + onPageSizeChange: () => {}, + }} + /> - + {}, + onPageSizeChange: () => {}, + }} + /> diff --git a/src/routes/admin/_admin.contacts.tsx b/src/routes/admin/_admin.contacts.tsx index 1c4c4892..887970e6 100644 --- a/src/routes/admin/_admin.contacts.tsx +++ b/src/routes/admin/_admin.contacts.tsx @@ -1,6 +1,5 @@ import { ContactsTabContent, - ContactStatus, CustomErrorComponent, DateRangeFilter, Searchbar, @@ -10,16 +9,21 @@ import { TabsTrigger, Toaster, } from '@/components'; - -import { contactsQueryOptions } from '@/utils'; +import { contactsQueryOptions, ContactStatus as ApiContactStatus } from '@/utils'; import { useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'; - import { useEffect, useState } from 'react'; - import { z } from 'zod'; import { CONTACT_ID_LENGTH } from '@/constants'; import { useSearchParam } from '@/hooks/useSearchParam.ts'; +import { ContactExtended } from '@/interfaces/contacts'; +import { ApiPaginationResponse, DEFAULT_PAGE_SIZE, DEFAULT_API_PAGE, convertFromApiPage } from '@/constants/pagination'; +import { useRoutePagination } from '@/components/DataTable'; + +interface ContactsApiResponse { + content: ContactExtended[]; + page: ApiPaginationResponse; +} export const Route = createFileRoute('/admin/_admin/contacts')({ component: Contacts, @@ -31,9 +35,12 @@ export const Route = createFileRoute('/admin/_admin/contacts')({ id: z.string().optional(), paymail: z.string().optional(), pubKey: z.string().optional(), + status: z.enum(['unconfirmed', 'awaiting', 'confirmed', 'rejected']).optional(), + page: z.coerce.number().optional().catch(DEFAULT_API_PAGE), + size: z.coerce.number().optional().catch(DEFAULT_PAGE_SIZE), }), errorComponent: ({ error }) => , - loaderDeps: ({ search: { sortBy, sort, createdRange, updatedRange, id, paymail, pubKey } }) => ({ + loaderDeps: ({ search: { sortBy, sort, createdRange, updatedRange, id, paymail, pubKey, status, page, size } }) => ({ sortBy, sort, createdRange, @@ -41,11 +48,14 @@ export const Route = createFileRoute('/admin/_admin/contacts')({ id, paymail, pubKey, + status, + page, + size, }), loader: async ({ context: { queryClient }, - deps: { createdRange, updatedRange, sortBy, sort, id, paymail, pubKey }, - }) => + deps: { createdRange, updatedRange, sortBy, sort, id, paymail, pubKey, status, page, size }, + }): Promise => await queryClient.ensureQueryData( contactsQueryOptions({ updatedRange, @@ -55,6 +65,10 @@ export const Route = createFileRoute('/admin/_admin/contacts')({ id, paymail, pubKey, + status, + page, + size, + includeDeleted: true, }), ), }); @@ -63,16 +77,51 @@ export function Contacts() { const [tab, setTab] = useState('all'); const [filter, setFilter] = useState(''); - const { createdRange, updatedRange, sortBy, sort } = useSearch({ + const { + sortBy, + sort, + createdRange, + updatedRange, + page = DEFAULT_API_PAGE, + size = DEFAULT_PAGE_SIZE, + } = useSearch({ from: '/admin/_admin/contacts', }); const [id, setID] = useSearchParam('/admin/_admin/contacts', 'id'); const [paymail, setPaymail] = useSearchParam('/admin/_admin/contacts', 'paymail'); const [pubKey, setPubKey] = useSearchParam('/admin/_admin/contacts', 'pubKey'); + const [statusParam, setStatusParam] = useSearchParam('/admin/_admin/contacts', 'status'); - const { - data: { content: contacts }, - } = useSuspenseQuery( + const navigate = useNavigate({ from: Route.fullPath }); + + // Use our custom pagination hook + const pagination = useRoutePagination('/admin/_admin/contacts'); + + // Update status parameter when tab changes + useEffect(() => { + switch (tab) { + case 'deleted': + case 'all': + // For deleted and all tabs, we don't set a status + setStatusParam(undefined); + break; + case 'unconfirmed': + setStatusParam('unconfirmed' as ApiContactStatus); + break; + case 'awaiting': + setStatusParam('awaiting' as ApiContactStatus); + break; + case 'confirmed': + setStatusParam('confirmed' as ApiContactStatus); + break; + case 'rejected': + setStatusParam('rejected' as ApiContactStatus); + break; + } + }, [tab, setStatusParam]); + + // Use the data from the loader + const { data } = useSuspenseQuery( contactsQueryOptions({ updatedRange, createdRange, @@ -81,87 +130,79 @@ export function Contacts() { id, paymail, pubKey, + page, + size, + status: statusParam as ApiContactStatus, + includeDeleted: true, }), ); - const unconfirmedContacts = contacts.filter((c) => c.status === ContactStatus.Unconfirmed && c.deletedAt === null); - const awaitingContacts = contacts.filter((c) => c.status === ContactStatus.Awaiting); - const confirmedContacts = contacts.filter((c) => c.status === ContactStatus.Confirmed); - const rejectedContacts = contacts.filter((c) => c.status === ContactStatus.Rejected); - const deletedContacts = contacts.filter((c) => c.deletedAt !== null); + const contactsResponse = data as ContactsApiResponse; - const navigate = useNavigate({ from: Route.fullPath }); + const contacts = contactsResponse.content; + const totalElements = contactsResponse.page.totalElements; + const totalPages = contactsResponse.page.totalPages; + const pageSize = contactsResponse.page.size; + const currentPage = contactsResponse.page.number; + // Clear search parameters when the tab is not "all", but preserve the status parameter useEffect(() => { if (tab !== 'all') { navigate({ - search: () => ({}), + search: (old) => ({ status: old.status }), replace: false, }); } - }, [tab]); + }, [tab, navigate]); + // Update search parameters based on filter input useEffect(() => { - if (!filter) { - setID(undefined); - setPaymail(undefined); - setPubKey(undefined); - return; - } - - if (filter.length === CONTACT_ID_LENGTH) { - setID(filter); - } else if (filter.includes('@')) { - setPaymail(filter); + if (filter) { + if (filter.length === CONTACT_ID_LENGTH) { + setID(filter); + setPaymail(''); + setPubKey(''); + } else { + setID(''); + if (filter.includes('@')) { + setPaymail(filter); + setPubKey(''); + } else { + setPaymail(''); + setPubKey(filter); + } + } } else { - setPubKey(filter); + setID(''); + setPaymail(''); + setPubKey(''); } - }, [filter]); + }, [filter, setID, setPaymail, setPubKey]); + + // Helper component to abstract TabsTrigger styling + const TabButton = ({ value, children }: { value: string; children: React.ReactNode }) => ( + + {children} + + ); return ( <> - +
{/* Desktop version - single row */} - - All - - - Awaiting - - - Rejected - - - Confirmed - - - Unconfirmed - - - Deleted - + All + Awaiting + Rejected + Confirmed + Unconfirmed + Deleted {/* Mobile version - stacked rows */} @@ -221,22 +262,32 @@ export function Contacts() {
- + - + - + - + - + - + c.deletedAt !== null)} /> diff --git a/src/routes/admin/_admin.paymails.tsx b/src/routes/admin/_admin.paymails.tsx index c84316d2..eaef9f7a 100644 --- a/src/routes/admin/_admin.paymails.tsx +++ b/src/routes/admin/_admin.paymails.tsx @@ -13,11 +13,17 @@ import { import { addStatusField, getDeletedElements, paymailsAdminQueryOptions } from '@/utils'; import { useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'; - -import { useEffect, useState } from 'react'; - +import { useEffect, useState, useMemo } from 'react'; import { z } from 'zod'; import { useSearchParam } from '@/hooks/useSearchParam.ts'; +import { ApiPaginationResponse, DEFAULT_PAGE_SIZE, DEFAULT_API_PAGE, convertFromApiPage } from '@/constants/pagination'; +import { useRoutePagination } from '@/components/DataTable'; +import { PaymailAddress } from '@bsv/spv-wallet-js-client'; + +interface PaymailsApiResponse { + content: PaymailAddress[]; + page: ApiPaginationResponse; +} export const Route = createFileRoute('/admin/_admin/paymails')({ component: Paymails, @@ -27,16 +33,23 @@ export const Route = createFileRoute('/admin/_admin/paymails')({ xpubId: z.string().optional().catch(''), createdRange: z.object({ from: z.string(), to: z.string() }).optional().catch(undefined), updatedRange: z.object({ from: z.string(), to: z.string() }).optional().catch(undefined), + page: z.coerce.number().optional().catch(DEFAULT_API_PAGE), + size: z.coerce.number().optional().catch(DEFAULT_PAGE_SIZE), }), - loaderDeps: ({ search: { sortBy, sort, xpubId, createdRange, updatedRange } }) => ({ + loaderDeps: ({ search: { sortBy, sort, xpubId, createdRange, updatedRange, page, size } }) => ({ sortBy, sort, xpubId, createdRange, updatedRange, + page, + size, }), errorComponent: ({ error }) => , - loader: async ({ context: { queryClient }, deps: { sort, sortBy, xpubId, createdRange, updatedRange } }) => + loader: async ({ + context: { queryClient }, + deps: { sort, sortBy, xpubId, createdRange, updatedRange, page, size }, + }): Promise => await queryClient.ensureQueryData( paymailsAdminQueryOptions({ xpubId, @@ -44,44 +57,66 @@ export const Route = createFileRoute('/admin/_admin/paymails')({ sortBy, createdRange, updatedRange, + page, + size, + includeDeleted: true, }), ), }); export function Paymails() { const [tab, setTab] = useState('all'); - const navigate = useNavigate({ from: Route.fullPath }); - const { sortBy, sort, createdRange, updatedRange } = useSearch({ + const { + sortBy, + sort, + createdRange, + updatedRange, + page = DEFAULT_API_PAGE, + size = DEFAULT_PAGE_SIZE, + } = useSearch({ from: '/admin/_admin/paymails', }); - const [xpubId, setXPubId] = useSearchParam('/admin/_admin/paymails', 'xpubId'); + const [xpubId, setXpubId] = useSearchParam('/admin/_admin/paymails', 'xpubId'); - const { data: paymails } = useSuspenseQuery( + const pagination = useRoutePagination('/admin/_admin/paymails'); + + const { data } = useSuspenseQuery( paymailsAdminQueryOptions({ xpubId, sortBy, sort, createdRange, updatedRange, + page, + size, + includeDeleted: true, }), ); - const mappedPaymails = addStatusField(paymails.content); - const deletedPaymails = getDeletedElements(mappedPaymails); + // Cast the response to access the pagination data + const paymailsResponse = data as PaymailsApiResponse; + + const { content: paymails, page: apiPage } = paymailsResponse; + const { totalElements, totalPages } = apiPage; + + // Memoize data transformations + const mappedPaymails = useMemo(() => addStatusField(paymails), [paymails]); + const deletedPaymails = useMemo(() => getDeletedElements(mappedPaymails), [mappedPaymails]); + // Clear URL search parameters when not in 'all' tab useEffect(() => { if (tab !== 'all') { navigate({ search: () => ({}), - replace: false, + replace: true, }).catch(console.error); } - }, [tab]); + }, [tab, navigate]); return ( <> - +
- +
@@ -110,7 +145,18 @@ export function Paymails() {
- + diff --git a/src/routes/admin/_admin.transactions.tsx b/src/routes/admin/_admin.transactions.tsx index 3a034290..8ac0f2e3 100644 --- a/src/routes/admin/_admin.transactions.tsx +++ b/src/routes/admin/_admin.transactions.tsx @@ -1,36 +1,49 @@ -import { CustomErrorComponent, DateRangeFilter, Searchbar, Toaster, TransactionsTabContent } from '@/components'; - import { transactionSearchSchema } from '@/searchSchemas'; -import { formatStatusLabel, transactionsQueryOptions } from '@/utils'; -import { useSuspenseQuery } from '@tanstack/react-query'; -import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'; -import { Button } from '@/components/ui/button'; import { + Button, + CustomErrorComponent, + DateRangeFilter, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { ChevronDown } from 'lucide-react'; + Searchbar, + Toaster, + TransactionsTabContent, +} from '@/components'; import { TRANSACTION_STATUS, TransactionStatusType } from '@/constants'; - +import { formatStatusLabel, transactionsQueryOptions } from '@/utils'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'; import { useSearchParam } from '@/hooks/useSearchParam.ts'; +import { useCallback, useMemo } from 'react'; +import { ApiPaginationResponse, DEFAULT_PAGE_SIZE, DEFAULT_API_PAGE, convertFromApiPage } from '@/constants/pagination'; +import { TransactionExtended } from '@/interfaces/transaction'; +import { useRoutePagination } from '@/components/DataTable'; +import { ChevronDown } from 'lucide-react'; + +interface TransactionsApiResponse { + content: TransactionExtended[]; + page: ApiPaginationResponse; +} export const Route = createFileRoute('/admin/_admin/transactions')({ component: Transactions, validateSearch: transactionSearchSchema, - loaderDeps: ({ search: { sortBy, sort, blockHeight, createdRange, updatedRange, status } }) => ({ + loaderDeps: ({ search: { sortBy, sort, blockHeight, createdRange, updatedRange, status, page, size } }) => ({ sortBy, sort, blockHeight, createdRange, updatedRange, status, + page, + size, }), errorComponent: ({ error }) => , loader: async ({ context: { queryClient }, - deps: { sort, sortBy, blockHeight, createdRange, updatedRange, status }, + deps: { sort, sortBy, blockHeight, createdRange, updatedRange, status, page, size }, }) => await queryClient.ensureQueryData( transactionsQueryOptions({ @@ -40,16 +53,30 @@ export const Route = createFileRoute('/admin/_admin/transactions')({ createdRange, updatedRange, status, + page, + size, }), ), }); export function Transactions() { - const { sortBy, sort, createdRange, updatedRange, status } = useSearch({ from: '/admin/_admin/transactions' }); + const { + sortBy, + sort, + createdRange, + updatedRange, + status, + page = DEFAULT_API_PAGE, + size = DEFAULT_PAGE_SIZE, + } = useSearch({ + from: '/admin/_admin/transactions', + }); const [blockHeight, setBlockHeight] = useSearchParam('/admin/_admin/transactions', 'blockHeight'); - const navigate = useNavigate(); + const navigate = useNavigate({ from: Route.fullPath }); - const { data: transactions } = useSuspenseQuery( + const pagination = useRoutePagination('/admin/_admin/transactions'); + + const { data } = useSuspenseQuery( transactionsQueryOptions({ blockHeight, sortBy, @@ -57,25 +84,36 @@ export function Transactions() { createdRange, updatedRange, status, + page, + size, }), ); - const handleStatusChange = (newStatus: string | null) => { - navigate({ - to: '.', - search: (prev) => ({ ...prev, status: newStatus }), - replace: true, - }); - }; + const transactions = data as TransactionsApiResponse; + + // Memoize current status key lookup + const currentStatusKey = useMemo(() => { + const currentStatus = status || null; + return Object.entries(TRANSACTION_STATUS).find(([, value]) => value === currentStatus)?.[0] || 'ALL'; + }, [status]); - const currentStatus = status || null; - const currentStatusKey = - Object.entries(TRANSACTION_STATUS).find(([, value]) => value === currentStatus)?.[0] || 'ALL'; + // Event handler to change status filter + const handleStatusChange = useCallback( + (newStatus: string | null) => { + navigate({ + to: '.', + search: (prev) => ({ ...prev, status: newStatus }), + replace: true, + }); + }, + [navigate], + ); return ( <>
+ {/* Status Dropdown */}
Status: @@ -94,6 +132,8 @@ export function Transactions() {
+ + {/* Date Filter & Block Height Search */}
+ + {/* Transactions Table */}
null} + pagination={{ + currentPage: convertFromApiPage(Number(page)), // Convert API's 1-indexed page to UI's 0-indexed + pageSize: Number(size), + totalPages: transactions.page.totalPages, + totalElements: transactions.page.totalElements, + onPageChange: pagination.onPageChange, + onPageSizeChange: pagination.onPageSizeChange, + }} />
diff --git a/src/routes/admin/_admin.xpub.tsx b/src/routes/admin/_admin.xpub.tsx index caf016f7..f8539a26 100644 --- a/src/routes/admin/_admin.xpub.tsx +++ b/src/routes/admin/_admin.xpub.tsx @@ -7,17 +7,23 @@ import { TabsList, TabsTrigger, Toaster, - XpubsSkeleton, XpubsTabContent, + XpubsSkeleton, } from '@/components'; - import { addStatusField, xPubQueryOptions } from '@/utils'; import { useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useSearch } from '@tanstack/react-router'; -import { useState } from 'react'; - +import { ApiPaginationResponse, DEFAULT_PAGE_SIZE, DEFAULT_API_PAGE, convertFromApiPage } from '@/constants/pagination'; +import { XPub } from '@bsv/spv-wallet-js-client'; +import { useState, useMemo } from 'react'; import { z } from 'zod'; import { useSearchParam } from '@/hooks/useSearchParam.ts'; +import { useRoutePagination } from '@/components/DataTable'; + +interface XpubsApiResponse { + content: XPub[]; + page: ApiPaginationResponse; +} export const Route = createFileRoute('/admin/_admin/xpub')({ component: Xpub, @@ -25,19 +31,26 @@ export const Route = createFileRoute('/admin/_admin/xpub')({ sortBy: z.string().optional().catch('id'), sort: z.string().optional().catch('asc'), id: z.string().optional(), + page: z.coerce.number().optional().default(DEFAULT_API_PAGE).catch(DEFAULT_API_PAGE), + size: z.coerce.number().optional().default(DEFAULT_PAGE_SIZE).catch(DEFAULT_PAGE_SIZE), }), - loaderDeps: ({ search: { sortBy, sort, id } }) => ({ + loaderDeps: ({ search: { sortBy, sort, id, page, size } }) => ({ sortBy, sort, id, + page, + size, }), errorComponent: ({ error }) => , - loader: async ({ context: { queryClient }, deps: { sortBy, sort, id } }) => + notFoundComponent: () => , + loader: async ({ context: { queryClient }, deps: { sortBy, sort, id, page, size } }) => await queryClient.ensureQueryData( xPubQueryOptions({ id, sort, sortBy, + page, + size, }), ), pendingComponent: () => , @@ -45,12 +58,25 @@ export const Route = createFileRoute('/admin/_admin/xpub')({ export function Xpub() { const [tab, setTab] = useState('all'); - - const { sortBy, sort } = useSearch({ from: '/admin/_admin/xpub' }); + const { sortBy, sort, page = DEFAULT_API_PAGE, size = DEFAULT_PAGE_SIZE } = useSearch({ from: '/admin/_admin/xpub' }); const [id, setID] = useSearchParam('/admin/_admin/xpub', 'id'); - const { data: xpubs } = useSuspenseQuery(xPubQueryOptions({ id, sortBy, sort })); - const mappedXpubs = addStatusField(xpubs.content); + const pagination = useRoutePagination('/admin/_admin/xpub'); + + const { data } = useSuspenseQuery( + xPubQueryOptions({ + id, + sortBy, + sort, + page, + size, + }), + ); + + const xpubs = data as XpubsApiResponse; + + // Memoize the transformed xpubs data + const mappedXpubs = useMemo(() => addStatusField(xpubs.content), [xpubs.content]); return ( <> @@ -65,7 +91,17 @@ export function Xpub() {
- +
diff --git a/src/routes/user/_user.access-keys.tsx b/src/routes/user/_user.access-keys.tsx index d0b391fe..01ca3bfc 100644 --- a/src/routes/user/_user.access-keys.tsx +++ b/src/routes/user/_user.access-keys.tsx @@ -9,14 +9,19 @@ import { TabsTrigger, Toaster, } from '@/components'; - import { accessKeysQueryOptions, addStatusField, getDeletedElements, getRevokedElements } from '@/utils'; import { useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'; - -import { useEffect, useState } from 'react'; - +import { AccessKey } from '@bsv/spv-wallet-js-client'; +import { useEffect, useState, useMemo } from 'react'; import { z } from 'zod'; +import { ApiPaginationResponse, DEFAULT_PAGE_SIZE, DEFAULT_API_PAGE, convertFromApiPage } from '@/constants/pagination'; +import { useRoutePagination } from '@/components/DataTable'; + +interface AccessKeysApiResponse { + content: AccessKey[]; + page: ApiPaginationResponse; +} export const Route = createFileRoute('/user/_user/access-keys')({ component: AccessKeys, @@ -26,23 +31,22 @@ export const Route = createFileRoute('/user/_user/access-keys')({ revokedRange: z.object({ from: z.string(), to: z.string() }).optional().catch(undefined), sort: z.string().optional().catch('desc'), updatedRange: z.object({ from: z.string(), to: z.string() }).optional().catch(undefined), - page: z.number().optional().catch(1), - size: z.number().optional().catch(10), + page: z.number().optional().catch(DEFAULT_API_PAGE), + size: z.number().optional().catch(DEFAULT_PAGE_SIZE), }), - loaderDeps: ({ search: { sortBy, sort, createdRange, revokedRange, updatedRange, page, size } }) => { - return { - sortBy, - sort, - createdRange, - updatedRange, - revokedRange, - page, - size, - }; - }, + loaderDeps: ({ search: { sortBy, sort, createdRange, revokedRange, updatedRange, page, size } }) => ({ + sortBy, + sort, + createdRange, + updatedRange, + revokedRange, + page, + size, + }), + errorComponent: ({ error }) => , loader: async ({ context: { queryClient }, - deps: { sortBy, sort, page, size, createdRange, revokedRange, updatedRange }, + deps: { sortBy, sort, createdRange, updatedRange, revokedRange, page, size }, }) => await queryClient.ensureQueryData( accessKeysQueryOptions({ @@ -55,18 +59,26 @@ export const Route = createFileRoute('/user/_user/access-keys')({ size, }), ), - errorComponent: ({ error }) => , }); export function AccessKeys() { const [tab, setTab] = useState('all'); - const { sortBy, sort, createdRange, updatedRange, revokedRange, page, size } = useSearch({ + const { + sortBy, + sort, + createdRange, + updatedRange, + revokedRange, + page = DEFAULT_API_PAGE, + size = DEFAULT_PAGE_SIZE, + } = useSearch({ from: '/user/_user/access-keys', }); - const navigate = useNavigate({ from: Route.fullPath }); + const pagination = useRoutePagination('/user/_user/access-keys'); + const { data: accessKeys } = useSuspenseQuery( accessKeysQueryOptions({ sortBy, @@ -79,22 +91,31 @@ export function AccessKeys() { }), ); - const mappedAccessKeys = addStatusField(accessKeys.content); - const revokedKeys = getRevokedElements(mappedAccessKeys); - const deletedKeys = getDeletedElements(mappedAccessKeys); + const { content, page: apiPage } = accessKeys as AccessKeysApiResponse; + + // Memoize the transformed access keys + const mappedAccessKeys = useMemo(() => addStatusField(content), [content]); + const revokedKeys = useMemo(() => getRevokedElements(mappedAccessKeys), [mappedAccessKeys]); + const deletedKeys = useMemo(() => getDeletedElements(mappedAccessKeys), [mappedAccessKeys]); + + // Calculate pagination values + const { totalElements, totalPages } = apiPage; + const currentPage = convertFromApiPage(apiPage.number); // Convert from 1-indexed (API) to 0-indexed (UI) + const pageSize = apiPage.size || DEFAULT_PAGE_SIZE; + // Clear URL search parameters when the active tab is not "all" useEffect(() => { if (tab !== 'all') { navigate({ search: () => ({}), - replace: false, + replace: true, }).catch(console.error); } - }, [tab]); + }, [tab, navigate]); return ( <> - +
- + diff --git a/src/routes/user/_user.paymails.tsx b/src/routes/user/_user.paymails.tsx index 5b04a3f0..59604d0a 100644 --- a/src/routes/user/_user.paymails.tsx +++ b/src/routes/user/_user.paymails.tsx @@ -12,10 +12,17 @@ import { import { paymailsQueryOptions, addStatusField, getDeletedElements } from '@/utils'; import { useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'; -import { useState, useEffect } from 'react'; - +import { useState, useEffect, useMemo } from 'react'; import { z } from 'zod'; import { useSearchParam } from '@/hooks/useSearchParam.ts'; +import { ApiPaginationResponse, DEFAULT_PAGE_SIZE, DEFAULT_API_PAGE, convertFromApiPage } from '@/constants/pagination'; +import { useRoutePagination } from '@/components/DataTable'; +import { PaymailAddress } from '@bsv/spv-wallet-js-client'; + +interface PaymailsApiResponse { + content: PaymailAddress[]; + page: ApiPaginationResponse; +} export const Route = createFileRoute('/user/_user/paymails')({ component: Paymails, @@ -25,16 +32,23 @@ export const Route = createFileRoute('/user/_user/paymails')({ alias: z.string().optional().catch(undefined), createdRange: z.object({ from: z.string(), to: z.string() }).optional().catch(undefined), updatedRange: z.object({ from: z.string(), to: z.string() }).optional().catch(undefined), + page: z.coerce.number().optional().catch(DEFAULT_API_PAGE), + size: z.coerce.number().optional().catch(DEFAULT_PAGE_SIZE), }), - loaderDeps: ({ search: { sortBy, sort, createdRange, updatedRange, alias } }) => ({ + loaderDeps: ({ search: { sortBy, sort, createdRange, updatedRange, alias, page, size } }) => ({ sortBy, sort, createdRange, updatedRange, alias, + page, + size, }), errorComponent: ({ error }) => , - loader: async ({ context: { queryClient }, deps: { sort, sortBy, createdRange, updatedRange, alias } }) => + loader: async ({ + context: { queryClient }, + deps: { sort, sortBy, createdRange, updatedRange, alias, page, size }, + }): Promise => await queryClient.ensureQueryData( paymailsQueryOptions({ sort, @@ -42,33 +56,51 @@ export const Route = createFileRoute('/user/_user/paymails')({ createdRange, updatedRange, alias, + page, + size, }), ), }); export function Paymails() { const [tab, setTab] = useState('all'); - const navigate = useNavigate({ from: Route.fullPath }); - const { sortBy, sort, createdRange, updatedRange } = useSearch({ + const { + sortBy, + sort, + createdRange, + updatedRange, + page = DEFAULT_API_PAGE, + size = DEFAULT_PAGE_SIZE, + } = useSearch({ from: '/user/_user/paymails', }); const [alias, setAlias] = useSearchParam('/user/_user/paymails', 'alias'); - const { data: paymails } = useSuspenseQuery( + // Use our custom pagination hook + const pagination = useRoutePagination('/user/_user/paymails'); + + const { data } = useSuspenseQuery( paymailsQueryOptions({ alias, sortBy, sort, createdRange, updatedRange, + page, + size, }), ); - const mappedPaymails = addStatusField(paymails.content); + const paymailsResponse = data as PaymailsApiResponse; + const { content, page: apiPage } = paymailsResponse; + const { totalElements, totalPages } = apiPage; - const deletedPaymails = getDeletedElements(mappedPaymails); + // Memoize data transformations + const mappedPaymails = useMemo(() => addStatusField(content), [content]); + const deletedPaymails = useMemo(() => getDeletedElements(mappedPaymails), [mappedPaymails]); + // Clear search parameters when tab is not "all" useEffect(() => { if (tab !== 'all') { navigate({ @@ -76,11 +108,11 @@ export function Paymails() { replace: false, }).catch(console.error); } - }, [tab]); + }, [tab, navigate]); return ( <> - +
- + diff --git a/src/routes/user/_user.transactions.tsx b/src/routes/user/_user.transactions.tsx index 18fab696..b2e06c97 100644 --- a/src/routes/user/_user.transactions.tsx +++ b/src/routes/user/_user.transactions.tsx @@ -20,43 +20,67 @@ import { } from '@/components/ui/dropdown-menu'; import { ChevronDown } from 'lucide-react'; import { TRANSACTION_STATUS, TransactionStatusType } from '@/constants'; - import { useSearchParam } from '@/hooks/useSearchParam.ts'; +import { ApiPaginationResponse, DEFAULT_PAGE_SIZE, DEFAULT_API_PAGE, convertFromApiPage } from '@/constants/pagination'; +import { useRoutePagination } from '@/components/DataTable'; +import { TransactionExtended } from '@/interfaces/transaction'; +import { useCallback, useMemo } from 'react'; + +interface TransactionsApiResponse { + content: TransactionExtended[]; + page: ApiPaginationResponse; +} export const Route = createFileRoute('/user/_user/transactions')({ component: Transactions, validateSearch: transactionSearchSchema, - loaderDeps: ({ search: { sortBy, sort, blockHeight, createdRange, updatedRange, status } }) => ({ + loaderDeps: ({ search: { sortBy, sort, blockHeight, createdRange, updatedRange, status, page, size } }) => ({ sortBy, sort, blockHeight, createdRange, updatedRange, status, + page, + size, }), errorComponent: ({ error }) => , loader: async ({ context: { queryClient }, - deps: { sort, sortBy, blockHeight, createdRange, updatedRange, status }, + deps: { sort, sortBy, blockHeight, createdRange, updatedRange, status, page, size }, }) => await queryClient.ensureQueryData( transactionsUserQueryOptions({ + blockHeight, sort, sortBy, - blockHeight, createdRange, updatedRange, status, + page, + size, }), ), }); function Transactions() { - const { sortBy, sort, createdRange, updatedRange, status } = useSearch({ from: '/user/_user/transactions' }); + const { + sortBy, + sort, + createdRange, + updatedRange, + status, + page = DEFAULT_API_PAGE, + size = DEFAULT_PAGE_SIZE, + } = useSearch({ + from: '/user/_user/transactions', + }); const [blockHeight, setBlockHeight] = useSearchParam('/user/_user/transactions', 'blockHeight'); - const navigate = useNavigate(); + const navigate = useNavigate({ from: Route.fullPath }); + + const pagination = useRoutePagination('/user/_user/transactions'); - const { data: transactions } = useSuspenseQuery( + const { data } = useSuspenseQuery( transactionsUserQueryOptions({ blockHeight, sortBy, @@ -64,25 +88,36 @@ function Transactions() { createdRange, updatedRange, status, + page, + size, }), ); - const handleStatusChange = (newStatus: string | null) => { - navigate({ - to: '.', - search: (prev) => ({ ...prev, status: newStatus }), - replace: true, - }); - }; + const transactions = data as TransactionsApiResponse; - const currentStatus = status || null; - const currentStatusKey = - Object.entries(TRANSACTION_STATUS).find(([, value]) => value === currentStatus)?.[0] || 'ALL'; + // Memoize current status key lookup based on status from URL + const currentStatusKey = useMemo(() => { + const currentStatus = status || null; + return Object.entries(TRANSACTION_STATUS).find(([, value]) => value === currentStatus)?.[0] || 'ALL'; + }, [status]); + + // Event handler to change status filter + const handleStatusChange = useCallback( + (newStatus: string | null) => { + navigate({ + to: '.', + search: (prev) => ({ ...prev, status: newStatus }), + replace: true, + }); + }, + [navigate], + ); return ( <>
+ {/* Status Dropdown and Tx Dialog */}
Status: @@ -100,8 +135,9 @@ function Transactions() { ))} - +
+ {/* Date Filter & Block Height Search */}
+ {/* Transactions Content */}
@@ -127,3 +172,5 @@ function Transactions() { ); } + +export { Transactions }; diff --git a/src/searchSchemas/transactionSearchSchema.tsx b/src/searchSchemas/transactionSearchSchema.tsx index b1e6977b..8302bc26 100644 --- a/src/searchSchemas/transactionSearchSchema.tsx +++ b/src/searchSchemas/transactionSearchSchema.tsx @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { DEFAULT_PAGE_SIZE, DEFAULT_API_PAGE } from '@/constants/pagination'; export const transactionSearchSchema = z.object({ sortBy: z.string().optional().catch('id'), @@ -7,4 +8,6 @@ export const transactionSearchSchema = z.object({ createdRange: z.object({ from: z.string(), to: z.string() }).optional().catch(undefined), updatedRange: z.object({ from: z.string(), to: z.string() }).optional().catch(undefined), status: z.string().nullable().optional().catch(undefined), + page: z.coerce.number().optional().default(DEFAULT_API_PAGE).catch(DEFAULT_API_PAGE), + size: z.coerce.number().optional().default(DEFAULT_PAGE_SIZE).catch(DEFAULT_PAGE_SIZE), }); diff --git a/src/utils/contactsQueryOptions.tsx b/src/utils/contactsQueryOptions.tsx index 712c9001..43d2ef15 100644 --- a/src/utils/contactsQueryOptions.tsx +++ b/src/utils/contactsQueryOptions.tsx @@ -1,6 +1,8 @@ import { queryOptions } from '@tanstack/react-query'; import { getAdminApi } from '@/store/clientStore'; +export type ContactStatus = 'unconfirmed' | 'awaiting' | 'confirmed' | 'rejected'; + export interface ContactsQueryOptions { page?: number; size?: number; @@ -14,17 +16,44 @@ export interface ContactsQueryOptions { paymail?: string; pubKey?: string; updatedRange?: { from: string; to: string }; + status?: ContactStatus; + includeDeleted?: boolean; } export const contactsQueryOptions = (opts: ContactsQueryOptions) => { - const { createdRange, updatedRange, page, size, sortBy, sort, id, paymail, pubKey } = opts; + const { + createdRange, + updatedRange, + page, + size, + sortBy, + sort, + id, + paymail, + pubKey, + status, + includeDeleted = true, + } = opts; const adminApi = getAdminApi(); return queryOptions({ - queryKey: ['contacts', createdRange, updatedRange, sortBy, sort, id, paymail, pubKey, page, size], + queryKey: [ + 'contacts', + createdRange, + updatedRange, + sortBy, + sort, + id, + paymail, + pubKey, + status, + includeDeleted, + page, + size, + ], queryFn: async () => await adminApi.contacts( - { createdRange, updatedRange, id, paymail, pubKey, includeDeleted: true }, + { createdRange, updatedRange, id, paymail, pubKey, status, includeDeleted }, {}, { sortBy: sortBy ?? 'id', sort: sort ?? 'asc', page, size }, ), diff --git a/src/utils/paymailsAdminQueryOptions.tsx b/src/utils/paymailsAdminQueryOptions.tsx index c4ee43cf..771c22b8 100644 --- a/src/utils/paymailsAdminQueryOptions.tsx +++ b/src/utils/paymailsAdminQueryOptions.tsx @@ -13,17 +13,18 @@ export interface PaymailsAdminQueryOptions { }; updatedRange?: { from: string; to: string }; alias?: string; + includeDeleted?: boolean; } export const paymailsAdminQueryOptions = (opts: PaymailsAdminQueryOptions) => { - const { xpubId, page, size, sortBy, sort, createdRange, updatedRange, alias } = opts; + const { xpubId, page, size, sortBy, sort, createdRange, updatedRange, alias, includeDeleted = true } = opts; const adminApi = getAdminApi(); return queryOptions({ - queryKey: ['paymails', xpubId, page, size, sortBy, sort, createdRange, updatedRange, alias], + queryKey: ['paymails', xpubId, page, size, sortBy, sort, createdRange, updatedRange, alias, includeDeleted], queryFn: async () => await adminApi.paymails( - { alias, xpubId, createdRange, updatedRange, includeDeleted: true }, + { alias, xpubId, createdRange, updatedRange, includeDeleted }, {}, { page,