# Admin Dashboard Staff Workload Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Admin dashboard shows active staff count, per-staff open ticket counts (including zeros), and a live feed with status, assignee, and last-message time.

**Architecture:** Enrich ticket list/detail SQL with `assigned_user.display_name` and `last_message_at` (max message `created_at`). Frontend Dashboard aggregates open tickets by `assigned_to` against active staff from `GET /users`. No new dashboard API.

**Tech Stack:** Fastify + postgres.js (`sql`), React + TanStack Query, shared types in `@wa-ticketing/shared`, existing `SoKpiCard` / dashboard list styles.

## Global Constraints

- Active users = roles `admin` | `user` with `is_active === true` (exclude `company_user`)
- Workload list shows **every** active staff member, including open count `0`
- Last worked on = latest message `created_at` (any direction); fallback `updated_at` in UI
- Portal dashboard unchanged
- No schema migration; no new `/dashboard` route
- Open-count aggregation uses existing `GET /tickets?status=open&limit=100` (document approximate if >100 open)
- Match existing dashboard visual language (white cards, `so-*` tokens)

**Spec:** `docs/superpowers/specs/2026-08-11-admin-dashboard-staff-workload-design.md`

## File map

| File | Responsibility |
|------|----------------|
| `packages/shared/src/types.ts` | `Ticket.assigned_user` includes `display_name`; add `last_message_at` |
| `apps/api/src/services/ticketService.ts` | Join `display_name`; subquery `last_message_at` on list + getById |
| `apps/frontend/src/pages/Dashboard.tsx` | Active users KPI/list + richer live feed rows |
| `CHANGELOG.md`, `README.md`, `docs/NewKnowledgeBase.md`, `docs/TestSprite_PRD_WA_Ticketing.md` | Docs / PRD |

---

### Task 1: Shared types + ticket SQL enrichments

**Files:**
- Modify: `packages/shared/src/types.ts` (Ticket interface ~331–356)
- Modify: `apps/api/src/services/ticketService.ts` (`getById` ~291–322, `list` ~354–396)

**Interfaces:**
- Produces: `Ticket.assigned_user?: Pick<User, 'id' | 'email' | 'display_name'>`
- Produces: `Ticket.last_message_at?: string | null`
- Consumes: `messages.created_at`, `users.display_name`

- [ ] **Step 1: Update shared Ticket type**

In `packages/shared/src/types.ts`, change the Ticket joined fields to:

```ts
  // Joined fields (optional)
  contact?: Contact;
  channel?: Channel;
  assigned_user?: Pick<User, 'id' | 'email' | 'display_name'>;
  /** ISO timestamp of latest message; null/omitted when ticket has no messages */
  last_message_at?: string | null;
  portal_participant?: PortalParticipant;
```

- [ ] **Step 2: Enrich getById SELECT**

Replace `json_build_object('id', u.id, 'email', u.email)` with:

```sql
json_build_object(
  'id', u.id,
  'email', u.email,
  'display_name', u.display_name
) AS assigned_user,
```

Add after the `portal_participant` CASE (still in SELECT list):

```sql
(
  SELECT MAX(m.created_at)
  FROM messages m
  WHERE m.ticket_id = t.id
) AS last_message_at,
```

Keep existing JOINs; `mapTicketRow` already spreads the row so no mapper change required unless null handling is desired (`last_message_at: row.last_message_at ?? null`).

- [ ] **Step 3: Enrich list SELECT**

Apply the **same** `assigned_user` JSON and `last_message_at` subquery changes to `ticketService.list` (mirror getById).

- [ ] **Step 4: Rebuild shared + smoke API**

Run from repo root:

```bash
npm run build --workspace=@wa-ticketing/shared
```

Expected: build succeeds.

Then hit (with staff JWT) `GET /api/v1/tickets?limit=1` and confirm each item includes `assigned_user.display_name` (may be null) and `last_message_at` (string or null).

- [ ] **Step 5: Commit**

```bash
git add packages/shared/src/types.ts apps/api/src/services/ticketService.ts
git commit -m "feat(api): enrich tickets with assignee display_name and last_message_at"
```

---

### Task 2: Dashboard staff workload + live feed UI

**Files:**
- Modify: `apps/frontend/src/pages/Dashboard.tsx`

**Interfaces:**
- Consumes: `GET /users` → `User[]`
- Consumes: `GET /tickets?status=open&limit=100` → `Ticket[]` (existing query)
- Consumes: `Ticket.assigned_user`, `Ticket.last_message_at`, `Ticket.status`, `Ticket.assigned_to`
- Produces: Active users count + per-staff open counts; live feed columns for status / responsible / last worked on

- [ ] **Step 1: Fetch users (staff dashboard only)**

Add query (only when `!isPortal`):

```ts
const { data: users, isLoading: usersLoading } = useQuery({
  queryKey: ['users'],
  queryFn: () => api.get<User[]>('/users').then(r => r.data),
  enabled: !isPortal,
});
```

Import `User` from `@wa-ticketing/shared`.

- [ ] **Step 2: Derive active staff + open counts**

```ts
const activeStaff = (users ?? []).filter(
  u => u.is_active && (u.role === 'admin' || u.role === 'user'),
);

const openByAssignee = new Map<string, number>();
let unassignedOpen = 0;
for (const t of openTickets ?? []) {
  if (!t.assigned_to) {
    unassignedOpen += 1;
    continue;
  }
  openByAssignee.set(t.assigned_to, (openByAssignee.get(t.assigned_to) ?? 0) + 1);
}

const staffWorkload = activeStaff
  .map(u => ({
    id: u.id,
    label: u.display_name?.trim() || u.email,
    openCount: openByAssignee.get(u.id) ?? 0,
  }))
  .sort((a, b) => b.openCount - a.openCount || a.label.localeCompare(b.label));
```

- [ ] **Step 3: Render Active users block**

Insert after the KPI grid (and before or after channel health — prefer **after KPI grid, before channel health**), only when `!isPortal`:

```tsx
{!isPortal && (
  <div className="bg-white border border-so-border rounded-2xl p-5 so-card-shadow">
    <div className="flex justify-between items-center mb-3.5">
      <div className="text-sm font-semibold text-so-text">Active users</div>
      <div className="text-2xl font-semibold text-so-text tabular-nums">
        {usersLoading ? '—' : activeStaff.length}
      </div>
    </div>
    <ul className="divide-y divide-[#EDF1F5]">
      {staffWorkload.map(row => (
        <li key={row.id} className="flex justify-between gap-3 py-2.5 text-sm">
          <span className="truncate text-so-text">{row.label}</span>
          <span className="shrink-0 tabular-nums text-so-muted">
            {row.openCount} open
          </span>
        </li>
      ))}
    </ul>
    {!usersLoading && (
      <div className="mt-3 pt-3 border-t border-[#EDF1F5] flex justify-between text-xs text-so-muted">
        <span>Unassigned</span>
        <span className="tabular-nums">{unassignedOpen} open</span>
      </div>
    )}
  </div>
)}
```

- [ ] **Step 4: Enrich live feed row**

For each ticket row, compute:

```ts
const assigneeLabel =
  ticket.assigned_user?.display_name?.trim()
  || ticket.assigned_user?.email
  || 'Unassigned';
const lastWorkedAt = ticket.last_message_at ?? ticket.updated_at ?? ticket.created_at;
```

Update the right-side meta area so it clearly shows:

1. Priority badge (existing)
2. Status badge alone (e.g. `ticket.status.toUpperCase()` with muted/open styling)
3. Responsible line: `assigneeLabel`
4. Relative time from `lastWorkedAt` labeled as last activity (keep compact; e.g. second line under assignee or replace the bare `w-8` time with a wider column)

Suggested structure for the trailing meta (replace the single priority+status chip + tiny time):

```tsx
<div className="shrink-0 text-right min-w-[7.5rem]">
  <div className="flex justify-end gap-1.5 mb-1">
    <span /* priority chip — existing pri styles */>{pri.label}</span>
    <span className="text-[10px] font-semibold tracking-wide px-2 py-0.5 rounded-full bg-[#EEF2F6] text-so-text">
      {ticket.status.toUpperCase()}
    </span>
  </div>
  <div className="text-xs text-so-muted truncate">{assigneeLabel}</div>
  <div className="text-[11px] text-so-muted">
    {formatDistanceToNow(new Date(lastWorkedAt), { addSuffix: true })}
  </div>
</div>
```

Do not change portal-only behaviour of the page beyond existing ticket list (portal still uses the same feed component; assignee/status fields are fine to show for portal too if the feed renders for them — keep portal KPI grid as today).

- [ ] **Step 5: Manual smoke**

1. Log in as admin → Dashboard
2. Confirm Active users count matches active staff
3. Confirm every active staff appears; open counts match assignments (including 0)
4. Confirm Unassigned line
5. Confirm live feed shows status, assignee, last worked relative time
6. Confirm portal user does **not** see Active users block

- [ ] **Step 6: Commit**

```bash
git add apps/frontend/src/pages/Dashboard.tsx
git commit -m "feat(frontend): show staff workload and richer live ticket feed"
```

---

### Task 3: Docs + TestSprite PRD

**Files:**
- Modify: `CHANGELOG.md`
- Modify: `README.md` (dashboard section if present; else brief feature note)
- Modify: `docs/NewKnowledgeBase.md`
- Modify: `docs/TestSprite_PRD_WA_Ticketing.md` (§3 Flow 1, §4 acceptance, §7 GET tickets response)

- [ ] **Step 1: CHANGELOG**

Add under Unreleased / latest:

```md
- Admin dashboard: active staff count, per-staff open ticket counts, live feed status/assignee/last message time
- Ticket API list/detail: `assigned_user.display_name`, `last_message_at`
```

- [ ] **Step 2: README + NewKnowledgeBase**

Document:

- Dashboard aggregates active staff from `/users` + open tickets from `/tickets?status=open`
- `last_message_at` is max(`messages.created_at`); UI falls back to `updated_at`
- Open counts capped by list `limit=100`

- [ ] **Step 3: TestSprite PRD**

Update Flow 1 dashboard steps to mention Active users + enriched live feed.  
Update GET `/api/v1/tickets` response note: `assigned_user` includes `display_name`; `last_message_at` present.  
Add acceptance bullets:

```md
- [ ] Admin dashboard shows active staff count and per-staff open ticket counts (including zeros)
- [ ] Live ticket queue shows status, assignee (or Unassigned), and last message relative time
```

- [ ] **Step 4: Commit**

```bash
git add CHANGELOG.md README.md docs/NewKnowledgeBase.md docs/TestSprite_PRD_WA_Ticketing.md
git commit -m "docs: admin dashboard staff workload and ticket enrichments"
```

---

## Spec coverage check

| Spec requirement | Task |
|------------------|------|
| `assigned_user.display_name` + `last_message_at` on list/detail | Task 1 |
| Shared Ticket types | Task 1 |
| Active staff count + list with zeros | Task 2 |
| Unassigned open count | Task 2 |
| Live feed status / responsible / last message | Task 2 |
| Portal unchanged for Active users | Task 2 |
| Docs / PRD | Task 3 |
| No new dashboard route / no migration | (constraint — not added) |

## Placeholder / consistency self-review

- Field names consistent: `last_message_at`, `assigned_user.display_name`
- Fallback chain consistent: `last_message_at ?? updated_at ?? created_at`
- Active staff filter matches spec (`admin`/`user` + `is_active`)
