# Phase 14: /pax8 UI Surface - Pattern Map

**Mapped:** 2026-07-11
**Files analyzed:** 8 (1 page, 4 API routes, 1 extended component, 1 modified component, 1 nav edit)
**Analogs found:** 8 / 8

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|--------------------|------|-----------|-----------------|---------------|
| `app/pax8/page.tsx` | component (page) | request-response (list + drill-down) | `app/engagement/page.tsx` (Tabs shell) + `app/admin/data-browser/companies/page.tsx` (DataTable/DetailModal composition) + `app/admin/device-link-conflicts/page.tsx` (review/resolve cards) | exact (composite of 3 precedents, no single one covers the whole page) |
| `app/api/pax8/companies/route.ts` | route (API) | CRUD (read, paginated/sortable) | `app/api/admin/device-link-conflicts/route.ts` (query shape) + `app/admin/data-browser/companies/page.tsx`'s consumed `/api/data/companies` (pagination/sort param contract) | role-match |
| `app/api/pax8/companies/[id]/route.ts` | route (API) | CRUD (read, single-entity aggregate) | `app/api/admin/device-link-conflicts/route.ts` (bulk-fetch-then-map pattern) | role-match |
| `app/api/pax8/company-matches/route.ts` | route (API) | CRUD (read, list with filter) | `app/api/admin/device-link-conflicts/route.ts` | exact |
| `app/api/pax8/company-matches/[id]/resolve/route.ts` | route (API) | CRUD (write, transactional) | `app/api/admin/device-link-conflicts/[id]/resolve/route.ts` | exact (with one required divergence — see Pattern 4 below) |
| `components/admin/DetailModal.tsx` | component (modal, extended in place) | transform (render) | itself — additive extension only, see Pitfall/Pattern below | n/a (modification, not new file) |
| `components/navigation/app-navigation.tsx` | component (nav config, edited) | n/a | itself — one array entry added | n/a (modification, not new file) |
| `lib/services/pax8-company-match-resolver.ts` (optional, if extracting for testability) | service | CRUD (transactional write) | `lib/services/pax8-company-matcher.ts` (query/eligibility conventions) | role-match |

## Pattern Assignments

### `app/pax8/page.tsx` (component/page, request-response)

**Analogs:** `app/engagement/page.tsx` (top-level Tabs page shell), `app/admin/data-browser/companies/page.tsx` (DataTable + DetailModal composition), `app/admin/device-link-conflicts/page.tsx` (review-card list + resolve action + Select filter + Skeleton/Alert states)

**Page shell + Tabs pattern** (`app/engagement/page.tsx` lines 597-644):
```tsx
'use client';
import { useEffect, useState, useCallback } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
...
return (
  <div className="container mx-auto px-6 py-6 space-y-6">
    <div className="flex items-center justify-between">
      <div>
        <h1 className="text-2xl font-bold tracking-tight">Employee Engagement</h1>
        <p className="text-muted-foreground text-sm mt-1">...</p>
      </div>
      <div className="flex items-center gap-3">{/* actions */}</div>
    </div>

    <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'overview' | 'by-employee')} className="space-y-6">
      <TabsList>
        <TabsTrigger value="overview">Overview</TabsTrigger>
        <TabsTrigger value="by-employee">By Employee</TabsTrigger>
      </TabsList>
      <TabsContent value="overview" className="space-y-6">...</TabsContent>
    </Tabs>
  </div>
);
```
Use this shell for `/pax8`'s two tabs: `"companies"` / `"needs-review"`. Note `app/admin/device-link-conflicts/page.tsx` instead uses `<PageHeader>` (`components/navigation/page-header.tsx`) for its title block — either is acceptable; `PageHeader` gives breadcrumbs (`accent` prop) if the planner wants a heavier header, `engagement`'s inline `<h1>` is lighter. Given `/pax8` is a new top-level route (not nested under `/admin`), prefer the `PageHeader` pattern with `breadcrumbs={[{ label: 'PAX8' }]}` for consistency with how `device-link-conflicts` (its closest functional cousin) presents itself.

**DataTable + DetailModal composition** (`app/admin/data-browser/companies/page.tsx` lines 38-78, 166-216):
```tsx
const [companies, setCompanies] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const [selectedCompany, setSelectedCompany] = useState<any>(null);
const [modalOpen, setModalOpen] = useState(false);

const fetchCompanies = async (currentPage: number, search?: string, sortBy?: string, sortOrder?: string) => {
  setIsLoading(true);
  try {
    const params = new URLSearchParams({ page: currentPage.toString(), limit: pageSize.toString() });
    if (search) params.append('search', search);
    if (sortBy) params.append('sort', sortBy);
    if (sortOrder) params.append('order', sortOrder);
    const response = await fetch(`/api/data/companies?${params}`);
    const result = await response.json();
    setCompanies(result.data || []);
    setTotalCount(result.pagination?.total || 0);
  } catch (error) {
    console.error('Failed to fetch companies:', error);
  } finally {
    setIsLoading(false);
  }
};

const handleRowClick = (company: any) => {
  setSelectedCompany(company);
  setModalOpen(true);
};

// ... columns array with { key, label, sortable?, render? } ...

<DataTable
  columns={columns}
  data={companies}
  totalCount={totalCount}
  page={page}
  pageSize={pageSize}
  onPageChange={setPage}
  onSort={(column, direction) => fetchCompanies(page, undefined, column, direction)}
  onSearch={(query) => fetchCompanies(1, query)}
  onRowClick={handleRowClick}
  isLoading={isLoading}
/>

<DetailModal
  open={modalOpen}
  onOpenChange={setModalOpen}
  title={`Company: ${selectedCompany?.name ?? selectedCompany?.id}`}
  data={selectedCompany}
/>
```
For `/pax8`, `handleRowClick` should trigger a *second* fetch (`GET /api/pax8/companies/[id]`) to get subscriptions/cost-breakdown before opening the modal, since the list row won't carry the full drill-down payload (per research's Architecture Patterns 1-2 split). Fetch-then-open, not open-then-fetch-in-modal, keeps loading state visible on the row rather than inside the dialog.

**Review-list + resolve-action + empty/error states** (`app/admin/device-link-conflicts/page.tsx` lines 80-129, 172-196, 198-282):
```tsx
async function resolve(reviewId: string, ciId: string): Promise<void> {
  setResolving(`${reviewId}:${ciId}`);
  try {
    const res = await fetch(`/api/admin/device-link-conflicts/${reviewId}/resolve`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ ciId }),
    });
    const data = (await res.json().catch(() => ({}))) as { error?: string };
    if (!res.ok) throw new Error(data.error ?? `Request failed: ${res.status}`);
    toast.success(`Linked to CI ${ciId}`);
    setItems((prev) => prev?.filter((r) => r.id !== reviewId) ?? null);
    setTotal((t) => Math.max(0, t - 1));
  } catch (err) {
    toast.error(err instanceof Error ? err.message : 'Resolve failed');
  } finally {
    setResolving(null);
  }
}
```
Error/loading/empty-state trio to copy verbatim (swap copy text):
```tsx
{error && (
  <Alert variant="destructive"><AlertTitle>Failed to load</AlertTitle><AlertDescription>{error}</AlertDescription></Alert>
)}
{items === null && !error && (
  <div className="space-y-2"><Skeleton className="h-24 w-full" />...</div>
)}
{items !== null && items.length === 0 && !error && (
  <Alert><CheckCircle2 className="size-4" /><AlertTitle>No conflicts</AlertTitle><AlertDescription>Nothing waiting for review on this filter.</AlertDescription></Alert>
)}
```
D-05's manual-search fallback and D-09's empty-candidate-array empty state ("No suggested matches — search manually") are new UI not present in `device-link-conflicts` (which has no manual-picker fallback) — build a `Command`/`Popover` combobox fed by `/api/data/companies-list` (see below), rendered inside each review card alongside (or instead of, when `candidate_company_ids` is empty) the candidate buttons.

---

### `app/api/pax8/companies/route.ts` (route, CRUD/read)

**Analog:** `app/api/admin/device-link-conflicts/route.ts` (query construction + auth placement), consumed-contract shape from `app/admin/data-browser/companies/page.tsx`'s `/api/data/companies` (page/limit/sort/order params + `{ data, pagination: { total } }` response envelope — verify actual response shape against that route before matching exactly, this phase's route can use either envelope shape but should be internally consistent)

**Auth pattern** — D-07 requires only `requireAuth()`, NOT `requirePermission()` (this file's routes must NOT copy `device-link-conflicts`' `requirePermission('admin','access')` for the read routes):
```typescript
import { requireAuth } from '@/lib/auth-utils';
export async function GET(request: NextRequest) {
  const { error } = await requireAuth();
  if (error) return error;
  // ...
}
```

**Paginated/parameterized query pattern** (`app/api/admin/device-link-conflicts/route.ts` lines 40-55):
```typescript
const url = request.nextUrl;
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '50', 10) || 50, 200);
const offset = Math.max(parseInt(url.searchParams.get('offset') ?? '0', 10) || 0, 0);
const params: unknown[] = [limit, offset];
```

**Company list join** (verified live against schema — RESEARCH.md Pattern 1, adapted from `scripts/verify-pax8-orders-matching.ts` lines 76-85):
```sql
SELECT
  pc.id, pc.name, pc.status, pc.city, pc.state_or_province, pc.country,
  pc.autotask_company_id, pc.match_confidence, pc.match_method,
  c.company_name AS matched_company_name,
  (SELECT count(*) FROM pax8_subscriptions s
    WHERE s.pax8_company_id = pc.id AND s.is_deleted = false
      AND s.status = 'Active') AS active_subscription_count
FROM pax8_companies pc
LEFT JOIN companies c ON c.id = pc.autotask_company_id
WHERE pc.is_deleted = false
ORDER BY pc.name
LIMIT $1 OFFSET $2;
```

**Response transform (snake_case → camelCase)** — no existing file does this exact shape for pax8, but every route in the codebase manually maps in the handler, e.g. `app/api/admin/device-link-conflicts/route.ts` lines 103-130:
```typescript
const items = reviews.rows.map((r) => ({
  id: r.id,
  detectedAt: r.detected_at,
  // ...
}));
return NextResponse.json({ items, total, limit, offset });
```

---

### `app/api/pax8/companies/[id]/route.ts` (route, CRUD/read, aggregate)

**Analog:** `app/api/admin/device-link-conflicts/route.ts`'s bulk-fetch-then-map pattern (lines 74-92) for the two-step "current subscriptions" + "latest order-item per subscription" join described in RESEARCH.md Pattern 2. No exact single-entity aggregate precedent exists elsewhere in the codebase for pax8 — this is a genuinely new query shape, follow the two-SQL-then-join-in-JS approach RESEARCH.md's Pattern 2 lays out rather than a single mega-query, to keep the per-subscription `DISTINCT ON` windowing (Pitfall 2) legible:
```sql
-- Step 1
SELECT s.id AS subscription_id, s.product_id, p.name AS product_name, p.sku,
       s.quantity, s.billing_term, s.status, s.price, s.partner_cost, s.currency
FROM pax8_subscriptions s
LEFT JOIN pax8_products p ON p.id = s.product_id
WHERE s.pax8_company_id = $1 AND s.is_deleted = false
ORDER BY p.name NULLS LAST;

-- Step 2 (per-subscription windowed latest, NOT a single MAX(start_period))
SELECT DISTINCT ON (subscription_id)
       subscription_id, product_id, sku, description, item_type,
       start_period, end_period, quantity, unit_price, line_total,
       partner_cost, partner_cost_total
FROM pax8_order_items
WHERE pax8_company_id = $1 AND is_deleted = false
  AND subscription_id IS NOT NULL
ORDER BY subscription_id, start_period DESC;
```
Auth: `requireAuth()` only (D-07) — same as the list route.

---

### `app/api/pax8/company-matches/route.ts` (route, CRUD/read)

**Analog:** `app/api/admin/device-link-conflicts/route.ts` — near-exact structural match, entire file (34-133) is the template.

**Auth** — D-07: view is `requireAuth()`, NOT `requirePermission('admin','access')` (this is the one place this phase's routes diverge from `device-link-conflicts`, which gates its GET admin-only — CONTEXT.md D-08 explicitly calls this out as deliberate).

**Bulk-fetch-candidates query** (adapted, `app/api/admin/device-link-conflicts/route.ts` lines 56-92, RESEARCH.md Pattern 3):
```typescript
const reviews = await postgresClient.query(
  `SELECT r.id::text, r.detected_at::text, r.candidate_company_ids,
          r.match_confidences,
          pc.id AS pax8_company_id, pc.name AS pax8_company_name
     FROM pax8_company_match_review r
     JOIN pax8_companies pc ON pc.id = r.pax8_company_id
    WHERE r.resolved_at IS NULL
    ORDER BY r.detected_at DESC
    LIMIT $1 OFFSET $2`,
  [limit, offset]
);
const allCandidateIds = new Set<number>();
for (const r of reviews.rows) for (const id of r.candidate_company_ids ?? []) allCandidateIds.add(Number(id));
const companyNames = await postgresClient.query(
  `SELECT id, company_name FROM companies WHERE id = ANY($1::bigint[])`,
  [Array.from(allCandidateIds)]
);
```
Note the type divergence flagged in RESEARCH.md: `candidate_company_ids` is `BIGINT[]`, not `UUID[]` like `device_link_review.candidate_ci_ids` — no `::text[]` cast needed on the array column itself in the SELECT.

---

### `app/api/pax8/company-matches/[id]/resolve/route.ts` (route, CRUD/write, transactional)

**Analog:** `app/api/admin/device-link-conflicts/[id]/resolve/route.ts` — full file is the template, with one required divergence (see below).

**Auth pattern (D-08 — copy exactly, this is the one route that DOES match device-link-conflicts' gating):**
```typescript
const { session, error } = await requirePermission('admin', 'access');
if (error) return error;
```

**Zod validation pattern** (lines 18-21, adapt field names):
```typescript
const ResolveBody = z.object({
  companyId: z.number().int().positive(),
  note: z.string().max(500).optional(),
});
```

**Transaction + row-lock pattern** (lines 51-98) — copy the `FOR UPDATE` + already-resolved 409 guard verbatim:
```typescript
return postgresClient.transaction(async (tx) => {
  const reviewRes = await tx.query(
    `SELECT pax8_company_id, candidate_company_ids, resolved_at
       FROM pax8_company_match_review WHERE id = $1 FOR UPDATE`,
    [id]
  );
  if (reviewRes.rowCount === 0) return NextResponse.json({ error: 'Review not found' }, { status: 404 });
  if (reviewRes.rows[0].resolved_at) return NextResponse.json({ error: 'Already resolved' }, { status: 409 });
  // DIVERGENCE: device-link-conflicts validates ciId is in candidate_ci_ids here
  // and 400s otherwise. DO NOT copy that check — D-05 requires accepting any
  // valid Autotask companyId, not just candidates (manual-search fallback).
  // Instead: validate companyId exists in `companies` (and is_active) before writing.
  ...
});
```

**Required two-table write (the actual divergence from the analog — device-link-conflicts only writes one other table):**
```typescript
await tx.query(
  `UPDATE pax8_companies
      SET autotask_company_id = $2, match_confidence = NULL,
          match_method = 'manual', matched_at = NOW()
    WHERE id = $1`,
  [reviewRes.rows[0].pax8_company_id, companyId]
);
await tx.query(
  `UPDATE pax8_company_match_review
      SET resolved_at = NOW(), resolved_by_user_id = $2,
          resolved_to_company_id = $3, resolution_note = $4
    WHERE id = $1`,
  [id, session?.user?.id ?? null, companyId, note ?? null]
);
```
This is load-bearing: `lib/services/pax8-company-matcher.ts` lines 216-231 excludes companies from re-matching via `c.match_method IS DISTINCT FROM 'manual'` AND a `NOT EXISTS` against resolved review rows — both signals must be set or the resolution won't "stick" against the next sync (D-06's persistence requirement).

**Anti-pattern to avoid** (explicit — matches `device-link-conflicts`' own candidate-membership check, which must NOT be copied here): don't restrict `companyId` to `candidate_company_ids` membership. Validate existence/active-state in `companies` instead.

---

### `components/admin/DetailModal.tsx` (component, extended in place — not a new file)

**This is a modification, not a from-scratch pattern.** Read in full; confirmed structure:

- `detectGroups(data)` (lines 256-260) sniffs field presence to pick a group set:
```typescript
function detectGroups(data: Record<string, any>): FieldGroup[] {
  if ('ticket_number' in data) return TICKET_GROUPS;
  if ('company_name' in data) return COMPANY_GROUPS;
  return [{ label: 'Fields', fields: Object.keys(data).map(k => ({ key: k, label: k })) }];
}
```
A PAX8 company row has `name`, not `company_name` — it will silently fall into the flat unstyled fallback branch today. Required additive change: add a `kind?: 'ticket' | 'company' | 'pax8_company'` prop to `DetailModalProps` (line 266-272) so the new page passes `kind="pax8_company"` explicitly instead of relying on field-name sniffing, and branch `detectGroups`/the header block on that prop when present (fall back to existing sniffing when absent, so `TICKET_GROUPS`/`COMPANY_GROUPS`/every other current caller is untouched).

- Header block also branches on `'ticket_number' in data` (lines 348-375) — same pattern, needs a `kind`-aware branch alongside, not instead of, the existing ternary.

- Every `FieldType` in `resolveLabel()` (lines 136-254) renders a single scalar — **no array-rendering path exists**. The subscriptions/cost-breakdown table (D-02/D-03) needs a new, unconditionally-rendered section in the Formatted tab (parallel to the existing "Description block for tickets" section at lines 543-551, which is exactly this shape — an unconditional extra block keyed on field presence):
```tsx
{'description' in data && data.description && (
  <div>
    <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">Description</h3>
    <div className="rounded-lg border p-4 text-sm whitespace-pre-wrap ...">{data.description}</div>
  </div>
)}
```
Copy this block's shape (conditional wrapper + `<h3>` label + bordered container) for a new `{data.subscriptions && Array.isArray(data.subscriptions) && (...)}` section rendering a compact table (product name / qty / billing term / line total) plus a summed total, styled with the same `rounded-lg border` / `text-xs uppercase tracking-wider` conventions used throughout this file — do not introduce a different visual language.

**Do not touch:** `TICKET_GROUPS`, `COMPANY_GROUPS`, or their existing detection branches (lines 49-132, 256-260's first two conditions) — this must be a pure addition per RESEARCH.md Pitfall 1.

---

### `components/navigation/app-navigation.tsx` (nav config, edited)

**Pattern** (lines 50-195: `navigationItems` array; lines 211-217: role-based filter):
```typescript
{
  title: 'PAX8',
  href: '/pax8',
  icon: /* pick a lucide icon not already used at top level, e.g. ShoppingCart or CreditCard */,
  description: 'PAX8 companies, subscriptions, and cost breakdown',
},
```
Add as a **top-level, non-nested** entry (like `Dashboard`/`Configuration Items`) — not inside the `Admin` children array — because the `visibleItems` filter (lines 212-217) only hides items titled `'Engagement'` or `'Admin'` from non-super-admins; every other top-level item (including a new `PAX8` one) is visible to all authenticated users by default, matching D-07's view-access requirement without any filter-logic change. `MobileNav` (`components/navigation/mobile-nav.tsx`) consumes the same `navigationItems` array — confirm no separate edit is needed there (per RESEARCH.md, one array edit covers both surfaces).

---

## Shared Patterns

### Authentication / Authorization split (D-07 / D-08)
**Source:** `lib/auth-utils.ts` lines 31-45 (`requireAuth`), 51-74 (`requirePermission`)
**Apply to:**
- `requireAuth()` only → `app/api/pax8/companies/route.ts`, `app/api/pax8/companies/[id]/route.ts`, `app/api/pax8/company-matches/route.ts` (view routes, D-07)
- `requirePermission('admin', 'access')` → `app/api/pax8/company-matches/[id]/resolve/route.ts` (mutation, D-08)
```typescript
const { session, error } = await requireAuth();          // view routes
if (error) return error;

const { session, error } = await requirePermission('admin', 'access');  // resolve route
if (error) return error;
```
Every new route MUST call one of these explicitly at the top of the handler — do not rely on `middleware.ts` (it only checks session-cookie presence, confirmed by reading `middleware.ts`; `/api/pax8/sync/route.ts` at lines 1-30 is the existing counter-example with **no** auth check at all — CONTEXT.md/RESEARCH.md both explicitly flag this as a gap not to repeat).

### Error handling / response shape
**Source:** every route above; convention is uniform across the codebase.
```typescript
try {
  // ...
} catch (err) {
  console.error('Failed to ...:', err);
  return NextResponse.json(
    { error: err instanceof Error ? err.message : 'Failed to ...' },
    { status: 500 }
  );
}
```
404/409/400 are returned inline (not via catch) for expected conditions (`Review not found`, `Already resolved`, validation failure) — see the resolve route analog.

### Toasts + resolve-action UX
**Source:** `app/admin/device-link-conflicts/page.tsx` lines 111-129
**Apply to:** the resolve button/handler in the "Needs Review" tab
```typescript
toast.success(`Linked to CI ${ciId}`);   // adapt message
toast.error(err instanceof Error ? err.message : 'Resolve failed');
```

### Manual-search fallback data source (D-05)
**Source:** `app/api/data/companies-list/route.ts` (full file, 15 lines) — returns `[{id, company_name}, ...]` for all active, non-deleted companies, no pagination, no auth check today (fine to leave as-is per RESEARCH.md — only ~242 rows, fetched once and filtered client-side; consider whether this phase should add a `requireAuth()` to it too, since D-08's spirit is "every new route" — this route isn't new, but flagging for planner judgment).
```typescript
const result = await postgresClient.query(
  `SELECT id, company_name FROM companies WHERE is_active = true AND is_deleted = false ORDER BY company_name ASC`
);
return NextResponse.json(result.rows);
```
Fetch once on mount/tab-open in the page, filter client-side with a shadcn `Command`/`Popover` combobox for D-05's manual-picker fallback.

### Postgres transaction pattern
**Source:** `app/api/admin/device-link-conflicts/[id]/resolve/route.ts` lines 51-98
**Apply to:** `app/api/pax8/company-matches/[id]/resolve/route.ts`
```typescript
return postgresClient.transaction(async (tx) => {
  const row = await tx.query(`... FOR UPDATE`, [id]);
  // guards (not found / already resolved) return early inside the transaction callback
  await tx.query(`UPDATE ...`, [...]);
  await tx.query(`UPDATE ...`, [...]);
  return NextResponse.json({ ok: true });
});
```

## No Analog Found

| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| Per-company windowed cost-breakdown query (`DISTINCT ON (subscription_id) ... ORDER BY subscription_id, start_period DESC`) | query pattern within `app/api/pax8/companies/[id]/route.ts` | transform/aggregate | No existing route in the codebase performs a per-subscription-windowed latest-row join; this is genuinely new SQL. RESEARCH.md Pattern 2 + Pitfall 2 is the authoritative source — follow that, not a codebase analog. |
| Array-rendering section inside `DetailModal`'s Formatted tab | component (modal section) | transform (render) | No existing `FieldGroup`/`FieldType` renders a list of rows; closest precedent is the unconditional "Description block for tickets" (single scalar, not a table) — extend from that shape, budgeted as its own task per RESEARCH.md Pitfall 1. |

## Metadata

**Analog search scope:** `app/admin/device-link-conflicts/**`, `app/api/admin/device-link-conflicts/**`, `components/admin/DataTable.tsx`, `components/admin/DetailModal.tsx`, `components/navigation/app-navigation.tsx`, `app/admin/data-browser/companies/page.tsx`, `app/api/data/companies-list/route.ts`, `app/engagement/page.tsx`, `lib/auth-utils.ts`, `lib/services/pax8-company-matcher.ts`, `middleware.ts`
**Files scanned:** 11 read in full (all cited above); `migrations/091,093_pax8_*.sql` schema referenced via RESEARCH.md's already-verified live-DB queries rather than re-read (no new information would be gained from re-reading migration files RESEARCH.md already fully quotes)
**Pattern extraction date:** 2026-07-11
