agency — client-portal: board-column labels and filters on the client list
As of 2026-08-06, the client-facing Requests list (/tickets for role: client) shows each request’s board-column label instead of the coarse
TicketStatus label, and clients can filter by column the way the team
already can. No Prisma migration. Team/admin views are unchanged.
The bug this fixes as a side effect
Section titled “The bug this fixes as a side effect”columnFilter initialized to the literal 'bc_open'
(TicketsClient.tsx) for every role, including clients. A client’s list was
therefore always scoped server-side to the Open column only — anything an
admin moved to Waiting on Client, Backlog, or a custom column was
invisible on /tickets for that client. initialColumnFilter(isClient, …)
(new, src/lib/ticket-list-filters.ts) now returns 'all' unconditionally
for clients, deep-link params or not. This is a deliberate, visible behavior
change: clients start seeing requests that were previously hidden.
Label resolution
Section titled “Label resolution”New pure helper resolveColumnLabel(ticket, visibleColumns) in
src/lib/board-column-label.ts:
- if
ticket.boardColumnIdmatches an entry invisibleColumnsby id, returns{ label: column.name, color: column.color }; - otherwise (no column, or a column not in
visibleColumns) falls back to{ label: TICKET_STATUS_CONFIG[status].label, color: null }.
visibleColumns is the columns state TicketsClient already fetches from
GET /api/board-columns — which was already server-filtered to
clientVisible: true for a client session — now passed down to
ClientRequestList → RequestRow as a prop. A client-hidden column’s
name therefore never reaches a client browser; the API payload for
tickets didn’t need a new field. ClientRequest gained boardColumnId: string | null to drive the lookup.
RequestRow’s status pill changed from the old TICKET_STATUS_CONFIG-driven
badge (which was conditionally rendered only if (statusCfg)) to always
rendering resolveColumnLabel’s label, with color shown as a leading dot
when non-null — same colour dot the board already renders for a column.
Filtering
Section titled “Filtering”The board-column tab strip (columnTabs, built from columns +
a leading All) was previously gated {!isClient && …} in TicketsClient.
That gate is now removed — clients get the same tabs, driven by the same
client-filtered columns array, so no client-hidden column ever appears as
a tab. The rest of the team-only filter row (search input, My
Tickets/Awaiting-us toggles, tenant/priority/assignee selects, sort mode)
stays behind {isTeam && …}; only the tab strip itself is now shared.
Counts. getCountForColumn (wrapping new countForColumn() in
src/lib/ticket-list-filters.ts) replaces the old inline allColumnTotal
math. For the All tab: a client always gets the API’s total (which
reflects every row actually listed, including any in a column the client
can’t enumerate); a team member gets the sum of visible column counts,
falling back to total if that sum is zero. Per-column tabs read
columnCountsFromApi[colId] for both roles.
Server-side (GET /api/tickets, route.ts): boardColumn.findMany now also
selects clientVisible, and the counts loop skips
isClient && !col.clientVisible columns entirely — so columnCounts in the
JSON response never carries a client-hidden column’s id or count for a
client session. A client passing ?boardColumnId=<id> for a column that
doesn’t exist, or exists but has clientVisible: false, now gets 400 Unknown column (checked via the column’s clientVisible field, newly
selected in the single-column lookup) instead of silently going through with
whatever the default/isDefault branch produced. Team/admin responses to
the same endpoint are unchanged.
Drag-to-reorder guard
Section titled “Drag-to-reorder guard”Two changes, because either alone is insufficient once a client can filter to a subset:
- Client UI —
ClientRequestListgained areorderableprop (TicketsClientpassescolumnFilter === 'all'). Whenfalse:useSortable’sdisabledflag is set, the drag handle button isn’t rendered,handleDragEndno-ops, and the helper text under the list switches from “Drag to set your priority order” to “Switch back to All to reorder your requests.” - Server —
PATCH /api/tickets/client-reordernow loads the client’s full active set (tenantId,clientVisible: true,deletedAt: null,status notIn [done, closed]) and 400s with'Partial ordering'unless the postedorderedIdsis an exact set-equality match (same size, every posted id in the active set) — added before the existing$transactionwrite, so a partial post performs no writes at all. This closes the gap the UI guard alone doesn’t: renumbering only a filtered subset would have collided withclientSortOrderranks held by the filtered-out rows.
The UI guard is what a client experiences (no handle, explanatory text); the
server guard is what makes the invariant (“every client’s clientSortOrder
is a collision-free 1..N sequence”) hold regardless of client.
New: src/lib/__tests__/board-column-label.test.ts (matched column vs.
null/hidden boardColumnId fallback), src/lib/__tests__/ticket-list-filters.test.ts
(countForColumn for client vs. team on all and a specific column;
initialColumnFilter for client vs. team, with/without deep-link params).
Extended: ClientRequestList.test.tsx (column label/colour rendering,
hidden-column fallback, reorderable vs. not), TicketsClient.test.tsx (tab
strip renders and defaults to All for a client), route.test.ts
(client vs. team columnCounts, 400 on hidden/unknown boardColumnId,
default-column fallback preserved for a client-visible default column),
client-reorder/route.test.ts (partial ordering rejected with no writes,
full active-set ordering succeeds). All use renderToStaticMarkup /
mocked-Prisma per repo convention.
No changes to the board view, team rows, column CRUD, or the
Ticket/BoardColumn schema.
Source: agency PR #399.