diff --git a/.planning/phases/14-pax8-ui-surface/14-05-SUMMARY.md b/.planning/phases/14-pax8-ui-surface/14-05-SUMMARY.md new file mode 100644 index 0000000..63b862d --- /dev/null +++ b/.planning/phases/14-pax8-ui-surface/14-05-SUMMARY.md @@ -0,0 +1,112 @@ +--- +phase: 14-pax8-ui-surface +plan: 05 +subsystem: ui +tags: [next.js, react, shadcn, command, popover, sonner, requireAuth] + +# Dependency graph +requires: + - phase: 14-pax8-ui-surface + plan: 02 + provides: "GET /api/pax8/company-matches, POST /api/pax8/company-matches/[id]/resolve" + - phase: 14-pax8-ui-surface + plan: 04 + provides: "app/pax8/page.tsx page shell with Companies/Needs Review Tabs and the Needs Review placeholder" +provides: + - "app/pax8/page.tsx — Needs Review tab fully implemented: amber review cards, per-candidate resolve buttons, manual company-search combobox (D-05/D-09), count badge" + - "app/api/data/companies-list/route.ts — hardened with requireAuth(), now safe as a data source for authenticated UI" +affects: ["14-06 (manual verification of the Needs Review tab end-to-end)"] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Manual-search combobox: shadcn Command inside Popover, fetched once via a guarded ensureCompaniesLoaded() (checks allCompanies !== null / companiesLoading before fetching) and shared across every review card via per-review-id selection state (Record)" + - "Reused the device-link-conflicts amber-card + Alert/Skeleton/empty-state trio + optimistic-removal resolve() pattern verbatim, extended with a second manual-resolution path calling the same resolve() handler" + +key-files: + created: [] + modified: + - app/pax8/page.tsx + - app/api/data/companies-list/route.ts + +key-decisions: + - "Fetch reviews + companies-list on first activation of the needs-review tab (useEffect keyed on activeTab, guarded by reviews === null / companiesLoading), not on page mount, to avoid an unnecessary request when a manager only visits the Companies tab" + - "Confidence values (raw trigram similarity strings like \"0.850\") are formatted client-side as a rounded percentage (e.g. \"85%\") for the candidate Badge, since the plan left exact formatting to discretion and the numeric convention (font-mono) benefits from a human-readable percentage over a raw decimal string" + - "Count badge on the Needs Review TabsTrigger reuses DetailModal's existing blue-500/20 count-badge style verbatim (per UI-SPEC's explicit callout), not the amber status hue or the --primary accent" + +requirements-completed: [PAX8-14, PAX8-12] + +# Metrics +duration: ~25min +completed: 2026-07-11 +--- + +# Phase 14 Plan 05: Needs Review Tab — Review Cards, Resolve, Manual Search Summary + +**Filled in the `/pax8` Needs Review tab with amber-bordered review cards (candidate "Link company" buttons + a Command/Popover manual company-search fallback), wired to the admin-gated resolve route with optimistic removal and toasts, plus a live count badge; also added `requireAuth()` to `/api/data/companies-list` now that it backs this authenticated UI.** + +## Performance + +- **Duration:** ~25 min +- **Tasks:** 3 completed +- **Files modified:** 2 + +## Accomplishments + +- `/api/data/companies-list` now calls `requireAuth()` before querying — closes the previously-unauthenticated gap RESEARCH.md flagged, response shape unchanged (`[{ id, company_name }]`). +- Needs Review tab fetches `GET /api/pax8/company-matches?limit=100` on first tab activation and renders one `Card className="border-amber-200"` per unresolved review, with an `AlertTriangle` icon header showing the PAX8 company name. +- Each stored candidate renders as a row (name + confidence `Badge`, formatted as a rounded percentage) with a "Link company" button that POSTs `{ companyId }` to `/api/pax8/company-matches/[id]/resolve`; on success the card is optimistically removed, `toast.success("Linked to {companyName}")` fires, and the count badge decrements; on failure `toast.error` fires with the server's message. +- Zero-candidate reviews (D-09) render the "No suggested matches" empty state inline and show only the manual picker — no candidate buttons. +- Every review card also gets a manual company-search combobox (shadcn `Command` inside `Popover`), backed by `/api/data/companies-list` fetched exactly once via a guarded `ensureCompaniesLoaded()` and shared across all cards. Selecting a company enables a "Link to selected company" button that calls the same `resolve()` handler used by candidate buttons — so a zero-candidate review resolves purely through manual search, and a review with candidates offers both paths. +- The Needs Review `TabsTrigger` shows a count badge (mirroring `DetailModal`'s existing Time/Notes tab count-badge style) whenever `reviewTotal > 0`. +- Error/loading/empty states reuse the exact `device-link-conflicts` `Alert`(destructive)/`Skeleton`-trio/`Alert`(empty, `CheckCircle2`) pattern with the UI-SPEC copy verbatim. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Harden /api/data/companies-list with requireAuth()** - `564be52` (feat) +2. **Task 2: Needs Review tab — review cards + candidate resolve + count badge** - `40efa1d` (feat) +3. **Task 3: Manual company-search combobox fallback (D-05/D-09)** - `13272f9` (feat) + +## Files Created/Modified + +- `app/api/data/companies-list/route.ts` — added `requireAuth()` gate as the first statement of `GET` +- `app/pax8/page.tsx` — replaced the Plan 04 Needs Review placeholder with the full review-card UI: fetch/state/resolve handlers, amber cards with candidate resolve rows, count badge, manual-search Command/Popover combobox, D-09 zero-candidate empty state + +## Decisions Made + +- Reviews + companies-list are fetched lazily on first Needs Review tab activation (not on page mount), keeping the Companies-tab-only visit free of an extra request — matches the plan's "(or on mount)" parenthetical loosely but favors the lazier of the two allowed options since it's strictly better for the common case. +- Confidence formatting: raw `TEXT[]` trigram-similarity strings (e.g. `"0.850"`) are parsed and rendered as a rounded percentage badge (`"85%"`) rather than the raw decimal — the plan didn't mandate a specific format and this reads more naturally next to the "Link company" CTA. +- Combobox implemented inline in the review-card `.map()` rather than extracted to a separate component — matches the codebase's low-abstraction convention (CLAUDE.md: "keep functions focused... avoid unnecessary abstraction") and the plan's own phrasing ("In each card render a Popover..."). + +## Deviations from Plan + +None - plan executed exactly as written. All three tasks matched their `` and `` blocks without needing any Rule 1-4 deviation. + +## Issues Encountered + +None. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- The Needs Review tab is fully wired: fetch, amber cards, candidate resolve, manual-search fallback, D-09 empty state, count badge, and the auth-hardened `companies-list` data source it depends on. +- Ready for Plan 06's manual verification: 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. +- No blockers for Plan 06. + +--- +*Phase: 14-pax8-ui-surface* +*Completed: 2026-07-11* + +## Self-Check: PASSED + +- FOUND: app/pax8/page.tsx +- FOUND: app/api/data/companies-list/route.ts +- FOUND commit: 564be52 +- FOUND commit: 40efa1d +- FOUND commit: 13272f9 diff --git a/app/api/data/companies-list/route.ts b/app/api/data/companies-list/route.ts index 0768a83..33dc84e 100644 --- a/app/api/data/companies-list/route.ts +++ b/app/api/data/companies-list/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; import { postgresClient } from '@/lib/services/postgres-client'; export async function GET() { + const { error } = await requireAuth(); + if (error) return error; + try { const result = await postgresClient.query( `SELECT id, company_name FROM companies WHERE is_active = true AND is_deleted = false ORDER BY company_name ASC` diff --git a/app/pax8/page.tsx b/app/pax8/page.tsx index 48f286f..506f928 100644 --- a/app/pax8/page.tsx +++ b/app/pax8/page.tsx @@ -1,11 +1,27 @@ 'use client'; import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; import { PageHeader } from '@/components/navigation/page-header'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { AlertTriangle, Check, CheckCircle2, ChevronsUpDown, Loader2 } from 'lucide-react'; import DataTable, { type Column } from '@/components/admin/DataTable'; import DetailModal from '@/components/admin/DetailModal'; +import { cn } from '@/lib/utils'; interface Pax8CompanyListItem { id: string; @@ -21,6 +37,32 @@ interface Pax8CompanyListItem { activeSubscriptionCount: number; } +interface ReviewCandidate { + companyId: number; + companyName: string | null; + confidence: string | null; +} + +interface CompanyReview { + id: string; + detectedAt: string; + pax8CompanyId: string; + pax8CompanyName: string; + candidates: ReviewCandidate[]; +} + +interface ManualCompanyOption { + id: number; + company_name: string; +} + +function formatConfidence(confidence: string | null): string | null { + if (!confidence) return null; + const n = parseFloat(confidence); + if (Number.isNaN(n)) return confidence; + return `${Math.round(n * 100)}%`; +} + // Maps DataTable column keys (what the table's onSort callback reports) to // the /api/pax8/companies `sort` query values (see 14-01-SUMMARY.md). const SORT_KEY_MAP: Record = { @@ -46,6 +88,18 @@ export default function Pax8Page() { const [selectedCompany, setSelectedCompany] = useState | null>(null); const [modalOpen, setModalOpen] = useState(false); + // Needs Review tab state + const [reviews, setReviews] = useState(null); + const [reviewTotal, setReviewTotal] = useState(0); + const [reviewError, setReviewError] = useState(null); + const [resolving, setResolving] = useState(null); + const [allCompanies, setAllCompanies] = useState(null); + const [companiesLoading, setCompaniesLoading] = useState(false); + const [selectedManualCompany, setSelectedManualCompany] = useState< + Record + >({}); + const [openPopoverId, setOpenPopoverId] = useState(null); + const fetchCompanies = async ( currentPage: number, search?: string, @@ -94,6 +148,73 @@ export default function Pax8Page() { fetchCompanies(nextPage, searchQuery, sortColumn, sortOrder); }; + async function loadReviews(): Promise { + setReviewError(null); + setReviews(null); + try { + const res = await fetch('/api/pax8/company-matches?limit=100'); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `Request failed: ${res.status}`); + } + const data = (await res.json()) as { items: CompanyReview[]; total: number }; + setReviews(data.items); + setReviewTotal(data.total); + } catch (err) { + setReviewError(err instanceof Error ? err.message : 'Unknown error'); + } + } + + async function ensureCompaniesLoaded(): Promise { + if (allCompanies !== null || companiesLoading) return; + setCompaniesLoading(true); + try { + const res = await fetch('/api/data/companies-list'); + if (!res.ok) throw new Error(`Request failed: ${res.status}`); + const data = (await res.json()) as ManualCompanyOption[]; + setAllCompanies(data); + } catch (err) { + console.error('Failed to fetch companies list:', err); + } finally { + setCompaniesLoading(false); + } + } + + // Fetch the review queue + manual-search company list on first activation + // of the Needs Review tab. + useEffect(() => { + if (activeTab !== 'needs-review') return; + if (reviews === null) void loadReviews(); + void ensureCompaniesLoaded(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab]); + + async function resolve(reviewId: string, companyId: number, companyName: string): Promise { + const key = `${reviewId}:${companyId}`; + setResolving(key); + try { + const res = await fetch(`/api/pax8/company-matches/${reviewId}/resolve`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ companyId }), + }); + 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 ${companyName}`); + setReviews((prev) => prev?.filter((r) => r.id !== reviewId) ?? null); + setReviewTotal((t) => Math.max(0, t - 1)); + setSelectedManualCompany((prev) => { + const next = { ...prev }; + delete next[reviewId]; + return next; + }); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Resolve failed'); + } finally { + setResolving(null); + } + } + // Fetch-then-open: keeps the loading indicator on the DataTable (the row's // parent) rather than inside an already-open, empty DetailModal. const handleRowClick = async (row: Pax8CompanyListItem) => { @@ -169,7 +290,14 @@ export default function Pax8Page() { > Companies - Needs Review + + Needs Review + {reviewTotal > 0 && ( + + {reviewTotal} + + )} + @@ -190,8 +318,174 @@ export default function Pax8Page() { - {/* Needs Review tab implemented in Plan 14-05 */} -

Loading…

+ {reviewError && ( + + Couldn't load PAX8 data + + Check your connection and try again, or visit /admin/integrations if PAX8 + sync is disabled. + + + )} + + {reviews === null && !reviewError && ( +
+ + + +
+ )} + + {reviews !== null && reviews.length === 0 && !reviewError && ( + + + No companies need review + + Every synced PAX8 company is matched to an Autotask company. + + + )} + + {reviews?.map((review) => ( + + + + + {review.pax8CompanyName} + + + + {review.candidates.length > 0 ? ( +
+ {review.candidates.map((candidate) => { + const key = `${review.id}:${candidate.companyId}`; + const isResolving = resolving === key; + const confidence = formatConfidence(candidate.confidence); + return ( +
+
+
+ {candidate.companyName ?? `Company ${candidate.companyId}`} +
+ {confidence && ( + + {confidence} + + )} +
+ +
+ ); + })} +
+ ) : ( + + No suggested matches + + Search manually to link this company to its Autotask counterpart. + + + )} + +
+ { + setOpenPopoverId(open ? review.id : null); + if (open) void ensureCompaniesLoaded(); + }} + > + + + + + + + + + {companiesLoading ? 'Loading companies…' : 'No company found.'} + + + {(allCompanies ?? []).map((company) => ( + { + setSelectedManualCompany((prev) => ({ + ...prev, + [review.id]: company, + })); + setOpenPopoverId(null); + }} + > + + {company.company_name} + + ))} + + + + + + +
+
+
+ ))}