feat: add server-side table sorting

This commit is contained in:
2026-06-17 10:59:50 +05:30
parent db11a512bf
commit bd0782a55f
28 changed files with 209 additions and 29 deletions

View File

@@ -81,6 +81,7 @@ export function useClientColumns({
columns.push({ columns.push({
id: 'actions', id: 'actions',
header: () => <div className="text-right">Actions</div>, header: () => <div className="text-right">Actions</div>,
enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const client = row.original; const client = row.original;
return ( return (

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import type { Client } from '@/types'; import type { Client } from '@/types';
@@ -14,8 +14,10 @@ interface ClientTableProps {
skip: number; skip: number;
limit: number; limit: number;
total: number; total: number;
sorting: SortingState;
onPageChange: (skip: number) => void; onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void; onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void;
} }
export function ClientTable({ export function ClientTable({
@@ -26,8 +28,10 @@ export function ClientTable({
skip, skip,
limit, limit,
total, total,
sorting,
onPageChange, onPageChange,
onLimitChange, onLimitChange,
onSortingChange,
}: ClientTableProps) { }: ClientTableProps) {
return ( return (
<DataTable <DataTable
@@ -44,6 +48,8 @@ export function ClientTable({
onPageChange, onPageChange,
onLimitChange, onLimitChange,
}} }}
sorting={sorting}
onSortingChange={onSortingChange}
/> />
); );
} }

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import type { SortingState } from '@tanstack/react-table';
import { useDebounce } from '@/hooks/useDebounce'; import { useDebounce } from '@/hooks/useDebounce';
@@ -9,6 +10,7 @@ export type ClientStatusFilter = 'all' | 'active' | 'inactive';
export function useClientFilters() { export function useClientFilters() {
const [skip, setSkip] = useState(0); const [skip, setSkip] = useState(0);
const [limit, setLimitValue] = useState(10); const [limit, setLimitValue] = useState(10);
const [sorting, setSortingValue] = useState<SortingState>([]);
const [searchTerm, setSearchTermValue] = useState(''); const [searchTerm, setSearchTermValue] = useState('');
const [statusFilter, setStatusFilterValue] = useState<ClientStatusFilter>('all'); const [statusFilter, setStatusFilterValue] = useState<ClientStatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400); const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
@@ -28,11 +30,18 @@ export function useClientFilters() {
setSkip(0); setSkip(0);
}, []); }, []);
const setSorting = useCallback((value: SortingState) => {
setSortingValue(value);
setSkip(0);
}, []);
return { return {
skip, skip,
setSkip, setSkip,
limit, limit,
setLimit, setLimit,
sorting,
setSorting,
searchTerm, searchTerm,
debouncedSearchTerm, debouncedSearchTerm,
setSearchTerm, setSearchTerm,

View File

@@ -2,7 +2,9 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import type { SortingState } from '@tanstack/react-table';
import { getServerSortParams } from '@/components/data-table/sorting';
import { clientService } from '@/services/api'; import { clientService } from '@/services/api';
import type { ClientListParams } from '@/types'; import type { ClientListParams } from '@/types';
import { clientKeys } from '../queries/clientKeys'; import { clientKeys } from '../queries/clientKeys';
@@ -13,6 +15,7 @@ interface UseClientsQueryParams {
limit: number; limit: number;
searchTerm: string; searchTerm: string;
statusFilter: ClientStatusFilter; statusFilter: ClientStatusFilter;
sorting: SortingState;
} }
function buildClientListParams({ function buildClientListParams({
@@ -20,22 +23,22 @@ function buildClientListParams({
limit, limit,
searchTerm, searchTerm,
statusFilter, statusFilter,
sorting,
}: UseClientsQueryParams): ClientListParams { }: UseClientsQueryParams): ClientListParams {
return { return {
skip, skip,
limit, limit,
search_term: searchTerm || undefined, search_term: searchTerm || undefined,
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active', is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
sort_by: 'created_at', ...getServerSortParams(sorting),
sort_order: 'desc',
}; };
} }
export function useClientsQuery(params: UseClientsQueryParams) { export function useClientsQuery(params: UseClientsQueryParams) {
const { skip, limit, searchTerm, statusFilter } = params; const { skip, limit, searchTerm, statusFilter, sorting } = params;
const listParams = useMemo( const listParams = useMemo(
() => buildClientListParams({ skip, limit, searchTerm, statusFilter }), () => buildClientListParams({ skip, limit, searchTerm, statusFilter, sorting }),
[limit, searchTerm, skip, statusFilter], [limit, searchTerm, skip, sorting, statusFilter],
); );
return useQuery({ return useQuery({

View File

@@ -26,6 +26,8 @@ export default function ClientsPage() {
setSkip, setSkip,
limit, limit,
setLimit, setLimit,
sorting,
setSorting,
searchTerm, searchTerm,
debouncedSearchTerm, debouncedSearchTerm,
setSearchTerm, setSearchTerm,
@@ -38,6 +40,7 @@ export default function ClientsPage() {
limit, limit,
searchTerm: debouncedSearchTerm, searchTerm: debouncedSearchTerm,
statusFilter, statusFilter,
sorting,
}); });
const clientForm = useClientForm({ const clientForm = useClientForm({
onSaved: () => setIsSheetOpen(false), onSaved: () => setIsSheetOpen(false),
@@ -112,8 +115,10 @@ export default function ClientsPage() {
skip={skip} skip={skip}
limit={limit} limit={limit}
total={total} total={total}
sorting={sorting}
onPageChange={setSkip} onPageChange={setSkip}
onLimitChange={setLimit} onLimitChange={setLimit}
onSortingChange={setSorting}
/> />
<PoweredBy /> <PoweredBy />

View File

@@ -65,6 +65,7 @@ export function usePlanColumns({
{ {
id: 'limits', id: 'limits',
header: 'Limits', header: 'Limits',
enableSorting: false,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
<p>{row.original.max_projects} projects</p> <p>{row.original.max_projects} projects</p>
@@ -96,6 +97,7 @@ export function usePlanColumns({
columns.push({ columns.push({
id: 'actions', id: 'actions',
header: () => <div className="text-right">Actions</div>, header: () => <div className="text-right">Actions</div>,
enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const plan = row.original; const plan = row.original;
return ( return (

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import type { Plan } from '@/types'; import type { Plan } from '@/types';
@@ -14,8 +14,10 @@ interface PlanTableProps {
skip: number; skip: number;
limit: number; limit: number;
total: number; total: number;
sorting: SortingState;
onPageChange: (skip: number) => void; onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void; onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void;
} }
export function PlanTable({ export function PlanTable({
@@ -26,8 +28,10 @@ export function PlanTable({
skip, skip,
limit, limit,
total, total,
sorting,
onPageChange, onPageChange,
onLimitChange, onLimitChange,
onSortingChange,
}: PlanTableProps) { }: PlanTableProps) {
return ( return (
<DataTable <DataTable
@@ -44,6 +48,8 @@ export function PlanTable({
onPageChange, onPageChange,
onLimitChange, onLimitChange,
}} }}
sorting={sorting}
onSortingChange={onSortingChange}
/> />
); );
} }

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import type { SortingState } from '@tanstack/react-table';
import { useDebounce } from '@/hooks/useDebounce'; import { useDebounce } from '@/hooks/useDebounce';
export type PlanStatusFilter = 'all' | 'active' | 'inactive'; export type PlanStatusFilter = 'all' | 'active' | 'inactive';
@@ -8,6 +9,7 @@ export type PlanStatusFilter = 'all' | 'active' | 'inactive';
export function usePlanFilters() { export function usePlanFilters() {
const [skip, setSkip] = useState(0); const [skip, setSkip] = useState(0);
const [limit, setLimitValue] = useState(10); const [limit, setLimitValue] = useState(10);
const [sorting, setSortingValue] = useState<SortingState>([]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<PlanStatusFilter>('all'); const [statusFilter, setStatusFilter] = useState<PlanStatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm, 400); const debouncedSearchTerm = useDebounce(searchTerm, 400);
@@ -27,11 +29,18 @@ export function usePlanFilters() {
setSkip(0); setSkip(0);
}; };
const setSorting = (value: SortingState) => {
setSortingValue(value);
setSkip(0);
};
return { return {
skip, skip,
setSkip, setSkip,
limit, limit,
setLimit, setLimit,
sorting,
setSorting,
searchTerm, searchTerm,
debouncedSearchTerm, debouncedSearchTerm,
setSearchTerm: updateSearchTerm, setSearchTerm: updateSearchTerm,

View File

@@ -2,7 +2,9 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import type { SortingState } from '@tanstack/react-table';
import { getServerSortParams } from '@/components/data-table/sorting';
import { mapPermissionTree } from '@/components/permission-tree'; import { mapPermissionTree } from '@/components/permission-tree';
import { permissionService, planService } from '@/services/api'; import { permissionService, planService } from '@/services/api';
import type { PlanListParams } from '@/types'; import type { PlanListParams } from '@/types';
@@ -15,6 +17,7 @@ interface UsePlansQueryParams {
limit: number; limit: number;
searchTerm: string; searchTerm: string;
statusFilter: PlanStatusFilter; statusFilter: PlanStatusFilter;
sorting: SortingState;
} }
function buildPlanListParams({ function buildPlanListParams({
@@ -22,14 +25,14 @@ function buildPlanListParams({
limit, limit,
searchTerm, searchTerm,
statusFilter, statusFilter,
sorting,
}: UsePlansQueryParams): PlanListParams { }: UsePlansQueryParams): PlanListParams {
return { return {
skip, skip,
limit, limit,
search_term: searchTerm || undefined, search_term: searchTerm || undefined,
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active', is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
sort_by: 'created_at', ...getServerSortParams(sorting),
sort_order: 'desc',
}; };
} }

View File

@@ -28,6 +28,8 @@ export default function PlansPage() {
setSkip, setSkip,
limit, limit,
setLimit, setLimit,
sorting,
setSorting,
searchTerm, searchTerm,
debouncedSearchTerm, debouncedSearchTerm,
setSearchTerm, setSearchTerm,
@@ -40,6 +42,7 @@ export default function PlansPage() {
limit, limit,
searchTerm: debouncedSearchTerm, searchTerm: debouncedSearchTerm,
statusFilter, statusFilter,
sorting,
}); });
const permissionsQuery = useOrganizationPermissionTreeQuery(isSheetOpen); const permissionsQuery = useOrganizationPermissionTreeQuery(isSheetOpen);
@@ -166,8 +169,10 @@ export default function PlansPage() {
skip={skip} skip={skip}
limit={limit} limit={limit}
total={total} total={total}
sorting={sorting}
onPageChange={setSkip} onPageChange={setSkip}
onLimitChange={setLimit} onLimitChange={setLimit}
onSortingChange={setSorting}
/> />
<PoweredBy /> <PoweredBy />

View File

@@ -66,6 +66,7 @@ export function useRoleColumns({
columns.push({ columns.push({
id: 'actions', id: 'actions',
header: () => <div className="text-right">Actions</div>, header: () => <div className="text-right">Actions</div>,
enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const role = row.original; const role = row.original;
return ( return (

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef, SortingState } from '@tanstack/react-table';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
@@ -14,8 +14,10 @@ interface RoleTableProps {
skip: number; skip: number;
limit: number; limit: number;
total: number; total: number;
sorting: SortingState;
onPageChange: (skip: number) => void; onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void; onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void;
} }
export function RoleTable({ export function RoleTable({
@@ -26,8 +28,10 @@ export function RoleTable({
skip, skip,
limit, limit,
total, total,
sorting,
onPageChange, onPageChange,
onLimitChange, onLimitChange,
onSortingChange,
}: RoleTableProps) { }: RoleTableProps) {
return ( return (
<DataTable <DataTable
@@ -44,6 +48,8 @@ export function RoleTable({
onPageChange, onPageChange,
onLimitChange, onLimitChange,
}} }}
sorting={sorting}
onSortingChange={onSortingChange}
/> />
); );
} }

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { SortingState } from '@tanstack/react-table';
export type StatusFilter = 'all' | 'active' | 'inactive'; export type StatusFilter = 'all' | 'active' | 'inactive';
@@ -9,6 +10,7 @@ const SEARCH_DEBOUNCE_MS = 400;
export function useRoleFilters() { export function useRoleFilters() {
const [skip, setSkip] = useState(0); const [skip, setSkip] = useState(0);
const [limit, setLimitValue] = useState(10); const [limit, setLimitValue] = useState(10);
const [sorting, setSortingValue] = useState<SortingState>([]);
const [searchTerm, setSearchTermValue] = useState(''); const [searchTerm, setSearchTermValue] = useState('');
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
const [statusFilter, setStatusFilterValue] = useState<StatusFilter>('all'); const [statusFilter, setStatusFilterValue] = useState<StatusFilter>('all');
@@ -36,11 +38,18 @@ export function useRoleFilters() {
setSkip(0); setSkip(0);
}; };
const setSorting = (value: SortingState) => {
setSortingValue(value);
setSkip(0);
};
return { return {
skip, skip,
setSkip, setSkip,
limit, limit,
setLimit, setLimit,
sorting,
setSorting,
searchTerm, searchTerm,
debouncedSearchTerm, debouncedSearchTerm,
setSearchTerm, setSearchTerm,

View File

@@ -1,9 +1,11 @@
'use client'; 'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { SortingState } from '@tanstack/react-table';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getServerSortParams } from '@/components/data-table/sorting';
import { mapPermissionTree } from '@/components/permission-tree'; import { mapPermissionTree } from '@/components/permission-tree';
import { permissionService, roleService } from '@/services/api'; import { permissionService, roleService } from '@/services/api';
import type { Role, RoleListParams } from '@/types'; import type { Role, RoleListParams } from '@/types';
@@ -16,6 +18,7 @@ interface UseRolesQueryParams {
limit: number; limit: number;
searchTerm: string; searchTerm: string;
statusFilter: StatusFilter; statusFilter: StatusFilter;
sorting: SortingState;
} }
function buildRoleListParams({ function buildRoleListParams({
@@ -23,22 +26,22 @@ function buildRoleListParams({
limit, limit,
searchTerm, searchTerm,
statusFilter, statusFilter,
sorting,
}: UseRolesQueryParams): RoleListParams { }: UseRolesQueryParams): RoleListParams {
return { return {
skip, skip,
limit, limit,
search_term: searchTerm || undefined, search_term: searchTerm || undefined,
effective_status: statusFilter === 'all' ? undefined : statusFilter === 'active', effective_status: statusFilter === 'all' ? undefined : statusFilter === 'active',
sort_by: 'created_at', ...getServerSortParams(sorting),
sort_order: 'desc',
}; };
} }
export function useRolesQuery(params: UseRolesQueryParams) { export function useRolesQuery(params: UseRolesQueryParams) {
const { skip, limit, searchTerm, statusFilter } = params; const { skip, limit, searchTerm, statusFilter, sorting } = params;
const listParams = useMemo( const listParams = useMemo(
() => buildRoleListParams({ skip, limit, searchTerm, statusFilter }), () => buildRoleListParams({ skip, limit, searchTerm, statusFilter, sorting }),
[limit, searchTerm, skip, statusFilter], [limit, searchTerm, skip, sorting, statusFilter],
); );
return useQuery({ return useQuery({

View File

@@ -31,6 +31,8 @@ export default function RolesPage() {
setSkip, setSkip,
limit, limit,
setLimit, setLimit,
sorting,
setSorting,
searchTerm, searchTerm,
debouncedSearchTerm, debouncedSearchTerm,
setSearchTerm, setSearchTerm,
@@ -43,6 +45,7 @@ export default function RolesPage() {
limit, limit,
searchTerm: debouncedSearchTerm, searchTerm: debouncedSearchTerm,
statusFilter, statusFilter,
sorting,
}); });
const permissionsQuery = usePermissionsTreeQuery(isSheetOpen); const permissionsQuery = usePermissionsTreeQuery(isSheetOpen);
const roleForm = useRoleForm({ const roleForm = useRoleForm({
@@ -125,8 +128,10 @@ export default function RolesPage() {
skip={skip} skip={skip}
limit={limit} limit={limit}
total={total} total={total}
sorting={sorting}
onPageChange={setSkip} onPageChange={setSkip}
onLimitChange={setLimit} onLimitChange={setLimit}
onSortingChange={setSorting}
/> />
<PoweredBy /> <PoweredBy />

View File

@@ -82,6 +82,7 @@ export function useTenantColumns({
columns.push({ columns.push({
id: 'actions', id: 'actions',
header: () => <div className="text-right">Actions</div>, header: () => <div className="text-right">Actions</div>,
enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const tenant = row.original; const tenant = row.original;
return ( return (

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import type { Tenant } from '@/types'; import type { Tenant } from '@/types';
@@ -14,8 +14,10 @@ interface TenantTableProps {
skip: number; skip: number;
limit: number; limit: number;
total: number; total: number;
sorting: SortingState;
onPageChange: (skip: number) => void; onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void; onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void;
} }
export function TenantTable({ export function TenantTable({
@@ -26,8 +28,10 @@ export function TenantTable({
skip, skip,
limit, limit,
total, total,
sorting,
onPageChange, onPageChange,
onLimitChange, onLimitChange,
onSortingChange,
}: TenantTableProps) { }: TenantTableProps) {
return ( return (
<DataTable <DataTable
@@ -44,6 +48,8 @@ export function TenantTable({
onPageChange, onPageChange,
onLimitChange, onLimitChange,
}} }}
sorting={sorting}
onSortingChange={onSortingChange}
/> />
); );
} }

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import type { SortingState } from '@tanstack/react-table';
import { useDebounce } from '@/hooks/useDebounce'; import { useDebounce } from '@/hooks/useDebounce';
@@ -9,6 +10,7 @@ export type TenantStatusFilter = 'all' | 'active' | 'inactive';
export function useTenantFilters() { export function useTenantFilters() {
const [skip, setSkip] = useState(0); const [skip, setSkip] = useState(0);
const [limit, setLimitValue] = useState(10); const [limit, setLimitValue] = useState(10);
const [sorting, setSortingValue] = useState<SortingState>([]);
const [searchTerm, setSearchTermValue] = useState(''); const [searchTerm, setSearchTermValue] = useState('');
const [statusFilter, setStatusFilterValue] = useState<TenantStatusFilter>('all'); const [statusFilter, setStatusFilterValue] = useState<TenantStatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400); const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
@@ -28,11 +30,18 @@ export function useTenantFilters() {
setSkip(0); setSkip(0);
}, []); }, []);
const setSorting = useCallback((value: SortingState) => {
setSortingValue(value);
setSkip(0);
}, []);
return { return {
skip, skip,
setSkip, setSkip,
limit, limit,
setLimit, setLimit,
sorting,
setSorting,
searchTerm, searchTerm,
debouncedSearchTerm, debouncedSearchTerm,
setSearchTerm, setSearchTerm,

View File

@@ -2,7 +2,9 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import type { SortingState } from '@tanstack/react-table';
import { getServerSortParams } from '@/components/data-table/sorting';
import { clientService, planService, tenantService } from '@/services/api'; import { clientService, planService, tenantService } from '@/services/api';
import type { TenantListParams } from '@/types'; import type { TenantListParams } from '@/types';
@@ -14,6 +16,7 @@ interface UseTenantsQueryParams {
limit: number; limit: number;
searchTerm: string; searchTerm: string;
statusFilter: TenantStatusFilter; statusFilter: TenantStatusFilter;
sorting: SortingState;
} }
const clientLookupParams = { const clientLookupParams = {
@@ -37,14 +40,14 @@ function buildTenantListParams({
limit, limit,
searchTerm, searchTerm,
statusFilter, statusFilter,
sorting,
}: UseTenantsQueryParams): TenantListParams { }: UseTenantsQueryParams): TenantListParams {
return { return {
skip, skip,
limit, limit,
search_term: searchTerm || undefined, search_term: searchTerm || undefined,
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active', is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
sort_by: 'created_at', ...getServerSortParams(sorting),
sort_order: 'desc',
}; };
} }

View File

@@ -31,6 +31,8 @@ export default function TenantsPage() {
setSkip, setSkip,
limit, limit,
setLimit, setLimit,
sorting,
setSorting,
searchTerm, searchTerm,
debouncedSearchTerm, debouncedSearchTerm,
setSearchTerm, setSearchTerm,
@@ -43,6 +45,7 @@ export default function TenantsPage() {
limit, limit,
searchTerm: debouncedSearchTerm, searchTerm: debouncedSearchTerm,
statusFilter, statusFilter,
sorting,
}); });
const clientsLookupQuery = useClientLookupQuery(isSheetOpen); const clientsLookupQuery = useClientLookupQuery(isSheetOpen);
const plansLookupQuery = usePlanLookupQuery(isSheetOpen); const plansLookupQuery = usePlanLookupQuery(isSheetOpen);
@@ -163,8 +166,10 @@ export default function TenantsPage() {
skip={skip} skip={skip}
limit={limit} limit={limit}
total={total} total={total}
sorting={sorting}
onPageChange={setSkip} onPageChange={setSkip}
onLimitChange={setLimit} onLimitChange={setLimit}
onSortingChange={setSorting}
/> />
<PoweredBy /> <PoweredBy />

View File

@@ -58,6 +58,7 @@ export function useUserColumns({
{ {
accessorKey: 'roles', accessorKey: 'roles',
header: 'Roles', header: 'Roles',
enableSorting: false,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{row.original.roles?.length ? ( {row.original.roles?.length ? (
@@ -93,6 +94,7 @@ export function useUserColumns({
columns.push({ columns.push({
id: 'actions', id: 'actions',
header: () => <div className="text-right">Actions</div>, header: () => <div className="text-right">Actions</div>,
enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const user = row.original; const user = row.original;
return ( return (

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import type { AdministrationUser } from '@/types'; import type { AdministrationUser } from '@/types';
@@ -14,8 +14,10 @@ interface UserTableProps {
skip: number; skip: number;
limit: number; limit: number;
total: number; total: number;
sorting: SortingState;
onPageChange: (skip: number) => void; onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void; onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void;
} }
export function UserTable({ export function UserTable({
@@ -26,8 +28,10 @@ export function UserTable({
skip, skip,
limit, limit,
total, total,
sorting,
onPageChange, onPageChange,
onLimitChange, onLimitChange,
onSortingChange,
}: UserTableProps) { }: UserTableProps) {
return ( return (
<DataTable <DataTable
@@ -44,6 +48,8 @@ export function UserTable({
onPageChange, onPageChange,
onLimitChange, onLimitChange,
}} }}
sorting={sorting}
onSortingChange={onSortingChange}
/> />
); );
} }

View File

@@ -2,6 +2,7 @@
import { useDebounce } from '@/hooks/useDebounce'; import { useDebounce } from '@/hooks/useDebounce';
import type { UserStatus } from '@/types'; import type { UserStatus } from '@/types';
import type { SortingState } from '@tanstack/react-table';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
export type StatusFilter = 'all' | UserStatus; export type StatusFilter = 'all' | UserStatus;
@@ -9,6 +10,7 @@ export type StatusFilter = 'all' | UserStatus;
export function useUserFilters() { export function useUserFilters() {
const [skip, setSkip] = useState(0); const [skip, setSkip] = useState(0);
const [limit, setLimitValue] = useState(10); const [limit, setLimitValue] = useState(10);
const [sorting, setSortingValue] = useState<SortingState>([]);
const [searchTerm, setSearchTermValue] = useState(''); const [searchTerm, setSearchTermValue] = useState('');
const [statusFilter, setStatusFilterValue] = useState<StatusFilter>('all'); const [statusFilter, setStatusFilterValue] = useState<StatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400); const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
@@ -28,11 +30,18 @@ export function useUserFilters() {
setSkip(0); setSkip(0);
}, []); }, []);
const setSorting = useCallback((value: SortingState) => {
setSortingValue(value);
setSkip(0);
}, []);
return { return {
skip, skip,
setSkip, setSkip,
limit, limit,
setLimit, setLimit,
sorting,
setSorting,
searchTerm, searchTerm,
debouncedSearchTerm, debouncedSearchTerm,
setSearchTerm, setSearchTerm,

View File

@@ -2,7 +2,9 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import type { SortingState } from '@tanstack/react-table';
import { getServerSortParams } from '@/components/data-table/sorting';
import { userService } from '@/services/api'; import { userService } from '@/services/api';
import type { UserListParams } from '@/types'; import type { UserListParams } from '@/types';
import { userKeys } from '../queries/userKeys'; import { userKeys } from '../queries/userKeys';
@@ -13,6 +15,7 @@ interface UseUsersQueryParams {
limit: number; limit: number;
searchTerm: string; searchTerm: string;
statusFilter: StatusFilter; statusFilter: StatusFilter;
sorting: SortingState;
} }
function buildUserListParams({ function buildUserListParams({
@@ -20,22 +23,22 @@ function buildUserListParams({
limit, limit,
searchTerm, searchTerm,
statusFilter, statusFilter,
sorting,
}: UseUsersQueryParams): UserListParams { }: UseUsersQueryParams): UserListParams {
return { return {
skip, skip,
limit, limit,
search_term: searchTerm || undefined, search_term: searchTerm || undefined,
effective_status: statusFilter === 'all' ? undefined : statusFilter, effective_status: statusFilter === 'all' ? undefined : statusFilter,
sort_by: 'created_at', ...getServerSortParams(sorting),
sort_order: 'desc',
}; };
} }
export function useUsersQuery(params: UseUsersQueryParams) { export function useUsersQuery(params: UseUsersQueryParams) {
const { skip, limit, searchTerm, statusFilter } = params; const { skip, limit, searchTerm, statusFilter, sorting } = params;
const listParams = useMemo( const listParams = useMemo(
() => buildUserListParams({ skip, limit, searchTerm, statusFilter }), () => buildUserListParams({ skip, limit, searchTerm, statusFilter, sorting }),
[limit, searchTerm, skip, statusFilter], [limit, searchTerm, skip, sorting, statusFilter],
); );
return useQuery({ return useQuery({

View File

@@ -27,6 +27,8 @@ export default function UsersPage() {
setSkip, setSkip,
limit, limit,
setLimit, setLimit,
sorting,
setSorting,
searchTerm, searchTerm,
debouncedSearchTerm, debouncedSearchTerm,
setSearchTerm, setSearchTerm,
@@ -39,6 +41,7 @@ export default function UsersPage() {
limit, limit,
searchTerm: debouncedSearchTerm, searchTerm: debouncedSearchTerm,
statusFilter, statusFilter,
sorting,
}); });
const userForm = useUserForm({ const userForm = useUserForm({
onSaved: () => setIsSheetOpen(false), onSaved: () => setIsSheetOpen(false),
@@ -128,8 +131,10 @@ export default function UsersPage() {
skip={skip} skip={skip}
limit={limit} limit={limit}
total={total} total={total}
sorting={sorting}
onPageChange={setSkip} onPageChange={setSkip}
onLimitChange={setLimit} onLimitChange={setLimit}
onSortingChange={setSorting}
/> />
<PoweredBy /> <PoweredBy />

View File

@@ -2,6 +2,8 @@
import { flexRender } from '@tanstack/react-table'; import { flexRender } from '@tanstack/react-table';
import { TableHead, TableHeader as ShadTableHeader, TableRow } from '@/components/ui/table'; import { TableHead, TableHeader as ShadTableHeader, TableRow } from '@/components/ui/table';
import { Table } from '@tanstack/react-table'; import { Table } from '@tanstack/react-table';
import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide } from 'lucide-react';
import { cn } from '@/lib/utils';
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => { const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
return ( return (
@@ -9,14 +11,38 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
const canSort = header.column.getCanSort();
const sortDirection = header.column.getIsSorted();
const SortIcon =
sortDirection === 'asc'
? ArrowUpNarrowWide
: sortDirection === 'desc'
? ArrowDownWideNarrow
: ArrowUpDown;
return ( return (
<TableHead <TableHead
key={header.id} key={header.id}
className="h-10 bg-card/80 px-2 text-foreground backdrop-blur-xl" onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
className={cn(
'h-10 bg-card/80 px-2 text-foreground backdrop-blur-xl transition-colors',
canSort && 'cursor-pointer select-none hover:bg-muted/60',
sortDirection && 'bg-muted/70',
)}
> >
{header.isPlaceholder ? null : ( {header.isPlaceholder ? null : (
<div className="flex items-center gap-2"> <div className="flex min-h-8 w-full items-center justify-between gap-2">
{flexRender(header.column.columnDef.header, header.getContext())} <span className="truncate">
{flexRender(header.column.columnDef.header, header.getContext())}
</span>
{canSort ? (
<SortIcon
className={cn(
'size-3.5 shrink-0 transition-colors',
sortDirection ? 'text-foreground' : 'text-muted-foreground/60',
)}
/>
) : null}
</div> </div>
)} )}
</TableHead> </TableHead>

View File

@@ -38,6 +38,8 @@ export interface DataTableProps<TData, TValue> {
onPageChange: (newSkip: number) => void; onPageChange: (newSkip: number) => void;
onLimitChange: (newLimit: number) => void; onLimitChange: (newLimit: number) => void;
}; };
sorting?: SortingState;
onSortingChange?: (sorting: SortingState) => void;
} }
export function DataTable<TData, TValue>({ export function DataTable<TData, TValue>({
@@ -53,9 +55,24 @@ export function DataTable<TData, TValue>({
emptyTitle = 'No results found.', emptyTitle = 'No results found.',
emptyDescription = 'Try adjusting your filters or search terms.', emptyDescription = 'Try adjusting your filters or search terms.',
pagination, pagination,
sorting: controlledSorting,
onSortingChange,
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = React.useState({}); const [rowSelection, setRowSelection] = React.useState({});
const [sorting, setSorting] = React.useState<SortingState>([]); const [internalSorting, setInternalSorting] = React.useState<SortingState>([]);
const sorting = controlledSorting ?? internalSorting;
const isManualSorting = Boolean(onSortingChange);
const handleSortingChange = React.useCallback(
(updater: SortingState | ((old: SortingState) => SortingState)) => {
const nextSorting = typeof updater === 'function' ? updater(sorting) : updater;
if (onSortingChange) {
onSortingChange(nextSorting);
return;
}
setInternalSorting(nextSorting);
},
[onSortingChange, sorting],
);
const columns = React.useMemo(() => { const columns = React.useMemo(() => {
const cols: ColumnDef<TData, TValue>[] = [...initialColumns]; const cols: ColumnDef<TData, TValue>[] = [...initialColumns];
@@ -105,10 +122,11 @@ export function DataTable<TData, TValue>({
columns, columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
onSortingChange: setSorting, onSortingChange: handleSortingChange,
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
manualPagination: !!pagination, manualPagination: !!pagination,
manualSorting: isManualSorting,
pageCount: pagination?.totalItems ? Math.ceil(pagination.totalItems / pagination.limit) : -1, pageCount: pagination?.totalItems ? Math.ceil(pagination.totalItems / pagination.limit) : -1,
state: { state: {
rowSelection, rowSelection,

View File

@@ -0,0 +1,14 @@
import type { SortingState } from '@tanstack/react-table';
export function getServerSortParams(sorting: SortingState) {
const activeSort = sorting[0];
if (!activeSort) {
return {};
}
return {
sort_by: activeSort.id,
sort_order: activeSort.desc ? 'desc' : 'asc',
};
}