---
phase: 14-pax8-ui-surface
plan: 05
type: execute
wave: 3
depends_on: ["14-02", "14-04"]
files_modified:
  - app/pax8/page.tsx
  - app/api/data/companies-list/route.ts
autonomous: true
requirements: [PAX8-14, PAX8-12]
must_haves:
  truths:
    - "The Needs Review tab lists every unresolved flagged/ambiguous PAX8 company match in amber-bordered cards, distinct from the Companies list"
    - "Each card offers a 'Link company' button per stored top-3 candidate AND a manual company-search combobox fallback"
    - "A card with an empty candidate list shows the 'No suggested matches — search manually' empty state (D-09) with only the manual picker"
    - "Clicking a resolve action POSTs to the admin-gated resolve route; on success the card disappears, a success toast shows, and the count badge decrements"
    - "The Needs Review trigger shows a count badge when there are open reviews"
  artifacts:
    - path: "app/pax8/page.tsx"
      provides: "Needs Review tab: review cards, candidate resolve buttons, manual-search combobox, count badge, empty/error/loading states"
      contains: "company-matches"
    - path: "app/api/data/companies-list/route.ts"
      provides: "requireAuth-hardened companies-list used by the manual-search fallback"
      exports: ["GET"]
  key_links:
    - from: "app/pax8/page.tsx Needs Review tab"
      to: "/api/pax8/company-matches"
      via: "fetch on tab load"
      pattern: "/api/pax8/company-matches"
    - from: "app/pax8/page.tsx resolve handler"
      to: "/api/pax8/company-matches/[id]/resolve"
      via: "POST { companyId, note? }"
      pattern: "/resolve"
    - from: "manual-search combobox"
      to: "/api/data/companies-list"
      via: "fetch-once + client-side filter"
      pattern: "/api/data/companies-list"
---

<objective>
Fill in the `Needs Review` tab stubbed by Plan 04: an amber-bordered card list (mirroring `device-link-conflicts`) of unresolved PAX8 company matches, each offering per-candidate "Link company" buttons plus a manual company-search combobox fallback (D-05), with the D-09 zero-candidate empty state, count badge, and resolve wiring to the admin-gated route. Also harden `/api/data/companies-list` with `requireAuth()` since this phase makes it a data source for authenticated UI.

Purpose: PAX8-14 (surface flagged matches for resolution) and PAX8-12's UI half (admin resolves them). This tab is the manual-resolution workflow that lets an admin fix the 38 open reviews without psql, satisfying roadmap SC#3-4.
Output: completed `app/pax8/page.tsx` Needs Review tab + auth-hardened companies-list route.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/14-pax8-ui-surface/14-RESEARCH.md
@.planning/phases/14-pax8-ui-surface/14-PATTERNS.md
@.planning/phases/14-pax8-ui-surface/14-UI-SPEC.md
@.planning/phases/14-pax8-ui-surface/14-CONTEXT.md
@.planning/phases/14-pax8-ui-surface/14-02-SUMMARY.md
@.planning/phases/14-pax8-ui-surface/14-04-SUMMARY.md

<interfaces>
<!-- Contracts this tab consumes. Plans 02 + 04 must be complete. -->

GET /api/pax8/company-matches?limit&offset (Plan 02) →
  { items: Array<{ id, detectedAt, pax8CompanyId, pax8CompanyName,
                   candidates: Array<{ companyId, companyName, confidence }> }>,
    total, limit, offset }

POST /api/pax8/company-matches/[id]/resolve (Plan 02, admin-gated) →
  body { companyId: number, note?: string }
  200 { ok: true, resolvedToCompanyId } | 400/403/404/409 { error }

GET /api/data/companies-list → [{ id, company_name }, ...] (all active companies, ~242 rows, no pagination)
  This plan adds requireAuth() to it.

Existing page (Plan 04): app/pax8/page.tsx already has the Tabs shell, activeTab state,
and a marked `{/* Needs Review tab implemented in Plan 14-05 */}` placeholder in the
needs-review TabsContent. This plan replaces that block; it also adds a count badge to the
needs-review TabsTrigger.

Precedent to mirror: app/admin/device-link-conflicts/page.tsx
  - amber card: <Card className="border-amber-200"> with an AlertTriangle text-amber-500 icon
  - resolve(reviewId, ...) handler: POST, on !res.ok throw, toast.success/toast.error,
    optimistic removal setItems(prev => prev.filter(...)) + setTotal(t => t-1)
  - error Alert (variant="destructive"), Skeleton loading trio, empty-state Alert with CheckCircle2

Combobox: shadcn Command + Popover (both already vendored in components/ui/). Fetch
/api/data/companies-list once on first tab open, filter client-side by company_name.

UI-SPEC copy (Copywriting Contract):
  - candidate button: "Link company"; manual path button: "Link to selected company"
  - empty (no open reviews): heading "No companies need review", body
    "Every synced PAX8 company is matched to an Autotask company."
  - per-row zero-candidate (D-09): heading "No suggested matches", body
    "Search manually to link this company to its Autotask counterpart."
  - error: heading "Couldn't load PAX8 data", body includes the /admin/integrations hint
  - success toast: "Linked to {companyName}"; failure toast: err.message ?? 'Resolve failed'
  - tab count badge: show open-review total when > 0
Color: amber border/icon = status hue (NOT the primary accent); "Link company"/"Link to
selected company" buttons + focus rings use --primary.
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Harden /api/data/companies-list with requireAuth()</name>
  <read_first>
    - app/api/data/companies-list/route.ts (full 15-line file — current GET has no auth)
    - lib/auth-utils.ts (requireAuth)
    - app/admin/display-settings/page.tsx (the only other consumer — an admin page already behind auth, so adding requireAuth is safe)
  </read_first>
  <files>app/api/data/companies-list/route.ts</files>
  <action>
    Add `const { error } = await requireAuth(); if (error) return error;` as the first statement of the `GET` handler, importing `requireAuth` from `@/lib/auth-utils`. Change the signature to accept the request if needed (requireAuth reads headers internally, so no request arg is required — keep `GET()` as-is and just call requireAuth). Leave the query and response shape unchanged (`[{ id, company_name }]`). This closes the gap RESEARCH.md flagged (D-08's "every route" spirit) now that this route feeds an authenticated UI's manual-search fallback; the sole other consumer is an authenticated admin page, so no caller breaks.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `app/api/data/companies-list/route.ts` GET calls `requireAuth()` and returns `error` before querying (grep: `requireAuth`)
    - Response shape unchanged: array of `{ id, company_name }`
    - `npx tsc --noEmit --pretty` passes
  </acceptance_criteria>
  <done>companies-list requires an authenticated session; unauthenticated callers get 401.</done>
</task>

<task type="auto">
  <name>Task 2: Needs Review tab — review cards + candidate resolve + count badge</name>
  <read_first>
    - app/pax8/page.tsx (the Plan 04 shell — locate the needs-review placeholder and the TabsTrigger to add the badge to; reuse its error/loading conventions)
    - app/admin/device-link-conflicts/page.tsx (full file — amber Card layout, resolve handler with optimistic removal + toasts, Skeleton/Alert/empty-state trio to copy)
    - .planning/phases/14-pax8-ui-surface/14-02-SUMMARY.md (final /api/pax8/company-matches + resolve response shapes)
    - .planning/phases/14-pax8-ui-surface/14-UI-SPEC.md (Copywriting, Color amber-vs-primary, Visual Hierarchy: amber cards are the focal point)
  </read_first>
  <files>app/pax8/page.tsx</files>
  <action>
    Replace the needs-review placeholder with a full implementation. Add state: `reviews` (`Review[] | null`), `reviewTotal`, `reviewError`, `resolving` (a string key like `${reviewId}:${companyId}` or null). On first activation of the needs-review tab (or on mount), `fetch('/api/pax8/company-matches?limit=100')`, set `reviews`/`reviewTotal`; handle error into `reviewError`. Render the device-link-conflicts trio: destructive `Alert` on error (heading "Couldn't load PAX8 data", body with the /admin/integrations hint), `Skeleton` list while `reviews === null`, and an empty-state `Alert` with `CheckCircle2` (heading "No companies need review", body "Every synced PAX8 company is matched to an Autotask company.") when the list is empty. For each review render `<Card className="border-amber-200">` with an `AlertTriangle className="text-amber-500"` header showing `pax8CompanyName`. Inside, list each candidate as a row with its `companyName` + a `Badge` showing `confidence`, and a primary "Link company" `Button` that calls `resolve(reviewId, candidate.companyId, candidate.companyName)`. The `resolve(reviewId, companyId, companyName)` handler POSTs to `/api/pax8/company-matches/${reviewId}/resolve` with body `{ companyId }`; on non-ok throw with the parsed `error`; on success `toast.success(\`Linked to ${companyName}\`)`, optimistically remove the review (`setReviews(prev => prev?.filter(r => r.id !== reviewId) ?? null)`), and `setReviewTotal(t => Math.max(0, t-1))`; on failure `toast.error(err.message ?? 'Resolve failed')`; always clear `resolving`. Add a count badge to the `Needs Review` `TabsTrigger`: when `reviewTotal > 0`, render a small rounded badge with the number (mirror DetailModal's tab count-badge style referenced in UI-SPEC). Leave a clearly-marked insertion point inside each card for the manual-search combobox (Task 3). Amber = status hue only; the resolve buttons + focus rings use `--primary` (UI-SPEC Color).
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - Needs Review tab fetches `/api/pax8/company-matches` and renders amber-bordered cards (grep: `border-amber-200` and `/api/pax8/company-matches`)
    - Each candidate has a "Link company" button whose handler POSTs to `.../resolve` with `{ companyId }` (grep: `/resolve`)
    - On success the card is optimistically removed and `toast.success` fires with "Linked to {name}"; on failure `toast.error` fires
    - The Needs Review `TabsTrigger` shows a count badge when `reviewTotal > 0`
    - Error/loading/empty states use the device-link-conflicts Alert/Skeleton copy from UI-SPEC
    - `npx tsc --noEmit --pretty` passes
  </acceptance_criteria>
  <done>The Needs Review tab distinctly surfaces flagged matches and resolves them via candidate buttons against the admin-gated route, with a live count badge.</done>
</task>

<task type="auto">
  <name>Task 3: Manual company-search combobox fallback (D-05 / D-09)</name>
  <read_first>
    - app/pax8/page.tsx (the Task 2 card insertion point + resolve handler to reuse)
    - components/ui/command.tsx and components/ui/popover.tsx (Command/Popover primitive APIs)
    - .planning/phases/14-pax8-ui-surface/14-UI-SPEC.md (Copywriting: "Link to selected company", zero-candidate empty state; Color: Command focus ring uses --primary)
    - .planning/phases/14-pax8-ui-surface/14-CONTEXT.md (D-05 dual path, D-09 empty-candidate handling)
  </read_first>
  <files>app/pax8/page.tsx</files>
  <action>
    Add a manual company picker to every review card, using shadcn `Command` inside a `Popover`. Fetch `/api/data/companies-list` once (on first needs-review tab open) into a shared `allCompanies` state (`{ id, company_name }[]`); guard against refetching. In each card render a `Popover` whose trigger is a `Button variant="outline"` reading the currently-selected company name or "Search company…", and whose content is a `Command` with a `CommandInput` filtering `allCompanies` by `company_name` (client-side filter — ~242 rows, no server search) and `CommandItem`s that set a per-card `selectedManualCompany` (track selection per review id, e.g. a `Record<reviewId, {id,name}>` or local card component state). Below the picker render a primary `Button` "Link to selected company" that is disabled until a company is chosen and calls the SAME `resolve(reviewId, selected.id, selected.name)` handler from Task 2. For the D-09 zero-candidate case (`candidates.length === 0`): render the empty-state copy inside the card (heading "No suggested matches", body "Search manually to link this company to its Autotask counterpart.") and show ONLY the manual picker + "Link to selected company" button (no candidate buttons). When candidates exist, show both the candidate "Link company" buttons AND the manual picker as an alternative. Do not add a new endpoint — reuse the resolve route; the resolver already accepts non-candidate company ids (Plan 02).
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - Each review card includes a `Command`/`Popover` combobox fed by `/api/data/companies-list` fetched once (grep: `/api/data/companies-list`)
    - Manual selection enables a "Link to selected company" button that calls the shared resolve handler with the chosen company id
    - A zero-candidate review renders the D-09 empty state ("No suggested matches") and shows ONLY the manual picker (no candidate buttons)
    - When candidates exist, both candidate buttons and the manual picker are available
    - `npx tsc --noEmit --pretty` passes
  </acceptance_criteria>
  <done>Every review can be resolved via a stored candidate or a manual company search; zero-candidate rows resolve purely through the manual picker.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| browser → GET /api/pax8/company-matches | Authenticated read of the flagged queue (Plan 02 gate) |
| browser → POST .../resolve | Admin-only mutation (Plan 02 gate); non-admin clicks receive 403 and surface as a toast |
| browser → GET /api/data/companies-list | Manual-search data source — hardened to requireAuth in Task 1 |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-14-01 | Elevation of Privilege | resolve action reachable from a manager-visible tab | mitigate | The mutation is enforced server-side by Plan 02's `requirePermission('admin','access')`. The UI does not rely on hiding the button for security — a non-admin POST returns 403, surfaced via `toast.error`. (Optionally hide/disable the resolve controls for non-admins as UX, but server gate is the control) |
| T-14-05 | Information Disclosure | /api/data/companies-list feeding the manual search | mitigate | Task 1 adds `requireAuth()` to the previously-unauthenticated route now that it backs authenticated UI (closes RESEARCH.md-flagged gap) |
| T-14-02 | Tampering | manual picker submitting an arbitrary company id | mitigate | Accepted by design (D-05) but bounded server-side: Plan 02's resolver validates the id exists and is_active before writing — the client picker only offers active companies, and the server re-validates |
| T-14-06 | Tampering (XSS) | rendering flagged/candidate company names | mitigate | React text auto-escaping; no dangerouslySetInnerHTML |
| T-14-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages; Command/Popover already vendored |
</threat_model>

<verification>
- `npx tsc --noEmit --pretty` passes
- Manual (Plan 06): resolve an ambiguous review via a candidate button and a zero-candidate review via manual search; confirm cards vanish, toasts fire, and the count badge decrements
</verification>

<success_criteria>
- Needs Review tab surfaces flagged matches distinctly (amber cards) with candidate + manual resolution paths and D-09 empty state
- companies-list route requires auth
- Resolve wiring hits the admin-gated route; success/failure feedback via toasts; live count badge
- Type check green
</success_criteria>

<output>
Create `.planning/phases/14-pax8-ui-surface/14-05-SUMMARY.md` when done
</output>
