- 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
502 lines
18 KiB
TypeScript
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'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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|