From 0cdc27fd9507036c73f355a1dfd9fd1e41d717e1 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 12 Sep 2026 15:03:24 -0500 Subject: [PATCH] Give Users a directory page and rebuild the admin lists around the table The admin dashboard was carrying the user directory as a panel beneath the stats, so the one table people actually work in was the hardest to reach and the smallest thing on screen. Users now has its own page alongside Organizations and Projects, and all three are built as list views rather than tables dropped onto a page. - New /admin/users route; the dashboard keeps only stats and analytics. - AdminListPage: title, row count and filters in one header bar, rows scrolling beneath a pinned table header, paging pinned at the bottom. The document no longer scrolls - only the rows do. - AdminDataTable gains a 'page' variant and column meta for width and alignment, so short columns stop giving their slack to Email. - Projects surfaces the creator it was already fetching and never rendering. - Table primitive takes containerClassName; its internal overflow-x wrapper is the sticky ancestor, so the scroll area has to be set there. Claude-Session: https://claude.ai/code/session_01LqxkXwhjRDsJ1N9cBYpU1n --- packages/web/e2e/admin-flow.spec.ts | 34 ++-- .../web/src/components/admin/UserTable.tsx | 10 +- .../components/admin/ui/AdminDataTable.tsx | 74 +++++-- .../src/components/admin/ui/AdminListPage.tsx | 35 ++++ packages/web/src/components/admin/ui/index.ts | 3 +- .../layout/sidebar/AdminSidebar.tsx | 5 +- packages/web/src/components/ui/table.tsx | 15 +- packages/web/src/routeTree.gen.ts | 22 +++ .../_app/_protected/admin/billing.ledger.tsx | 2 +- .../routes/_app/_protected/admin/index.tsx | 80 +------- .../_app/_protected/admin/orgs.index.tsx | 94 ++++----- .../_app/_protected/admin/projects.index.tsx | 187 ++++++++++-------- .../_app/_protected/admin/users.$userId.tsx | 10 +- .../_app/_protected/admin/users.index.tsx | 78 ++++++++ 14 files changed, 399 insertions(+), 250 deletions(-) create mode 100644 packages/web/src/components/admin/ui/AdminListPage.tsx create mode 100644 packages/web/src/routes/_app/_protected/admin/users.index.tsx diff --git a/packages/web/e2e/admin-flow.spec.ts b/packages/web/e2e/admin-flow.spec.ts index f0970e975..97900da1d 100644 --- a/packages/web/e2e/admin-flow.spec.ts +++ b/packages/web/e2e/admin-flow.spec.ts @@ -1,9 +1,9 @@ /** * Admin flow e2e test * - * Exercises the admin dashboard, user detail pages (loader pilot with - * useSuspenseQuery), navigation between detail pages, and non-admin - * access denial -- all in a single workflow. + * Exercises the users directory, user detail pages (loader pilot with + * useSuspenseQuery), navigation between detail pages, the dashboard + * snapshot, and non-admin access denial -- all in a single workflow. * * Requires: * - Dev server running: pnpm --filter web dev (localhost:3010, DEV_MODE=true) @@ -56,7 +56,7 @@ async function loginAndGoto( await page.goto(path); } -test('Admin dashboard, user detail (loader pilot), and access control', async ({ +test('Users directory, user detail (loader pilot), dashboard, and access control', async ({ page, context, }) => { @@ -87,14 +87,11 @@ test('Admin dashboard, user detail (loader pilot), and access control', async ({ // Email-verified badge renders for seeded user await expect(page.getByText('Verified')).toBeVisible(); - // ── Back link navigates to dashboard ── - await page.getByRole('link', { name: /Back to Admin Dashboard/ }).click(); - await expect(page.getByText('Admin Dashboard')).toBeVisible({ timeout: 10_000 }); - - // ── Dashboard stats and user table ── - await expect(page.getByText('Total Users')).toBeVisible(); - await expect(page.getByText('Active Sessions')).toBeVisible(); - await expect(page.getByText('New This Week')).toBeVisible(); + // ── Back link navigates to the users directory ── + await page.getByRole('link', { name: /Back to Users/ }).click(); + await expect(page.getByRole('heading', { name: 'Users', exact: true })).toBeVisible({ + timeout: 10_000, + }); // ── Search for the regular user and navigate via click (client-side loader) ── const searchInput = page.getByPlaceholder('Search by name or email...'); @@ -112,8 +109,10 @@ test('Admin dashboard, user detail (loader pilot), and access control', async ({ await expect(page.getByText('Profile Information')).toBeVisible({ timeout: 10_000 }); // ── Navigate to admin's own profile ── - await page.getByRole('link', { name: /Back to Admin Dashboard/ }).click(); - await expect(page.getByText('Admin Dashboard')).toBeVisible({ timeout: 10_000 }); + await page.getByRole('link', { name: /Back to Users/ }).click(); + await expect(page.getByRole('heading', { name: 'Users', exact: true })).toBeVisible({ + timeout: 10_000, + }); await searchInput.fill(scenario.admin.email); await waitForSearchToSettle(page); @@ -130,6 +129,13 @@ test('Admin dashboard, user detail (loader pilot), and access control', async ({ // Admin user shows the Admin badge in the profile area await expect(page.getByRole('main').getByText('Admin', { exact: true })).toBeVisible(); + // ── Dashboard is the stats snapshot, reached from the sidebar ── + await page.goto('/admin'); + await expect(page.getByText('Admin Dashboard')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText('Total Users')).toBeVisible(); + await expect(page.getByText('Active Sessions')).toBeVisible(); + await expect(page.getByText('New This Week')).toBeVisible(); + // ── Non-admin access control ── await context.clearCookies(); await loginAndGoto(page, context, scenario.regularCookies, '/admin'); diff --git a/packages/web/src/components/admin/UserTable.tsx b/packages/web/src/components/admin/UserTable.tsx index 29eec2a5d..47bbd586f 100644 --- a/packages/web/src/components/admin/UserTable.tsx +++ b/packages/web/src/components/admin/UserTable.tsx @@ -36,8 +36,8 @@ interface UserTableProps { users: UserRow[]; loading?: boolean; refreshing?: boolean; - fillRows?: boolean; skeletonRows?: number; + variant?: 'panel' | 'page'; emptyState?: React.ReactNode; } @@ -45,8 +45,8 @@ export function UserTable({ users, loading, refreshing, - fillRows, skeletonRows, + variant, emptyState, }: UserTableProps) { const navigate = useNavigate(); @@ -107,6 +107,7 @@ export function UserTable({ { accessorKey: 'providers', header: 'Providers', + meta: { className: 'w-28' }, cell: info => { const providers = info.row.original.providers || []; if (providers.length === 0) { @@ -140,6 +141,7 @@ export function UserTable({ { accessorKey: 'banned', header: 'Status', + meta: { className: 'w-24' }, cell: info => info.row.original.banned ? Banned @@ -148,6 +150,7 @@ export function UserTable({ { accessorKey: 'stripeCustomerId', header: 'Stripe customer', + meta: { className: 'w-44' }, cell: info => { const value = info.getValue() as string | undefined; return value ? @@ -158,6 +161,7 @@ export function UserTable({ { accessorKey: 'createdAt', header: 'Joined', + meta: { className: 'w-28' }, cell: info => ( {formatDate(info.getValue() as string | number | null | undefined)} @@ -174,8 +178,8 @@ export function UserTable({ data={users || []} loading={loading} refreshing={refreshing} - fillRows={fillRows} skeletonRows={skeletonRows} + variant={variant} emptyState={emptyState ?? 'No users found'} enableSorting onRowClick={(row: UserRow) => diff --git a/packages/web/src/components/admin/ui/AdminDataTable.tsx b/packages/web/src/components/admin/ui/AdminDataTable.tsx index 36d5e6834..279972079 100644 --- a/packages/web/src/components/admin/ui/AdminDataTable.tsx +++ b/packages/web/src/components/admin/ui/AdminDataTable.tsx @@ -33,6 +33,13 @@ const features = tableFeatures({ export type AdminColumnDef = ColumnDef; +/** Set on a column's `meta`. */ +export interface AdminColumnMeta { + /** Applied to the header and body cell alike, so widths stay in step. */ + className?: string; + align?: 'left' | 'right'; +} + interface AdminDataTableProps { columns: AdminColumnDef[]; data: T[]; @@ -47,8 +54,10 @@ interface AdminDataTableProps { skeletonRows?: number; /** Dims the rows in place while a new page or search result is in flight. */ refreshing?: boolean; - /** Pads short result sets to `skeletonRows` so the panel keeps one height. */ - fillRows?: boolean; + /** Pads the body out to this many rows so the panel keeps one height. */ + fillRows?: number; + /** 'page' fills the shell and scrolls under a pinned header; 'panel' sits in a card. */ + variant?: 'panel' | 'page'; } export function AdminDataTable({ @@ -61,6 +70,7 @@ export function AdminDataTable({ skeletonRows = 8, refreshing, fillRows, + variant = 'panel', }: AdminDataTableProps) { const [sorting, setSorting] = useState([]); @@ -75,16 +85,23 @@ export function AdminDataTable({ const rows = table.getRowModel().rows; - const fillerCount = fillRows ? Math.max(0, skeletonRows - rows.length) : 0; + const fillerCount = fillRows ? Math.max(0, fillRows - rows.length) : 0; + const isPage = variant === 'page'; + // Rows span the full width, so the edge cells carry the header bar's inset. + const edgeInset = + isPage ? + '[&_td:first-child]:pl-6 [&_th:first-child]:pl-6 [&_td:last-child]:pr-6 [&_th:last-child]:pr-6' + : ''; return ( - - +
+ {table.getHeaderGroups().map(headerGroup => ( {headerGroup.headers.map(header => { const sortable = enableSorting && header.column.getCanSort(); const sorted = header.column.getIsSorted(); + const meta = header.column.columnDef.meta as AdminColumnMeta | undefined; return ( ({ } className={cn( 'text-muted-foreground h-9 px-3 text-xs font-medium', + isPage && 'bg-muted/40', sortable && 'hover:text-foreground cursor-pointer transition-colors select-none', + meta?.className, )} onClick={sortable ? header.column.getToggleSortingHandler() : undefined} > -
+
{header.isPlaceholder ? null : ( flexRender(header.column.columnDef.header, header.getContext()) )} @@ -129,7 +153,10 @@ export function AdminDataTable({ Array.from({ length: skeletonRows }, (_, i) => ( {columns.map((_, j) => ( - + ))} @@ -141,7 +168,12 @@ export function AdminDataTable({ {emptyState} @@ -155,11 +187,22 @@ export function AdminDataTable({ className={cn('border-border', onRowClick && 'cursor-pointer')} onClick={() => onRowClick?.(row.original)} > - {row.getAllCells().map(cell => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} + {row.getAllCells().map(cell => { + const meta = cell.column.columnDef.meta as AdminColumnMeta | undefined; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} ))} @@ -169,7 +212,10 @@ export function AdminDataTable({ rows.length > 0 && Array.from({ length: fillerCount }, (_, i) => ( - + ))} diff --git a/packages/web/src/components/admin/ui/AdminListPage.tsx b/packages/web/src/components/admin/ui/AdminListPage.tsx new file mode 100644 index 000000000..4ef3ded7b --- /dev/null +++ b/packages/web/src/components/admin/ui/AdminListPage.tsx @@ -0,0 +1,35 @@ +// Directory views where the table is the page: only the rows scroll, between a +// header bar and pinned paging. + +import type { ReactNode } from 'react'; + +interface AdminListPageProps { + title: string; + /** Total matching rows, not the number on this page. */ + count?: number; + filters?: ReactNode; + footer?: ReactNode; + children: ReactNode; +} + +export function AdminListPage({ title, count, filters, footer, children }: AdminListPageProps) { + return ( +
+
+

{title}

+ {count !== undefined && ( + {count} + )} + {filters &&
{filters}
} +
+ +
{children}
+ + {footer && ( +
+ {footer} +
+ )} +
+ ); +} diff --git a/packages/web/src/components/admin/ui/index.ts b/packages/web/src/components/admin/ui/index.ts index e5cda92e0..bc55ac052 100644 --- a/packages/web/src/components/admin/ui/index.ts +++ b/packages/web/src/components/admin/ui/index.ts @@ -1,6 +1,7 @@ -export { AdminDataTable, type AdminColumnDef } from './AdminDataTable'; +export { AdminDataTable, type AdminColumnDef, type AdminColumnMeta } from './AdminDataTable'; export { AdminEmpty, AdminError } from './AdminEmpty'; export { AdminField, AdminFieldGrid } from './AdminField'; +export { AdminListPage } from './AdminListPage'; export { AdminPage } from './AdminPage'; export { AdminPanel } from './AdminPanel'; export { AdminSearch } from './AdminSearch'; diff --git a/packages/web/src/components/layout/sidebar/AdminSidebar.tsx b/packages/web/src/components/layout/sidebar/AdminSidebar.tsx index d7652b267..e7eaccf6a 100644 --- a/packages/web/src/components/layout/sidebar/AdminSidebar.tsx +++ b/packages/web/src/components/layout/sidebar/AdminSidebar.tsx @@ -6,6 +6,7 @@ import { Link, useLocation } from '@tanstack/react-router'; import { LayoutDashboardIcon, + UsersIcon, BuildingIcon, FolderIcon, HardDriveIcon, @@ -34,6 +35,7 @@ const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [ { label: 'Directory', items: [ + { label: 'Users', icon: UsersIcon, path: '/admin/users' }, { label: 'Organizations', icon: BuildingIcon, path: '/admin/orgs' }, { label: 'Projects', icon: FolderIcon, path: '/admin/projects' }, ], @@ -55,10 +57,9 @@ const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [ }, ]; -// User detail pages are reached from the dashboard, so they keep it highlighted. function isItemActive(pathname: string, path: string): boolean { if (path === '/admin') { - return pathname === '/admin' || pathname === '/admin/' || pathname.startsWith('/admin/users'); + return pathname === '/admin' || pathname === '/admin/'; } return pathname === path || pathname.startsWith(`${path}/`); } diff --git a/packages/web/src/components/ui/table.tsx b/packages/web/src/components/ui/table.tsx index b5a90f728..df198d9f8 100644 --- a/packages/web/src/components/ui/table.tsx +++ b/packages/web/src/components/ui/table.tsx @@ -2,9 +2,16 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; -function Table({ className, ...props }: React.ComponentProps<'table'>) { +function Table({ + className, + containerClassName, + ...props +}: React.ComponentProps<'table'> & { containerClassName?: string }) { return ( -
+
) {
) { return ( ); diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index 936d94bdc..cd3551605 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -76,6 +76,7 @@ import { Route as AppProtectedAdminOrgsIndexRouteImport } from './routes/_app/_p import { Route as AppProtectedAdminOrgsOrgIdRouteImport } from './routes/_app/_protected/admin/orgs.$orgId' import { Route as AppProtectedAdminProjectsIndexRouteImport } from './routes/_app/_protected/admin/projects.index' import { Route as AppProtectedAdminProjectsProjectIdRouteImport } from './routes/_app/_protected/admin/projects.$projectId' +import { Route as AppProtectedAdminUsersIndexRouteImport } from './routes/_app/_protected/admin/users.index' import { Route as AppProtectedAdminUsersUserIdRouteImport } from './routes/_app/_protected/admin/users.$userId' import { Route as AppProtectedProjectsProjectIdStudiesStudyIdChecklistsChecklistIdRouteImport } from './routes/_app/_protected/projects.$projectId/studies.$studyId.checklists.$checklistId' import { Route as ApiOrgsOrgIdProjectsProjectIdStudiesStudyIdPdfsRouteImport } from './routes/api/orgs/$orgId/projects/$projectId/studies/$studyId/pdfs' @@ -433,6 +434,12 @@ const AppProtectedAdminProjectsProjectIdRoute = path: '/projects/$projectId', getParentRoute: () => AppProtectedAdminRoute, } as any) +const AppProtectedAdminUsersIndexRoute = + AppProtectedAdminUsersIndexRouteImport.update({ + id: '/users/', + path: '/users/', + getParentRoute: () => AppProtectedAdminRoute, + } as any) const AppProtectedAdminUsersUserIdRoute = AppProtectedAdminUsersUserIdRouteImport.update({ id: '/users/$userId', @@ -534,6 +541,7 @@ export interface FileRoutesByFullPath { '/admin/users/$userId': typeof AppProtectedAdminUsersUserIdRoute '/admin/orgs/': typeof AppProtectedAdminOrgsIndexRoute '/admin/projects/': typeof AppProtectedAdminProjectsIndexRoute + '/admin/users/': typeof AppProtectedAdminUsersIndexRoute '/projects/$projectId/studies/$studyId/checklists/$checklistId': typeof AppProtectedProjectsProjectIdStudiesStudyIdChecklistsChecklistIdRoute '/api/orgs/$orgId/projects/$projectId/studies/$studyId/pdfs': typeof ApiOrgsOrgIdProjectsProjectIdStudiesStudyIdPdfsRouteWithChildren '/projects/$projectId/studies/$studyId/reconcile/$checklist1Id/$checklist2Id': typeof AppProtectedProjectsProjectIdStudiesStudyIdReconcileChecklist1IdChecklist2IdRoute @@ -602,6 +610,7 @@ export interface FileRoutesByTo { '/admin/users/$userId': typeof AppProtectedAdminUsersUserIdRoute '/admin/orgs': typeof AppProtectedAdminOrgsIndexRoute '/admin/projects': typeof AppProtectedAdminProjectsIndexRoute + '/admin/users': typeof AppProtectedAdminUsersIndexRoute '/projects/$projectId/studies/$studyId/checklists/$checklistId': typeof AppProtectedProjectsProjectIdStudiesStudyIdChecklistsChecklistIdRoute '/api/orgs/$orgId/projects/$projectId/studies/$studyId/pdfs': typeof ApiOrgsOrgIdProjectsProjectIdStudiesStudyIdPdfsRouteWithChildren '/projects/$projectId/studies/$studyId/reconcile/$checklist1Id/$checklist2Id': typeof AppProtectedProjectsProjectIdStudiesStudyIdReconcileChecklist1IdChecklist2IdRoute @@ -677,6 +686,7 @@ export interface FileRoutesById { '/_app/_protected/admin/users/$userId': typeof AppProtectedAdminUsersUserIdRoute '/_app/_protected/admin/orgs/': typeof AppProtectedAdminOrgsIndexRoute '/_app/_protected/admin/projects/': typeof AppProtectedAdminProjectsIndexRoute + '/_app/_protected/admin/users/': typeof AppProtectedAdminUsersIndexRoute '/_app/_protected/projects/$projectId/studies/$studyId/checklists/$checklistId': typeof AppProtectedProjectsProjectIdStudiesStudyIdChecklistsChecklistIdRoute '/api/orgs/$orgId/projects/$projectId/studies/$studyId/pdfs': typeof ApiOrgsOrgIdProjectsProjectIdStudiesStudyIdPdfsRouteWithChildren '/_app/_protected/projects/$projectId/studies/$studyId/reconcile/$checklist1Id/$checklist2Id': typeof AppProtectedProjectsProjectIdStudiesStudyIdReconcileChecklist1IdChecklist2IdRoute @@ -750,6 +760,7 @@ export interface FileRouteTypes { | '/admin/users/$userId' | '/admin/orgs/' | '/admin/projects/' + | '/admin/users/' | '/projects/$projectId/studies/$studyId/checklists/$checklistId' | '/api/orgs/$orgId/projects/$projectId/studies/$studyId/pdfs' | '/projects/$projectId/studies/$studyId/reconcile/$checklist1Id/$checklist2Id' @@ -818,6 +829,7 @@ export interface FileRouteTypes { | '/admin/users/$userId' | '/admin/orgs' | '/admin/projects' + | '/admin/users' | '/projects/$projectId/studies/$studyId/checklists/$checklistId' | '/api/orgs/$orgId/projects/$projectId/studies/$studyId/pdfs' | '/projects/$projectId/studies/$studyId/reconcile/$checklist1Id/$checklist2Id' @@ -892,6 +904,7 @@ export interface FileRouteTypes { | '/_app/_protected/admin/users/$userId' | '/_app/_protected/admin/orgs/' | '/_app/_protected/admin/projects/' + | '/_app/_protected/admin/users/' | '/_app/_protected/projects/$projectId/studies/$studyId/checklists/$checklistId' | '/api/orgs/$orgId/projects/$projectId/studies/$studyId/pdfs' | '/_app/_protected/projects/$projectId/studies/$studyId/reconcile/$checklist1Id/$checklist2Id' @@ -1408,6 +1421,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppProtectedAdminProjectsProjectIdRouteImport parentRoute: typeof AppProtectedAdminRoute } + '/_app/_protected/admin/users/': { + id: '/_app/_protected/admin/users/' + path: '/users' + fullPath: '/admin/users/' + preLoaderRoute: typeof AppProtectedAdminUsersIndexRouteImport + parentRoute: typeof AppProtectedAdminRoute + } '/_app/_protected/admin/users/$userId': { id: '/_app/_protected/admin/users/$userId' path: '/users/$userId' @@ -1458,6 +1478,7 @@ interface AppProtectedAdminRouteChildren { AppProtectedAdminUsersUserIdRoute: typeof AppProtectedAdminUsersUserIdRoute AppProtectedAdminOrgsIndexRoute: typeof AppProtectedAdminOrgsIndexRoute AppProtectedAdminProjectsIndexRoute: typeof AppProtectedAdminProjectsIndexRoute + AppProtectedAdminUsersIndexRoute: typeof AppProtectedAdminUsersIndexRoute } const AppProtectedAdminRouteChildren: AppProtectedAdminRouteChildren = { @@ -1475,6 +1496,7 @@ const AppProtectedAdminRouteChildren: AppProtectedAdminRouteChildren = { AppProtectedAdminUsersUserIdRoute: AppProtectedAdminUsersUserIdRoute, AppProtectedAdminOrgsIndexRoute: AppProtectedAdminOrgsIndexRoute, AppProtectedAdminProjectsIndexRoute: AppProtectedAdminProjectsIndexRoute, + AppProtectedAdminUsersIndexRoute: AppProtectedAdminUsersIndexRoute, } const AppProtectedAdminRouteWithChildren = diff --git a/packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx b/packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx index da79b7280..10297b56f 100644 --- a/packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx +++ b/packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx @@ -379,7 +379,7 @@ function AdminBillingLedgerPage() { data={entries} loading={ledgerQuery.isLoading} refreshing={ledgerQuery.isFetching} - fillRows + fillRows={10} skeletonRows={10} emptyState={ | undefined; - const usersDataQuery = useAdminUsers({ - page, - limit: PAGE_SIZE, - search: debouncedSearch, - }); - const usersData = usersDataQuery.data as - | { - users: Array<{ id: string; [key: string]: unknown }>; - pagination: { limit: number; total: number; totalPages: number }; - } - | undefined; - - const handleSearchChange = (value: string) => { - setSearch(value); - setPage(1); - }; - - const pagination = usersData?.pagination; - return ( - + @@ -66,43 +29,6 @@ function AdminDashboard() { - - - } - footer={ - - } - > - - } - /> - ); } diff --git a/packages/web/src/routes/_app/_protected/admin/orgs.index.tsx b/packages/web/src/routes/_app/_protected/admin/orgs.index.tsx index b3a65a3d8..6d33fd0ff 100644 --- a/packages/web/src/routes/_app/_protected/admin/orgs.index.tsx +++ b/packages/web/src/routes/_app/_protected/admin/orgs.index.tsx @@ -7,8 +7,7 @@ import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { AdminDataTable, AdminEmpty, - AdminPage, - AdminPanel, + AdminListPage, AdminSearch, ServerPagination, type AdminColumnDef, @@ -22,11 +21,10 @@ interface OrgRow { memberCount?: number; projectCount?: number; }; - plan?: string; createdAt?: string | number; } -const PAGE_SIZE = 10; +const PAGE_SIZE = 25; export const Route = createFileRoute('/_app/_protected/admin/orgs/')({ component: AdminOrgList, @@ -74,6 +72,7 @@ function AdminOrgList() { { accessorKey: 'stats.memberCount', header: 'Members', + meta: { className: 'w-24', align: 'right' }, cell: info => ( {info.row.original.stats?.memberCount ?? 0} @@ -83,6 +82,7 @@ function AdminOrgList() { { accessorKey: 'stats.projectCount', header: 'Projects', + meta: { className: 'w-24', align: 'right' }, cell: info => ( {info.row.original.stats?.projectCount ?? 0} @@ -92,6 +92,7 @@ function AdminOrgList() { { accessorKey: 'createdAt', header: 'Created', + meta: { className: 'w-32' }, cell: info => ( {formatDate(info.getValue() as string | number | null | undefined)} @@ -105,51 +106,50 @@ function AdminOrgList() { const pagination = orgsData?.pagination; return ( - - + } + footer={ + + } + > + } - footer={ - + enableSorting + onRowClick={(row: OrgRow) => + navigate({ + to: '/admin/orgs/$orgId' as string, + params: { orgId: row.id } as Record, + }) } - > - - } - enableSorting - onRowClick={(row: OrgRow) => - navigate({ - to: '/admin/orgs/$orgId' as string, - params: { orgId: row.id } as Record, - }) - } - /> - - + /> + ); } diff --git a/packages/web/src/routes/_app/_protected/admin/projects.index.tsx b/packages/web/src/routes/_app/_protected/admin/projects.index.tsx index 8f8e85ce9..6f8b40226 100644 --- a/packages/web/src/routes/_app/_protected/admin/projects.index.tsx +++ b/packages/web/src/routes/_app/_protected/admin/projects.index.tsx @@ -8,8 +8,8 @@ import { AdminDataTable, AdminEmpty, AdminError, + AdminListPage, AdminPage, - AdminPanel, AdminSearch, ServerPagination, type AdminColumnDef, @@ -43,7 +43,7 @@ interface OrgOption { name: string; } -const PAGE_SIZE = 10; +const PAGE_SIZE = 25; const ALL_ORGS_VALUE = 'all'; export const Route = createFileRoute('/_app/_protected/admin/projects/')({ @@ -111,9 +111,31 @@ function AdminProjectList() { ); }, }, + { + accessorKey: 'creatorDisplayName', + header: 'Created by', + cell: info => { + const project = info.row.original; + const name = project.creatorDisplayName || project.creatorName; + if (!name && !project.creatorEmail) { + return -; + } + return ( + } + className='text-muted-foreground hover:text-primary transition-colors' + onClick={(e: React.MouseEvent) => e.stopPropagation()} + > + {name || project.creatorEmail} + + ); + }, + }, { accessorKey: 'memberCount', header: 'Members', + meta: { className: 'w-24', align: 'right' }, cell: info => ( {info.getValue() as number} ), @@ -121,6 +143,7 @@ function AdminProjectList() { { accessorKey: 'fileCount', header: 'Files', + meta: { className: 'w-20', align: 'right' }, cell: info => ( {info.getValue() as number} ), @@ -128,6 +151,7 @@ function AdminProjectList() { { accessorKey: 'createdAt', header: 'Created', + meta: { className: 'w-32' }, cell: info => ( {formatDate(info.getValue() as string | number | null | undefined)} @@ -147,87 +171,86 @@ function AdminProjectList() { } return ( - - - - - - } - footer={ - + - } - > - { - setSearch(''); - setSelectedOrgId(''); - setPage(1); - }} - > - Clear filters - - ) - } - /> - } - enableSorting - onRowClick={(row: ProjectRow) => - navigate({ - to: '/admin/projects/$projectId' as string, - params: { projectId: row.id } as Record, - }) - } + + + } + footer={ + - - + } + > + { + setSearch(''); + setSelectedOrgId(''); + setPage(1); + }} + > + Clear filters + + ) + } + /> + } + enableSorting + onRowClick={(row: ProjectRow) => + navigate({ + to: '/admin/projects/$projectId' as string, + params: { projectId: row.id } as Record, + }) + } + /> + ); } diff --git a/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx b/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx index 051ac07bd..bea3002ff 100644 --- a/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx +++ b/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx @@ -32,7 +32,7 @@ import { RevokeAllSessionsDialog, } from '@/components/admin/users/UserDialogs'; -const BACK_TO_DASHBOARD = { to: '/admin', label: 'Back to Admin Dashboard' }; +const BACK_TO_USERS = { to: '/admin/users', label: 'Back to Users' }; export const Route = createFileRoute('/_app/_protected/admin/users/$userId')({ loader: async ({ params: { userId } }) => { @@ -45,7 +45,7 @@ export const Route = createFileRoute('/_app/_protected/admin/users/$userId')({ function UserDetailError() { const router = useRouter(); return ( - + router.invalidate()} /> ); @@ -54,7 +54,7 @@ function UserDetailError() { /** Holds the page's shape while the suspense query resolves. */ function UserDetailSkeleton() { return ( - + @@ -160,7 +160,7 @@ function UserDetailContent() { await deleteUser(userId); showToast.success('Success', 'User deleted successfully'); setConfirmDialog(null); - navigate({ to: '/admin' as string }); + navigate({ to: '/admin/users' as string }); } catch (error) { await handleError(error, { showToast: true }); setLoading(false); @@ -169,7 +169,7 @@ function UserDetailContent() { return ( diff --git a/packages/web/src/routes/_app/_protected/admin/users.index.tsx b/packages/web/src/routes/_app/_protected/admin/users.index.tsx new file mode 100644 index 000000000..f426cfcde --- /dev/null +++ b/packages/web/src/routes/_app/_protected/admin/users.index.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { createFileRoute } from '@tanstack/react-router'; +import { UsersIcon } from 'lucide-react'; +import { useAdminUsers } from '@/hooks/useAdminQueries'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import { UserTable } from '@/components/admin/UserTable'; +import { AdminEmpty, AdminListPage, AdminSearch, ServerPagination } from '@/components/admin/ui'; + +const PAGE_SIZE = 25; + +export const Route = createFileRoute('/_app/_protected/admin/users/')({ + component: AdminUserList, +}); + +function AdminUserList() { + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const debouncedSearch = useDebouncedValue(search, 300); + + const usersDataQuery = useAdminUsers({ + page, + limit: PAGE_SIZE, + search: debouncedSearch, + }); + const usersData = usersDataQuery.data as + | { + users: Array<{ id: string; [key: string]: unknown }>; + pagination: { limit: number; total: number; totalPages: number }; + } + | undefined; + + const handleSearchChange = (value: string) => { + setSearch(value); + setPage(1); + }; + + const pagination = usersData?.pagination; + + return ( + + } + footer={ + + } + > + + } + /> + + ); +}