diff --git a/app/pax8/page.tsx b/app/pax8/page.tsx index 48f286f..e4d202a 100644 --- a/app/pax8/page.tsx +++ b/app/pax8/page.tsx @@ -1,9 +1,15 @@ '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 { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; import DataTable, { type Column } from '@/components/admin/DataTable'; import DetailModal from '@/components/admin/DetailModal'; @@ -21,6 +27,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 +78,17 @@ 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 fetchCompanies = async ( currentPage: number, search?: string, @@ -94,6 +137,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 +279,14 @@ export default function Pax8Page() { > Companies - Needs Review + + Needs Review + {reviewTotal > 0 && ( + + {reviewTotal} + + )} + @@ -190,8 +307,101 @@ 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. + + + )} + + {/* Manual company-search combobox implemented in Task 3 */} +
+
+ ))}