feat(14-05): Needs Review tab review cards, candidate resolve, count badge

- Amber-bordered cards (border-amber-200) list unresolved PAX8 company
  match reviews, fetched from /api/pax8/company-matches on first tab
  activation
- Each candidate row offers a "Link company" button that POSTs to
  .../[id]/resolve; on success the card is optimistically removed,
  toast.success fires, and reviewTotal decrements
- Needs Review TabsTrigger shows a count badge when reviewTotal > 0
- Error/loading/empty states mirror device-link-conflicts' Alert/
  Skeleton/empty-state trio per UI-SPEC copy
- Zero-candidate reviews render the D-09 "No suggested matches" empty
  state; manual-search combobox insertion point left for Task 3
This commit is contained in:
lorentz 2026-07-11 14:43:05 -04:00
parent 564be52b97
commit 40efa1d3b6

View file

@ -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<string, string> = {
@ -46,6 +78,17 @@ export default function Pax8Page() {
const [selectedCompany, setSelectedCompany] = useState<Record<string, any> | null>(null);
const [modalOpen, setModalOpen] = useState(false);
// Needs Review tab state
const [reviews, setReviews] = useState<CompanyReview[] | null>(null);
const [reviewTotal, setReviewTotal] = useState(0);
const [reviewError, setReviewError] = useState<string | null>(null);
const [resolving, setResolving] = useState<string | null>(null);
const [allCompanies, setAllCompanies] = useState<ManualCompanyOption[] | null>(null);
const [companiesLoading, setCompaniesLoading] = useState(false);
const [selectedManualCompany, setSelectedManualCompany] = useState<
Record<string, ManualCompanyOption>
>({});
const fetchCompanies = async (
currentPage: number,
search?: string,
@ -94,6 +137,73 @@ export default function Pax8Page() {
fetchCompanies(nextPage, searchQuery, sortColumn, sortOrder);
};
async function loadReviews(): Promise<void> {
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<void> {
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<void> {
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() {
>
<TabsList>
<TabsTrigger value="companies">Companies</TabsTrigger>
<TabsTrigger value="needs-review">Needs Review</TabsTrigger>
<TabsTrigger value="needs-review" className="gap-1.5">
Needs Review
{reviewTotal > 0 && (
<span className="ml-1 rounded-full bg-blue-500/20 text-blue-600 text-xs px-1.5 py-0.5 font-medium">
{reviewTotal}
</span>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="companies" className="space-y-4">
@ -190,8 +307,101 @@ export default function Pax8Page() {
</TabsContent>
<TabsContent value="needs-review" className="space-y-4">
{/* Needs Review tab implemented in Plan 14-05 */}
<p className="text-sm text-muted-foreground">Loading</p>
{reviewError && (
<Alert variant="destructive">
<AlertTitle>Couldn&apos;t load PAX8 data</AlertTitle>
<AlertDescription>
Check your connection and try again, or visit /admin/integrations if PAX8
sync is disabled.
</AlertDescription>
</Alert>
)}
{reviews === null && !reviewError && (
<div className="space-y-2">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
)}
{reviews !== null && reviews.length === 0 && !reviewError && (
<Alert>
<CheckCircle2 className="size-4" />
<AlertTitle>No companies need review</AlertTitle>
<AlertDescription>
Every synced PAX8 company is matched to an Autotask company.
</AlertDescription>
</Alert>
)}
{reviews?.map((review) => (
<Card key={review.id} className="border-amber-200">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base font-medium">
<AlertTriangle className="size-5 text-amber-500" />
{review.pax8CompanyName}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{review.candidates.length > 0 ? (
<div className="space-y-2">
{review.candidates.map((candidate) => {
const key = `${review.id}:${candidate.companyId}`;
const isResolving = resolving === key;
const confidence = formatConfidence(candidate.confidence);
return (
<div
key={candidate.companyId}
className="flex items-center justify-between gap-3 rounded-md border p-3"
>
<div className="space-y-0.5 min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{candidate.companyName ?? `Company ${candidate.companyId}`}
</div>
{confidence && (
<Badge variant="outline" className="text-xs font-mono">
{confidence}
</Badge>
)}
</div>
<Button
size="sm"
disabled={isResolving}
onClick={() =>
void resolve(
review.id,
candidate.companyId,
candidate.companyName ?? `Company ${candidate.companyId}`
)
}
>
{isResolving ? (
<>
<Loader2 className="size-3 animate-spin mr-1" />
Linking
</>
) : (
'Link company'
)}
</Button>
</div>
);
})}
</div>
) : (
<Alert>
<AlertTitle>No suggested matches</AlertTitle>
<AlertDescription>
Search manually to link this company to its Autotask counterpart.
</AlertDescription>
</Alert>
)}
{/* Manual company-search combobox implemented in Task 3 */}
</CardContent>
</Card>
))}
</TabsContent>
</Tabs>
</div>