Skip to content

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.

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.

New pure helper resolveColumnLabel(ticket, visibleColumns) in src/lib/board-column-label.ts:

  • if ticket.boardColumnId matches an entry in visibleColumns by 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.

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.

Two changes, because either alone is insufficient once a client can filter to a subset:

  1. Client UI — ClientRequestList gained a reorderable prop (TicketsClient passes columnFilter === 'all'). When false: useSortable’s disabled flag is set, the drag handle button isn’t rendered, handleDragEnd no-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.”
  2. Server — PATCH /api/tickets/client-reorder now loads the client’s full active set (tenantId, clientVisible: true, deletedAt: null, status notIn [done, closed]) and 400s with 'Partial ordering' unless the posted orderedIds is an exact set-equality match (same size, every posted id in the active set) — added before the existing $transaction write, 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 with clientSortOrder ranks 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.