wulf-pulse/app/pax8/page.tsx
lorentz 13272f9a6f feat(14-05): manual company-search combobox fallback (D-05/D-09)
- Every review card gets a Command/Popover combobox fed by
  /api/data/companies-list, fetched once via ensureCompaniesLoaded()
  and shared across cards
- Selecting a company enables a "Link to selected company" button that
  calls the same resolve() handler as the candidate buttons
- Zero-candidate reviews (D-09) show only the manual picker; reviews
  with candidates show both candidate buttons and the manual picker
2026-07-11 14:43:56 -04:00

502 lines
18 KiB
TypeScript

'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;
name: string;
status: string | null;
city: string | null;
stateOrProvince: string | null;
country: string | null;
autotaskCompanyId: number | null;
matchConfidence: number | null;
matchMethod: string | null;
matchedCompanyName: string | null;
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> = {
name: 'name',
matched: 'match',
activeSubscriptionCount: 'subscriptions',
location: 'city',
};
const PAGE_SIZE = 25;
export default function Pax8Page() {
const [activeTab, setActiveTab] = useState<'companies' | 'needs-review'>('companies');
const [companies, setCompanies] = useState<Pax8CompanyListItem[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [sortColumn, setSortColumn] = useState<string | undefined>(undefined);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc' | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState<string | undefined>(undefined);
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 [openPopoverId, setOpenPopoverId] = useState<string | null>(null);
const fetchCompanies = async (
currentPage: number,
search?: string,
sortBy?: string,
order?: 'asc' | 'desc'
) => {
setIsLoading(true);
try {
const offset = (currentPage - 1) * PAGE_SIZE;
const params = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String(offset) });
if (search) params.append('search', search);
if (sortBy) params.append('sort', SORT_KEY_MAP[sortBy] ?? sortBy);
if (order) params.append('order', order);
const res = await fetch(`/api/pax8/companies?${params}`);
const data = await res.json();
setCompanies(data.items ?? []);
setTotalCount(data.total ?? 0);
} catch (err) {
console.error('Failed to fetch PAX8 companies:', err);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchCompanies(page, searchQuery, sortColumn, sortOrder);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]);
const handleSort = (column: string, direction: 'asc' | 'desc') => {
setSortColumn(column);
setSortOrder(direction);
setPage(1);
fetchCompanies(1, searchQuery, column, direction);
};
const handleSearch = (query: string) => {
setSearchQuery(query);
setPage(1);
fetchCompanies(1, query, sortColumn, sortOrder);
};
const handlePageChange = (nextPage: number) => {
setPage(nextPage);
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) => {
setIsLoading(true);
try {
const res = await fetch(`/api/pax8/companies/${row.id}`);
const data = await res.json();
setSelectedCompany({
...data.company,
subscriptions: data.subscriptions,
costTotal: data.costTotal,
});
setModalOpen(true);
} catch (err) {
console.error('Failed to fetch PAX8 company detail:', err);
} finally {
setIsLoading(false);
}
};
const columns: Column<Pax8CompanyListItem>[] = [
{
key: 'name',
label: 'Name',
sortable: true,
render: (value: string) => <span className="font-medium">{value}</span>,
},
{
key: 'matched',
label: 'Matched Company',
sortable: true,
render: (_value, row) =>
row.matchedCompanyName ? (
<span className="text-primary hover:underline">{row.matchedCompanyName}</span>
) : (
<Badge variant="secondary">Unmatched</Badge>
),
},
{
key: 'activeSubscriptionCount',
label: 'Subs',
sortable: true,
render: (value: number) => <span className="font-mono tabular-nums">{value ?? 0}</span>,
},
{
key: 'location',
label: 'Location',
sortable: true,
render: (_value, row) => {
const parts = [row.city, row.stateOrProvince, row.country].filter(Boolean);
return parts.length > 0 ? (
parts.join(', ')
) : (
<span className="text-muted-foreground"></span>
);
},
},
];
return (
<>
<PageHeader
title="PAX8"
description="PAX8 companies, subscriptions, and cost breakdown"
breadcrumbs={[{ label: 'PAX8' }]}
accent
/>
<div className="container mx-auto px-6 py-6 space-y-6">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'companies' | 'needs-review')}
className="space-y-6"
>
<TabsList>
<TabsTrigger value="companies">Companies</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">
<DataTable
columns={columns}
data={companies}
totalCount={totalCount}
page={page}
pageSize={PAGE_SIZE}
onPageChange={handlePageChange}
onSort={handleSort}
onSearch={handleSearch}
onRowClick={handleRowClick}
isLoading={isLoading}
emptyTitle="No PAX8 companies synced yet"
emptyDescription="Run the PAX8 sync from /admin/integrations, then refresh this page."
/>
</TabsContent>
<TabsContent value="needs-review" className="space-y-4">
{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>
)}
<div className="flex items-center gap-2 pt-1">
<Popover
open={openPopoverId === review.id}
onOpenChange={(open) => {
setOpenPopoverId(open ? review.id : null);
if (open) void ensureCompaniesLoaded();
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="flex-1 justify-between font-normal"
>
{selectedManualCompany[review.id]?.company_name ?? 'Search company…'}
<ChevronsUpDown className="size-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command>
<CommandInput placeholder="Search company…" />
<CommandList>
<CommandEmpty>
{companiesLoading ? 'Loading companies…' : 'No company found.'}
</CommandEmpty>
<CommandGroup>
{(allCompanies ?? []).map((company) => (
<CommandItem
key={company.id}
value={company.company_name}
onSelect={() => {
setSelectedManualCompany((prev) => ({
...prev,
[review.id]: company,
}));
setOpenPopoverId(null);
}}
>
<Check
className={cn(
'size-4',
selectedManualCompany[review.id]?.id === company.id
? 'opacity-100'
: 'opacity-0'
)}
/>
{company.company_name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Button
disabled={
!selectedManualCompany[review.id] ||
resolving === `${review.id}:${selectedManualCompany[review.id]?.id}`
}
onClick={() => {
const selected = selectedManualCompany[review.id];
if (selected) void resolve(review.id, selected.id, selected.company_name);
}}
>
{resolving === `${review.id}:${selectedManualCompany[review.id]?.id}` ? (
<>
<Loader2 className="size-3 animate-spin mr-1" />
Linking
</>
) : (
'Link to selected company'
)}
</Button>
</div>
</CardContent>
</Card>
))}
</TabsContent>
</Tabs>
</div>
<DetailModal
open={modalOpen}
onOpenChange={setModalOpen}
kind="pax8_company"
title={`PAX8: ${selectedCompany?.name ?? ''}`}
data={selectedCompany}
/>
</>
);
}