diff --git a/package.json b/package.json index 94ba18c..1b5ec1f 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "next-safe-action": "^7.10.4", "next-themes": "^0.4.4", "pg": "^8.16.0", + "nuqs": "^2.5.1", "postgres": "^3.4.5", "radix-ui": "^1.4.2", "react": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8e4447..581845f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) pg: specifier: ^8.16.0 version: 8.16.0(pg-native@3.4.5) @@ -516,24 +519,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@ast-grep/napi-linux-arm64-musl@0.35.0': resolution: {integrity: sha512-1EcvHPwyWpCL/96LuItBYGfeI5FaMTRvL+dHbO/hL5q1npqbb5qn+ppJwtNOjTPz8tayvgggxVk9T4C2O7taYA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@ast-grep/napi-linux-x64-gnu@0.35.0': resolution: {integrity: sha512-FDzNdlqmQnsiWXhnLxusw5AOfEcEM+5xtmrnAf3SBRFr86JyWD9qsynnFYC2pnP9hlMfifNH2TTmMpyGJW49Xw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@ast-grep/napi-linux-x64-musl@0.35.0': resolution: {integrity: sha512-wlmndjfBafT8u5p4DBnoRQyoCSGNuVSz7rT3TqhvlHcPzUouRWMn95epU9B1LNLyjXvr9xHeRjSktyCN28w57Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@ast-grep/napi-win32-arm64-msvc@0.35.0': resolution: {integrity: sha512-gkhJeYc4rrZLX2icLxalPikTLMR57DuIYLwLr9g+StHYXIsGHrbfrE6Nnbdd8Izfs34ArFCrcwdaMrGlvOPSeg==} @@ -1040,24 +1047,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@1.9.4': resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@1.9.4': resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@1.9.4': resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@1.9.4': resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} @@ -2189,67 +2200,79 @@ packages: resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} @@ -2353,48 +2376,56 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-gnu@15.2.1': resolution: {integrity: sha512-gXDX5lIboebbjhiMT6kFgu4svQyjoSed6dHyjx5uZsjlvTwOAnZpn13w9XDaIMFFHw7K8CpBK7HfDKw0VZvUXQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.1.2': resolution: {integrity: sha512-9CF1Pnivij7+M3G74lxr+e9h6o2YNIe7QtExWq1KUK4hsOLTBv6FJikEwCaC3NeYTflzrm69E5UfwEAbV2U9/g==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-arm64-musl@15.2.1': resolution: {integrity: sha512-3v0pF/adKZkBWfUffmB/ROa+QcNTrnmYG4/SS+r52HPwAK479XcWoES2I+7F7lcbqc7mTeVXrIvb4h6rR/iDKg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.1.2': resolution: {integrity: sha512-tINV7WmcTUf4oM/eN3Yuu/f8jQ5C6AkueZPKeALs/qfdfX57eNv4Ij7rt0SA6iZ8+fMobVfcFVv664Op0caCCg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-gnu@15.2.1': resolution: {integrity: sha512-RbsVq2iB6KFJRZ2cHrU67jLVLKeuOIhnQB05ygu5fCNgg8oTewxweJE8XlLV+Ii6Y6u4EHwETdUiRNXIAfpBww==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.1.2': resolution: {integrity: sha512-jf2IseC4WRsGkzeUw/cK3wci9pxR53GlLAt30+y+B+2qAQxMw6WAC3QrANIKxkcoPU3JFh/10uFfmoMDF9JXKg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-linux-x64-musl@15.2.1': resolution: {integrity: sha512-QHsMLAyAIu6/fWjHmkN/F78EFPKmhQlyX5C8pRIS2RwVA7z+t9cTb0IaYWC3EHLOTjsU7MNQW+n2xGXr11QPpg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.1.2': resolution: {integrity: sha512-wvg7MlfnaociP7k8lxLX4s2iBJm4BrNiNFhVUY+Yur5yhAJHfkS8qPPeDEUH8rQiY0PX3u/P7Q/wcg6Mv6GSAA==} @@ -2539,31 +2570,37 @@ packages: resolution: {integrity: sha512-BhEzNLjn4HjP8+Q18D3/jeIDBxW7OgoJYIjw2CaaysnYneoTlij8hPTKxHfyqq4IGM3fFs9TLR/k338M3zkQ7g==} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-arm64-musl@11.2.0': resolution: {integrity: sha512-yxbMYUgRmN2V8x8XoxmD/Qq6aG7YIW3ToMDILfmcfeeRRVieEJ3DOWBT0JSE+YgrOy79OyFDH/1lO8VnqLmDQQ==} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-resolver/binding-linux-riscv64-gnu@11.2.0': resolution: {integrity: sha512-QG1UfgC2N2qhW1tOnDCgB/26vn1RCshR5sYPhMeaxO1gMQ3kEKbZ3QyBXxrG1IX5qsXYj5hPDJLDYNYUjRcOpg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-s390x-gnu@11.2.0': resolution: {integrity: sha512-uqTDsQdi6mrkSV1gvwbuT8jf/WFl6qVDVjNlx7IPSaAByrNiJfPrhTmH8b+Do58Dylz7QIRZgxQ8CHIZSyBUdg==} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-gnu@11.2.0': resolution: {integrity: sha512-GZdHXhJ7p6GaQg9MjRqLebwBf8BLvGIagccI6z5yMj4fV3LU4QuDfwSEERG+R6oQ/Su9672MBqWwncvKcKT68w==} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-musl@11.2.0': resolution: {integrity: sha512-YBAC3GOicYznReG2twE7oFPSeK9Z1f507z1EYWKg6HpGYRYRlJyszViu7PrhMT85r/MumDTs429zm+CNqpFWOA==} cpu: [x64] os: [linux] + libc: [musl] '@oxc-resolver/binding-wasm32-wasi@11.2.0': resolution: {integrity: sha512-+qlIg45CPVPy+Jn3vqU1zkxA/AAv6e/2Ax/ImX8usZa8Tr2JmQn/93bmSOOOnr9fXRV9d0n4JyqYzSWxWPYDEw==} @@ -4542,24 +4579,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.0.14': resolution: {integrity: sha512-gVkJdnR/L6iIcGYXx64HGJRmlme2FGr/aZH0W6u4A3RgPMAb+6ELRLi+UBiH83RXBm9vwCfkIC/q8T51h8vUJQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.0.14': resolution: {integrity: sha512-EE+EQ+c6tTpzsg+LGO1uuusjXxYx0Q00JE5ubcIGfsogSKth8n8i2BcS2wYTQe4jXGs+BQs35l78BIPzgwLddw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.0.14': resolution: {integrity: sha512-KCCOzo+L6XPT0oUp2Jwh233ETRQ/F6cwUnMnR0FvMUCbkDAzHbcyOgpfuAtRa5HD0WbTbH4pVD+S0pn1EhNfbw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-win32-arm64-msvc@4.0.14': resolution: {integrity: sha512-AHObFiFL9lNYcm3tZSPqa/cHGpM5wOrNmM2uOMoKppp+0Hom5uuyRh0QkOp7jftsHZdrZUpmoz0Mp6vhh2XtUg==} @@ -6235,24 +6276,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.29.2: resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.29.2: resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.29.2: resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.29.2: resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==} @@ -6752,6 +6797,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'} @@ -15410,6 +15476,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: {} diff --git a/src/actions/get-credit-transactions.ts b/src/actions/get-credit-transactions.ts index ec40ae5..2e5b052 100644 --- a/src/actions/get-credit-transactions.ts +++ b/src/actions/get-credit-transactions.ts @@ -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); diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 0932f26..611e83e 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -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,15 +58,17 @@ export default async function LocaleLayout({ fontBricolageGrotesque.variable )} > - - - {children} + + + + {children} - - - - - + + + + + + ); diff --git a/src/components/admin/users-page.tsx b/src/components/admin/users-page.tsx index dfd94b0..39f4a7f 100644 --- a/src/components/admin/users-page.tsx +++ b/src/components/admin/users-page.tsx @@ -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([ - { 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 ( 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, + }); + } + }} /> ); } diff --git a/src/components/admin/users-table.tsx b/src/components/admin/users-table.tsx index f9f54f6..2485f49 100644 --- a/src/components/admin/users-table.tsx +++ b/src/components/admin/users-table.tsx @@ -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 extends React.HTMLAttributes { @@ -116,12 +117,27 @@ function DataTableColumnHeader({ ); } +function TableRowSkeleton({ columns }: { columns: number }) { + return ( + + {Array.from({ length: columns }).map((_, index) => ( + +
+ +
+
+ ))} +
+ ); +} + 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([ - { id: 'createdAt', desc: true }, - ]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); @@ -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({ ))} - {table.getRowModel().rows?.length ? ( + {loading ? ( + // Show skeleton rows while loading + Array.from({ length: pageSize }).map((_, index) => ( + + )) + ) : table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - {loading ? tTable('loading') : tTable('noResults')} + {tTable('noResults')} )} diff --git a/src/components/settings/credits/credit-transactions-table.tsx b/src/components/settings/credits/credit-transactions-table.tsx index 0e1fd51..39fedfa 100644 --- a/src/components/settings/credits/credit-transactions-table.tsx +++ b/src/components/settings/credits/credit-transactions-table.tsx @@ -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({ ); } +function TableRowSkeleton({ columns }: { columns: number }) { + return ( + + {Array.from({ length: columns }).map((_, index) => ( + +
+ +
+
+ ))} +
+ ); +} + 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([ - { id: 'createdAt', desc: true }, - ]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); @@ -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({ ))} - {table.getRowModel().rows?.length ? ( + {loading ? ( + // Show skeleton rows while loading + Array.from({ length: pageSize }).map((_, index) => ( + + )) + ) : table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - {loading ? tTable('loading') : tTable('noResults')} + {tTable('noResults')} )} diff --git a/src/components/settings/credits/credit-transactions.tsx b/src/components/settings/credits/credit-transactions.tsx index 608f0c9..d9b0351 100644 --- a/src/components/settings/credits/credit-transactions.tsx +++ b/src/components/settings/credits/credit-transactions.tsx @@ -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([ - { 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() { 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, + }); + } + }} /> ); } diff --git a/src/components/settings/credits/credits-page-client.tsx b/src/components/settings/credits/credits-page-client.tsx index dc5172f..34cedb8 100644 --- a/src/components/settings/credits/credits-page-client.tsx +++ b/src/components/settings/credits/credits-page-client.tsx @@ -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 (
- + {t('tabs.balance')}