You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Make it easy to tell admins and managers apart from everyone else on /users, without splitting the page into separate tables.
Three changes to the existing table:
Default sort by role — Admins first, then Managers, then users with no role
Role becomes a sortable column — it currently is not
Counts and filters above the table
Current state
components/features/users-table.tsx:
COLUMNS contains only user, joined and applications — role is absent, so the Roles column header is not sortable
defaultSort is { key: 'joined', direction: 'desc' }
The Roles cell renders an Admin badge, a Manager badge, both, or — when neither applies — a bare em dash —
Search filters name and email only, client-side
getUsersForAdmin already returns isAdmin, managedPositions (id + title) and a non-draft applications count
So this is presentation only. No query or schema change is needed, and the filter options derive from data already on the client.
1. Role ordering
Roles are not mutually exclusive — an admin can also manage positions. A user is placed in the highest category they qualify for:
Rank
Group
Rule
Renders as
0
Admin
isAdmin
Admin badge
1
Manager
managedPositions.length > 0
Manager badge
2
—
neither
—, unchanged
The third group stays unnamed. No "Applicant" label, no new badge — users with no role keep the existing em dash and simply sort to the bottom.
Add a role entry to COLUMNS whose accessor returns that rank, so sorting is deterministic rather than alphabetical on a badge string. A rank also keeps the unnamed group ordered correctly without needing a string to sort on.
This is the default sort, not a permanent primary sort. Clicking any other column re-sorts the whole table and the role grouping goes away — that is intended. Sorting by the Role column restores it.
Within a group the order must be stable and meaningful — sort secondarily by name, falling back to email, rather than leaving it to the query's createdAt desc.
2. Counts
A summary line above the table showing how many admins and how many managers, alongside the total number of users. The unnamed third group is not called out as its own figure — it is the remainder, and naming it in the counts would undo the decision above.
Counts should reflect the currently filtered set, not the total, so they stay truthful as filters narrow the list.
3. Filters
Role — All / Admin / Manager. No option for the unnamed group; sorting by the Role column is how you bring those users together
Managed position — pick a position, see who manages it. Options derive from the managedPositions already present on the loaded rows, so no new query
Both compose with the existing search box rather than replacing it
Worth considering: extend the search to match managed position titles too, since that is the other thing an admin scans this table for.
Acceptance criteria
role added to COLUMNS with a rank-based accessor (Admin 0, Manager 1, none 2)
Default sort is role ascending, so users with no role fall to the bottom
Clicking another column overrides the role grouping
The Roles column header is sortable, with correct aria-sort
Ties within a role group sort by name, then email
The third group remains unlabelled — the — cell is unchanged, and no new badge is introduced
Counts for admins and managers shown above the table, plus the total
Counts reflect the filtered set
Role filter (All / Admin / Manager) and managed-position filter, both composing with search
Filter and sort state is keyboard accessible and announced
Overlapping tickets
Extract a Shared DataTable and Section Card #392 (shared DataTable) — the biggest interaction. It proposes a DataTableColumn<T>[] API across all five tables. Filtering and role-aware sorting should live in that shared component, not be bolted onto this one table and rewritten later. Decide the order before starting; a concrete filtering requirement is good input to that abstraction.
Mobile Layout Pass — Users Page, Dashboard, Tables and Filters #378 (touch targets and mobile layout) — same file. This page has 28 of 28 interactive targets under 44px and is the only table with no mobile card fallback. This ticket adds more controls to it. Best done as one piece of work.
Add an Account Deactivated Screen and Admin Reactivation #387 (deactivated screen and reactivation) — proposes a separate admin page listing deactivated accounts. An Active/Deactivated filter here may be the better home. Note getUsersForAdmin hard-filters deletedAt: null, so deactivated users cannot appear under any filter until that changes.
SESSION REQUIRED: touches CLAUDE.md / .claude/** — a dispatched agent can't edit those
Overview
#392 landed, so users-table.tsx already renders through DataTable + filterRows and the mechanism this ticket plugs into exists. The work is the role dimension: give the existing roles column a rank-based sortAccessor, make it the default sort, and add a Role / Managed-position filter pair plus admin & manager figures to the toolbar count line.
Two small, general extensions to lib/data-table.ts keep it declarative instead of hand-rolled on this one table:
Tuple sort accessors — sortAccessor may return an array compared left-to-right, so the role column sorts by [rank, displayName, email] in one accessor. No new prop, no second sort pass.
searchValue split out of filterValue — today filterValue feeds both free-text search and exact-match filters, which breaks the moment a filter token is an id (managed position) or a word the user might type (admin). searchValue feeds the query, filterValue feeds the filters. users-table is the only consumer of filterValue today, so the migration is one column.
No query, action or schema change: getUsersForAdmin already returns everything the counts and both filters need.
Changes
lib/data-table.ts — add SortValue; widen sortAccessor to SortValue | SortValue[] and teach compareValues to compare tuples left-to-right; add searchValue to DataTableColumn<T> and narrow filterValue's doc/role to exact-match filters only; filterRows reads searchValue for query, filterValue for filters.
lib/utils.ts — getUserRoleTokens() and getUserRoleRank() (pure, next to formatTableCount); rank derives from the first token so the two can't drift.
components/features/users-table.tsx — role/position filter state + selects, Clear filters, role counts in the count line; roles column gains sortAccessor + filterValue; managedPositions gains searchValue (titles) + filterValue (ids); user's filterValue → searchValue; defaultSort → { key: 'roles', direction: 'asc' }.
app/(main)/(auth)/users/loading.tsx — toolbar skeleton gains the two select placeholders and a wider count line, so the header doesn't shift on resolve.
.claude/docs/WORKFLOWS.md — new AD-10 Find a user entry (default role ordering, the three composing controls, the count line, the no-match state) + its TOC link on the Admin line. Appended rather than inserted, so AD-7…AD-9 anchors don't renumber.
tests/unit/data-table.test.ts — new: tuple compare and the search/filter split.
tests/unit/utils.test.ts — cases for getUserRoleTokens / getUserRoleRank.
Extend compareValues to accept tuples: normalise both sides to arrays, compare index-by-index with the existing scalar logic, first non-zero wins; a null/undefined element sorts after a present one at that position; equal prefix → shorter array first. Existing scalar call sites keep their exact behaviour.
Add searchValue?: (row: T) => string | string[] | null to DataTableColumn<T>; in filterRows the query pass reads searchValue only and the filters pass reads filterValue only. Update both field comments (one line each).
lib/utils.ts: getUserRoleTokens({ isAdmin, managedPositions }) returns UserRoleFilter[] in rank order — badge semantics, so an admin who also manages positions returns both; getUserRoleRank() returns USER_ROLE_FILTER_OPTIONS.findIndex(first token), falling back to 2 when there are none.
users-table.tsx: roles column gets sortAccessor: (u) => [getUserRoleRank(u), u.name ?? u.email, u.email] and filterValue: getUserRoleTokens. Cell markup unchanged — keep the — and add no badge. Reuse getUserRoleTokens for the existing isManager derivation in the cell and the mobile card rather than re-deriving managedPositions.length > 0.
managedPositions column: filterValue: (u) => u.managedPositions.map((p) => p.id) (ids — titles are not unique) and searchValue: (u) => u.managedPositions.map((p) => p.title), which is what makes search match position titles.
user column: rename its filterValue to searchValue (unchanged body).
Add roleFilter / positionFilteruseState<string>('') next to the existing query; build DataTableFilter[] from whichever are set and pass filterRows(users, COLUMNS, { query, filters }). Local state, not nuqs — matches the search box already on this table (see Risks).
Derive positionOptions from users (not filtered, so options never vanish mid-filter): flatten managedPositions, dedupe by id, sort by title. Render the position select only when positionOptions.length > 0.
Toolbar: Role select, Managed position select, then the existing search input, then Clear filters (only when any of the three is active), then the count line — same flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end row as today. Each select gets a <Label htmlFor> and an id on SelectTrigger, w-full sm:w-48; the "All …" item uses value="" like applications-toolbar.
Count line: keep formatTableCount for the user total (isFiltered = search or either select active), then append · N admins · N managers computed over filtered with singular/plural. Stays in the existing aria-live="polite" paragraph so filtering is announced.
defaultSort={{ key: 'roles', direction: 'asc' }}; noMatchMessage="No users match your filters.". DataTable picks roles up as a valid ?sort value automatically (sortableKeys is derived from the columns).
Update app/(main)/(auth)/users/loading.tsx toolbar block to match the new control row.
Add AD-10 Find a user to .claude/docs/WORKFLOWS.md and link it from the Admin line in the index.
Unit tests: tuple compare (rank then name then email; null element last), filterRows (a filterValue-only column is not searched; a searchValue-only column is not filterable; role + position + query compose with AND), and the role token/rank helpers (admin, manager, admin-and-manager, neither).
npm run prettier:check, npm run eslint:check, npm run tsc:check, npm run test.
UX states
Default view — table opens grouped: admins, then managers, then everyone else, alphabetical by display name inside each group. The Roles header shows the ascending arrow and aria-sort="ascending" on load (DataTable already reflects defaultSort when no ?sort param is present).
Toolbar — one wrapping row above the table: Role (All roles / Admin / Manager) · Managed position (All positions / …titles) · Search (placeholder unchanged: "Search by name or email") · Clear filters · count line pushed right. Stacks full-width below sm.
Counts — 12 users · 2 admins · 3 managers; filtered: 4 / 12 users · 1 admin · 2 managers. Both role figures always render, including 0 admins, so the live region's shape is stable.
Filtered, no match — the table card renders the single centred row "No users match your filters." on desktop and the same sentence in the mobile block; Clear filters in the toolbar is the way out, and the count line reads 0 / 12 users · 0 admins · 0 managers.
Empty (no users at all) — unchanged: the existing EmptyState ("No users found" / "Active users will appear here.") replaces the whole table, toolbar included.
Loading — route-level loading.tsx only; no new async surface. Skeleton updated so the two selects don't pop in.
Error — no new failure path; the promote/deactivate toasts and the global boundary are untouched.
Accessibility — every control has a visible <Label> bound by htmlFor/id; both selects are Radix (keyboard + typeahead + focus return for free); the Roles header is the same <button> inside a <th aria-sort> as the other sortable columns, with the "Sort by Roles, currently ascending" label; the count line's existing aria-live="polite" announces every filter, search and clear.
Sign in as an admin and open /users — rows are grouped admins → managers → the rest, and within each group ordered by name (email for users with no name).
A user who is both an admin and a manager shows both badges and sits in the admin group.
Click the Roles header — arrow and aria-sort cycle asc → desc → cleared; desc puts the unlabelled group first; cleared returns to the default role order.
Click Joined — the role grouping is replaced by the date sort; reload with ?sort=roles&dir=desc in the URL and the table honours it.
Set Role → Admin — only admins remain, the count line reads N / M users · N admins · … managers, and the badges match.
Set Role → Manager — admins who also manage positions are included (intended: the filter follows the badges, not the sort rank).
Pick a Managed position — only that position's managers remain; combine it with Role and with a search term and confirm all three narrow together (AND).
Search a managed position title — matching managers appear; search a name and an email — both still work.
Search something with no matches — "No users match your filters.", count line 0 / M users · 0 admins · 0 managers; Clear filters restores every row and resets all three controls.
Keyboard only: Tab to each select, open with Enter/Space, choose with arrows + Enter, Escape closes; Tab to the Roles header and toggle with Enter. Confirm a screen reader announces the updated count line after each change.
At 375px: controls stack full-width, no horizontal scroll, mobile cards reflect the active filters and sort order.
Throttle to Slow 3G and reload /users — the skeleton toolbar matches the real one, no visible shift.
On an install where no user manages a position, the Managed position select is absent and the rest of the toolbar still lays out correctly.
Promote and deactivate still work from both the desktop row and the mobile card, with their confirmations and toasts.
Both light and dark theme.
Risks / notes
Counts and filters use badge semantics; the rank is only for sorting. An admin who manages positions is counted and filtered as both — that is what their row visibly shows — while the rank still places them in the admin group. Consequence: the two figures can sum above the number of rows. The alternative (mutually-exclusive buckets) would make the Manager filter hide users wearing a Manager badge. Veto at the plan gate if the exclusive reading was intended.
The column key stays roles. The ticket says "add a role entry to COLUMNS", but the column already exists as roles — it just has no sortAccessor. Keeping the key avoids churn and keeps the filter key aligned with the column it filters. It also fixes the URL contract as ?sort=roles.
filterValue semantics change under other callers — users-table is the only consumer today (grep confirms), so the split is a one-column migration, but any table written against the old dual-purpose filterValue after this lands needs searchValue instead.
First click on the already-default Roles header is a no-op visually — DataTable moves from "no ?sort param" to ?sort=roles&dir=asc, which is the order already shown. Pre-existing for joined today; not worth changing DataTable's toggle cycle here.
No schema, migration, prisma/data/ or prisma/actions/ change — this is presentation over data already fetched, so there is no new validation or auth surface.
Goal
Make it easy to tell admins and managers apart from everyone else on
/users, without splitting the page into separate tables.Three changes to the existing table:
Current state
components/features/users-table.tsx:COLUMNScontains onlyuser,joinedandapplications—roleis absent, so the Roles column header is not sortabledefaultSortis{ key: 'joined', direction: 'desc' }Adminbadge, aManagerbadge, both, or — when neither applies — a bare em dash—nameandemailonly, client-sidegetUsersForAdminalready returnsisAdmin,managedPositions(id + title) and a non-draftapplicationscountSo this is presentation only. No query or schema change is needed, and the filter options derive from data already on the client.
1. Role ordering
Roles are not mutually exclusive — an admin can also manage positions. A user is placed in the highest category they qualify for:
isAdminAdminbadgemanagedPositions.length > 0Managerbadge—, unchangedThe third group stays unnamed. No "Applicant" label, no new badge — users with no role keep the existing em dash and simply sort to the bottom.
Add a
roleentry toCOLUMNSwhose accessor returns that rank, so sorting is deterministic rather than alphabetical on a badge string. A rank also keeps the unnamed group ordered correctly without needing a string to sort on.This is the default sort, not a permanent primary sort. Clicking any other column re-sorts the whole table and the role grouping goes away — that is intended. Sorting by the Role column restores it.
Within a group the order must be stable and meaningful — sort secondarily by name, falling back to email, rather than leaving it to the query's
createdAt desc.2. Counts
A summary line above the table showing how many admins and how many managers, alongside the total number of users. The unnamed third group is not called out as its own figure — it is the remainder, and naming it in the counts would undo the decision above.
Counts should reflect the currently filtered set, not the total, so they stay truthful as filters narrow the list.
3. Filters
managedPositionsalready present on the loaded rows, so no new queryWorth considering: extend the search to match managed position titles too, since that is the other thing an admin scans this table for.
Acceptance criteria
roleadded toCOLUMNSwith a rank-based accessor (Admin 0, Manager 1, none 2)aria-sort—cell is unchanged, and no new badge is introducedOverlapping tickets
DataTable) — the biggest interaction. It proposes aDataTableColumn<T>[]API across all five tables. Filtering and role-aware sorting should live in that shared component, not be bolted onto this one table and rewritten later. Decide the order before starting; a concrete filtering requirement is good input to that abstraction.getUsersForAdminhard-filtersdeletedAt: null, so deactivated users cannot appear under any filter until that changes./usersas a candidate. Counts over an unpaginated table make that gap visible.Implementation Plan
Overview
#392 landed, so
users-table.tsxalready renders throughDataTable+filterRowsand the mechanism this ticket plugs into exists. The work is the role dimension: give the existingrolescolumn a rank-basedsortAccessor, make it the default sort, and add a Role / Managed-position filter pair plus admin & manager figures to the toolbar count line.Two small, general extensions to
lib/data-table.tskeep it declarative instead of hand-rolled on this one table:sortAccessormay return an array compared left-to-right, so the role column sorts by[rank, displayName, email]in one accessor. No new prop, no second sort pass.searchValuesplit out offilterValue— todayfilterValuefeeds both free-text search and exact-match filters, which breaks the moment a filter token is an id (managed position) or a word the user might type (admin).searchValuefeeds the query,filterValuefeeds the filters.users-tableis the only consumer offilterValuetoday, so the migration is one column.No query, action or schema change:
getUsersForAdminalready returns everything the counts and both filters need.Changes
lib/data-table.ts— addSortValue; widensortAccessortoSortValue | SortValue[]and teachcompareValuesto compare tuples left-to-right; addsearchValuetoDataTableColumn<T>and narrowfilterValue's doc/role to exact-match filters only;filterRowsreadssearchValueforquery,filterValueforfilters.lib/types.ts—UserRoleFilter = 'admin' | 'manager'.lib/constants.ts—USER_ROLE_FILTER_OPTIONS: { value: UserRoleFilter; label: string }[], array order = rank order, mirroringREVIEWER_APPLICATION_STATUS_OPTIONS.lib/utils.ts—getUserRoleTokens()andgetUserRoleRank()(pure, next toformatTableCount); rank derives from the first token so the two can't drift.components/features/users-table.tsx— role/position filter state + selects, Clear filters, role counts in the count line;rolescolumn gainssortAccessor+filterValue;managedPositionsgainssearchValue(titles) +filterValue(ids);user'sfilterValue→searchValue;defaultSort→{ key: 'roles', direction: 'asc' }.app/(main)/(auth)/users/loading.tsx— toolbar skeleton gains the two select placeholders and a wider count line, so the header doesn't shift on resolve..claude/docs/WORKFLOWS.md— newAD-10 Find a userentry (default role ordering, the three composing controls, the count line, the no-match state) + its TOC link on the Admin line. Appended rather than inserted, so AD-7…AD-9 anchors don't renumber.tests/unit/data-table.test.ts— new: tuple compare and the search/filter split.tests/unit/utils.test.ts— cases forgetUserRoleTokens/getUserRoleRank.Implementation
lib/data-table.ts: exporttype SortValue = string | number | Date;sortAccessor?: (row: T) => SortValue | SortValue[] | null | undefined.compareValuesto accept tuples: normalise both sides to arrays, compare index-by-index with the existing scalar logic, first non-zero wins; anull/undefinedelement sorts after a present one at that position; equal prefix → shorter array first. Existing scalar call sites keep their exact behaviour.searchValue?: (row: T) => string | string[] | nulltoDataTableColumn<T>; infilterRowsthequerypass readssearchValueonly and thefilterspass readsfilterValueonly. Update both field comments (one line each).lib/types.ts+lib/constants.ts:UserRoleFilterandUSER_ROLE_FILTER_OPTIONS(admin→ "Admin",manager→ "Manager").lib/utils.ts:getUserRoleTokens({ isAdmin, managedPositions })returnsUserRoleFilter[]in rank order — badge semantics, so an admin who also manages positions returns both;getUserRoleRank()returnsUSER_ROLE_FILTER_OPTIONS.findIndex(first token), falling back to2when there are none.users-table.tsx:rolescolumn getssortAccessor: (u) => [getUserRoleRank(u), u.name ?? u.email, u.email]andfilterValue: getUserRoleTokens. Cell markup unchanged — keep the—and add no badge. ReusegetUserRoleTokensfor the existingisManagerderivation in the cell and the mobile card rather than re-derivingmanagedPositions.length > 0.managedPositionscolumn:filterValue: (u) => u.managedPositions.map((p) => p.id)(ids — titles are not unique) andsearchValue: (u) => u.managedPositions.map((p) => p.title), which is what makes search match position titles.usercolumn: rename itsfilterValuetosearchValue(unchanged body).roleFilter/positionFilteruseState<string>('')next to the existingquery; buildDataTableFilter[]from whichever are set and passfilterRows(users, COLUMNS, { query, filters }). Local state, not nuqs — matches the search box already on this table (see Risks).positionOptionsfromusers(notfiltered, so options never vanish mid-filter): flattenmanagedPositions, dedupe by id, sort by title. Render the position select only whenpositionOptions.length > 0.Clear filters(only when any of the three is active), then the count line — sameflex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-endrow as today. Each select gets a<Label htmlFor>and anidonSelectTrigger,w-full sm:w-48; the "All …" item usesvalue=""likeapplications-toolbar.formatTableCountfor the user total (isFiltered= search or either select active), then append· N admins · N managerscomputed overfilteredwith singular/plural. Stays in the existingaria-live="polite"paragraph so filtering is announced.defaultSort={{ key: 'roles', direction: 'asc' }};noMatchMessage="No users match your filters.".DataTablepicksrolesup as a valid?sortvalue automatically (sortableKeysis derived from the columns).app/(main)/(auth)/users/loading.tsxtoolbar block to match the new control row.AD-10 Find a userto.claude/docs/WORKFLOWS.mdand link it from the Admin line in the index.filterRows(afilterValue-only column is not searched; asearchValue-only column is not filterable; role + position + query compose with AND), and the role token/rank helpers (admin, manager, admin-and-manager, neither).npm run prettier:check,npm run eslint:check,npm run tsc:check,npm run test.UX states
aria-sort="ascending"on load (DataTablealready reflectsdefaultSortwhen no?sortparam is present).Role(All roles / Admin / Manager) ·Managed position(All positions / …titles) ·Search(placeholder unchanged: "Search by name or email") ·Clear filters· count line pushed right. Stacks full-width belowsm.12 users · 2 admins · 3 managers; filtered:4 / 12 users · 1 admin · 2 managers. Both role figures always render, including0 admins, so the live region's shape is stable.Clear filtersin the toolbar is the way out, and the count line reads0 / 12 users · 0 admins · 0 managers.EmptyState("No users found" / "Active users will appear here.") replaces the whole table, toolbar included.loading.tsxonly; no new async surface. Skeleton updated so the two selects don't pop in.<Label>bound byhtmlFor/id; both selects are Radix (keyboard + typeahead + focus return for free); the Roles header is the same<button>inside a<th aria-sort>as the other sortable columns, with the "Sort by Roles, currently ascending" label; the count line's existingaria-live="polite"announces every filter, search and clear.Testing
/users— rows are grouped admins → managers → the rest, and within each group ordered by name (email for users with no name).aria-sortcycle asc → desc → cleared; desc puts the unlabelled group first; cleared returns to the default role order.?sort=roles&dir=descin the URL and the table honours it.N / M users · N admins · … managers, and the badges match.0 / M users · 0 admins · 0 managers; Clear filters restores every row and resets all three controls./users— the skeleton toolbar matches the real one, no visible shift.Risks / notes
roles. The ticket says "add aroleentry toCOLUMNS", but the column already exists asroles— it just has nosortAccessor. Keeping the key avoids churn and keeps the filter key aligned with the column it filters. It also fixes the URL contract as?sort=roles.nuqsfor all three is a reasonable follow-up, best bundled with Add Pagination to the Applications Hub #389 (pagination) or Mobile Layout Pass — Users Page, Dashboard, Tables and Filters #378.filterValuesemantics change under other callers —users-tableis the only consumer today (grep confirms), so the split is a one-column migration, but any table written against the old dual-purposefilterValueafter this lands needssearchValueinstead.DataTablemoves from "no?sortparam" to?sort=roles&dir=asc, which is the order already shown. Pre-existing forjoinedtoday; not worth changingDataTable's toggle cycle here.prisma/data/orprisma/actions/change — this is presentation over data already fetched, so there is no new validation or auth surface.