Merge pull request #85 from MkSaaSHQ/dev/nuqs
feat: integrate with nuqs for users and credit transaction tables
This commit is contained in:
commit
21eee041ab
@ -111,6 +111,7 @@
|
||||
"next-intl": "^4.0.0",
|
||||
"next-safe-action": "^7.10.4",
|
||||
"next-themes": "^0.4.4",
|
||||
"nuqs": "^2.5.1",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.2",
|
||||
"react": "^19.0.0",
|
||||
|
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@ -266,6 +266,9 @@ importers:
|
||||
next-themes:
|
||||
specifier: ^0.4.4
|
||||
version: 0.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
nuqs:
|
||||
specifier: ^2.5.1
|
||||
version: 2.5.1(next@15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.5
|
||||
@ -5388,6 +5391,27 @@ packages:
|
||||
resolution: {integrity: sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
nuqs@2.5.1:
|
||||
resolution: {integrity: sha512-YvAyI01gaEfS6U2iTcfffKccGkqYRnGmLoCHvDjK4ShgtB0tKmYgC7+ez9PmdaiDmrLR+y1qHzfQC66T0VFwWQ==}
|
||||
peerDependencies:
|
||||
'@remix-run/react': '>=2'
|
||||
'@tanstack/react-router': ^1
|
||||
next: '>=14.2.0'
|
||||
react: '>=18.2.0 || ^19.0.0-0'
|
||||
react-router: ^6 || ^7
|
||||
react-router-dom: ^6 || ^7
|
||||
peerDependenciesMeta:
|
||||
'@remix-run/react':
|
||||
optional: true
|
||||
'@tanstack/react-router':
|
||||
optional: true
|
||||
next:
|
||||
optional: true
|
||||
react-router:
|
||||
optional: true
|
||||
react-router-dom:
|
||||
optional: true
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -11439,6 +11463,13 @@ snapshots:
|
||||
|
||||
npm-to-yarn@3.0.1: {}
|
||||
|
||||
nuqs@2.5.1(next@15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0):
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
react: 19.0.0
|
||||
optionalDependencies:
|
||||
next: 15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
@ -44,17 +44,30 @@ export const getCreditTransactionsAction = userActionClient
|
||||
const { pageIndex, pageSize, search, sorting } = parsedInput;
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
|
||||
// search by type, amount, paymentId, description, and restrict to current user
|
||||
// Search logic: text fields use ilike, and if search is a number, also search amount fields
|
||||
const searchConditions = [];
|
||||
if (search) {
|
||||
// Always search text fields
|
||||
searchConditions.push(
|
||||
ilike(creditTransaction.type, `%${search}%`),
|
||||
ilike(creditTransaction.paymentId, `%${search}%`),
|
||||
ilike(creditTransaction.description, `%${search}%`)
|
||||
);
|
||||
|
||||
// If search is a valid number, also search numeric fields
|
||||
const numericSearch = Number.parseInt(search, 10);
|
||||
if (!Number.isNaN(numericSearch)) {
|
||||
searchConditions.push(
|
||||
eq(creditTransaction.amount, numericSearch),
|
||||
eq(creditTransaction.remainingAmount, numericSearch)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const where = search
|
||||
? and(
|
||||
eq(creditTransaction.userId, currentUser.id),
|
||||
or(
|
||||
ilike(creditTransaction.type, `%${search}%`),
|
||||
ilike(creditTransaction.amount, `%${search}%`),
|
||||
ilike(creditTransaction.remainingAmount, `%${search}%`),
|
||||
ilike(creditTransaction.paymentId, `%${search}%`),
|
||||
ilike(creditTransaction.description, `%${search}%`)
|
||||
)
|
||||
or(...searchConditions)
|
||||
)
|
||||
: eq(creditTransaction.userId, currentUser.id);
|
||||
|
||||
|
@ -12,6 +12,7 @@ import { routing } from '@/i18n/routing';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type Locale, NextIntlClientProvider, hasLocale } from 'next-intl';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import { Providers } from './providers';
|
||||
@ -57,6 +58,7 @@ export default async function LocaleLayout({
|
||||
fontBricolageGrotesque.variable
|
||||
)}
|
||||
>
|
||||
<NuqsAdapter>
|
||||
<NextIntlClientProvider>
|
||||
<Providers locale={locale}>
|
||||
{children}
|
||||
@ -66,6 +68,7 @@ export default async function LocaleLayout({
|
||||
<Analytics />
|
||||
</Providers>
|
||||
</NextIntlClientProvider>
|
||||
</NuqsAdapter>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
@ -4,31 +4,56 @@ import { UsersTable } from '@/components/admin/users-table';
|
||||
import { useUsers } from '@/hooks/use-users';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
parseAsIndex,
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
useQueryStates,
|
||||
} from 'nuqs';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export function UsersPageClient() {
|
||||
const t = useTranslations('Dashboard.admin.users');
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'createdAt', desc: true },
|
||||
]);
|
||||
|
||||
const { data, isLoading } = useUsers(pageIndex, pageSize, search, sorting);
|
||||
const [{ page, pageSize, search, sortId, sortDesc }, setQueryStates] =
|
||||
useQueryStates({
|
||||
page: parseAsIndex.withDefault(0), // parseAsIndex adds +1 to URL, so 0-based internally, 1-based in URL
|
||||
pageSize: parseAsInteger.withDefault(10),
|
||||
search: parseAsString.withDefault(''),
|
||||
sortId: parseAsString.withDefault('createdAt'),
|
||||
sortDesc: parseAsInteger.withDefault(1),
|
||||
});
|
||||
|
||||
const sorting: SortingState = useMemo(
|
||||
() => [{ id: sortId, desc: Boolean(sortDesc) }],
|
||||
[sortId, sortDesc]
|
||||
);
|
||||
|
||||
// page is already 0-based internally thanks to parseAsIndex
|
||||
const { data, isLoading } = useUsers(page, pageSize, search, sorting);
|
||||
|
||||
return (
|
||||
<UsersTable
|
||||
data={data?.items || []}
|
||||
total={data?.total || 0}
|
||||
pageIndex={pageIndex}
|
||||
pageIndex={page}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
sorting={sorting}
|
||||
loading={isLoading}
|
||||
onSearch={setSearch}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSortingChange={setSorting}
|
||||
onSearch={(newSearch) => setQueryStates({ search: newSearch, page: 0 })}
|
||||
onPageChange={(newPageIndex) => setQueryStates({ page: newPageIndex })}
|
||||
onPageSizeChange={(newPageSize) =>
|
||||
setQueryStates({ pageSize: newPageSize, page: 0 })
|
||||
}
|
||||
onSortingChange={(newSorting) => {
|
||||
if (newSorting.length > 0) {
|
||||
setQueryStates({
|
||||
sortId: newSorting[0].id,
|
||||
sortDesc: newSorting[0].desc ? 1 : 0,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -59,6 +59,7 @@ import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Label } from '../ui/label';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
@ -116,12 +117,27 @@ function DataTableColumnHeader<TData, TValue>({
|
||||
);
|
||||
}
|
||||
|
||||
function TableRowSkeleton({ columns }: { columns: number }) {
|
||||
return (
|
||||
<TableRow>
|
||||
{Array.from({ length: columns }).map((_, index) => (
|
||||
<TableCell key={index} className="py-4">
|
||||
<div className="flex items-center gap-2 pl-3">
|
||||
<Skeleton className="h-6 w-full max-w-32" />
|
||||
</div>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
interface UsersTableProps {
|
||||
data: User[];
|
||||
total: number;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
search: string;
|
||||
sorting?: SortingState;
|
||||
loading?: boolean;
|
||||
onSearch: (search: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
@ -138,6 +154,7 @@ export function UsersTable({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
search,
|
||||
sorting = [{ id: 'createdAt', desc: true }],
|
||||
loading,
|
||||
onSearch,
|
||||
onPageChange,
|
||||
@ -146,9 +163,6 @@ export function UsersTable({
|
||||
}: UsersTableProps) {
|
||||
const t = useTranslations('Dashboard.admin.users');
|
||||
const tTable = useTranslations('Common.table');
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'createdAt', desc: true },
|
||||
]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
@ -351,7 +365,6 @@ export function UsersTable({
|
||||
},
|
||||
onSortingChange: (updater) => {
|
||||
const next = typeof updater === 'function' ? updater(sorting) : updater;
|
||||
setSorting(next);
|
||||
onSortingChange?.(next);
|
||||
},
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
@ -444,7 +457,12 @@ export function UsersTable({
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
{loading ? (
|
||||
// Show skeleton rows while loading
|
||||
Array.from({ length: pageSize }).map((_, index) => (
|
||||
<TableRowSkeleton key={index} columns={columns.length} />
|
||||
))
|
||||
) : table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
@ -466,7 +484,7 @@ export function UsersTable({
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{loading ? tTable('loading') : tTable('noResults')}
|
||||
{tTable('noResults')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
@ -76,6 +76,7 @@ import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Label } from '../../ui/label';
|
||||
import { Skeleton } from '../../ui/skeleton';
|
||||
|
||||
// Define the credit transaction interface
|
||||
export interface CreditTransaction {
|
||||
@ -152,12 +153,27 @@ function DataTableColumnHeader<TData, TValue>({
|
||||
);
|
||||
}
|
||||
|
||||
function TableRowSkeleton({ columns }: { columns: number }) {
|
||||
return (
|
||||
<TableRow>
|
||||
{Array.from({ length: columns }).map((_, index) => (
|
||||
<TableCell key={index} className="py-4">
|
||||
<div className="flex items-center gap-2 pl-3">
|
||||
<Skeleton className="h-6 w-full max-w-32" />
|
||||
</div>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreditTransactionsTableProps {
|
||||
data: CreditTransaction[];
|
||||
total: number;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
search: string;
|
||||
sorting?: SortingState;
|
||||
loading?: boolean;
|
||||
onSearch: (search: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
@ -171,6 +187,7 @@ export function CreditTransactionsTable({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
search,
|
||||
sorting = [{ id: 'createdAt', desc: true }],
|
||||
loading,
|
||||
onSearch,
|
||||
onPageChange,
|
||||
@ -179,9 +196,6 @@ export function CreditTransactionsTable({
|
||||
}: CreditTransactionsTableProps) {
|
||||
const t = useTranslations('Dashboard.settings.credits.transactions');
|
||||
const tTable = useTranslations('Common.table');
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'createdAt', desc: true },
|
||||
]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
@ -449,7 +463,6 @@ export function CreditTransactionsTable({
|
||||
},
|
||||
onSortingChange: (updater) => {
|
||||
const next = typeof updater === 'function' ? updater(sorting) : updater;
|
||||
setSorting(next);
|
||||
onSortingChange?.(next);
|
||||
},
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
@ -538,7 +551,12 @@ export function CreditTransactionsTable({
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
{loading ? (
|
||||
// Show skeleton rows while loading
|
||||
Array.from({ length: pageSize }).map((_, index) => (
|
||||
<TableRowSkeleton key={index} columns={columns.length} />
|
||||
))
|
||||
) : table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
@ -560,7 +578,7 @@ export function CreditTransactionsTable({
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{loading ? tTable('loading') : tTable('noResults')}
|
||||
{tTable('noResults')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
@ -4,22 +4,36 @@ import { CreditTransactionsTable } from '@/components/settings/credits/credit-tr
|
||||
import { useCreditTransactions } from '@/hooks/use-credits';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
parseAsIndex,
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
useQueryStates,
|
||||
} from 'nuqs';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* Credit transactions component
|
||||
*/
|
||||
export function CreditTransactions() {
|
||||
const t = useTranslations('Dashboard.settings.credits.transactions');
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'createdAt', desc: true },
|
||||
]);
|
||||
|
||||
const [{ page, pageSize, search, sortId, sortDesc }, setQueryStates] =
|
||||
useQueryStates({
|
||||
page: parseAsIndex.withDefault(0), // 0-based internally, 1-based in URL
|
||||
pageSize: parseAsInteger.withDefault(10),
|
||||
search: parseAsString.withDefault(''),
|
||||
sortId: parseAsString.withDefault('createdAt'),
|
||||
sortDesc: parseAsInteger.withDefault(1),
|
||||
});
|
||||
|
||||
const sorting: SortingState = useMemo(
|
||||
() => [{ id: sortId, desc: Boolean(sortDesc) }],
|
||||
[sortId, sortDesc]
|
||||
);
|
||||
|
||||
const { data, isLoading } = useCreditTransactions(
|
||||
pageIndex,
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
sorting
|
||||
@ -29,14 +43,24 @@ export function CreditTransactions() {
|
||||
<CreditTransactionsTable
|
||||
data={data?.items || []}
|
||||
total={data?.total || 0}
|
||||
pageIndex={pageIndex}
|
||||
pageIndex={page}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
sorting={sorting}
|
||||
loading={isLoading}
|
||||
onSearch={setSearch}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSortingChange={setSorting}
|
||||
onSearch={(newSearch) => setQueryStates({ search: newSearch, page: 0 })}
|
||||
onPageChange={(newPageIndex) => setQueryStates({ page: newPageIndex })}
|
||||
onPageSizeChange={(newPageSize) =>
|
||||
setQueryStates({ pageSize: newPageSize, page: 0 })
|
||||
}
|
||||
onSortingChange={(newSorting) => {
|
||||
if (newSorting.length > 0) {
|
||||
setQueryStates({
|
||||
sortId: newSorting[0].id,
|
||||
sortDesc: newSorting[0].desc ? 1 : 0,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import { CreditTransactions } from '@/components/settings/credits/credit-transac
|
||||
import CreditsBalanceCard from '@/components/settings/credits/credits-balance-card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
|
||||
/**
|
||||
* Credits page client, show credit balance and transactions
|
||||
@ -12,9 +13,24 @@ import { useTranslations } from 'next-intl';
|
||||
export default function CreditsPageClient() {
|
||||
const t = useTranslations('Dashboard.settings.credits');
|
||||
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
'tab',
|
||||
parseAsStringLiteral(['balance', 'transactions']).withDefault('balance')
|
||||
);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (value === 'balance' || value === 'transactions') {
|
||||
setActiveTab(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<Tabs defaultValue="balance" className="w-full">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="">
|
||||
<TabsTrigger value="balance">{t('tabs.balance')}</TabsTrigger>
|
||||
<TabsTrigger value="transactions">
|
||||
|
Loading…
Reference in New Issue
Block a user