feat(14-04): /pax8 page shell + Companies tab (DataTable + DetailModal drill-down)

- New app/pax8/page.tsx client page with PageHeader + Companies/Needs Review Tabs shell
- Companies tab: DataTable of PAX8 companies (name, matched Autotask company or Unmatched badge, active subscription count, city/state/country) with sort/search/pagination against /api/pax8/companies
- Row click fetch-then-opens the extended DetailModal (kind="pax8_company") with subscriptions + cost breakdown from /api/pax8/companies/[id]
- Needs Review tab left as a marked placeholder for Plan 14-05
This commit is contained in:
lorentz 2026-07-11 14:36:31 -04:00
parent 8e1114aeec
commit 51470f8892

208
app/pax8/page.tsx Normal file
View file

@ -0,0 +1,208 @@
'use client';
import { useEffect, useState } from 'react';
import { PageHeader } from '@/components/navigation/page-header';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import DataTable, { type Column } from '@/components/admin/DataTable';
import DetailModal from '@/components/admin/DetailModal';
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;
}
// 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);
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);
};
// 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">Needs Review</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">
{/* Needs Review tab implemented in Plan 14-05 */}
<p className="text-sm text-muted-foreground">Loading</p>
</TabsContent>
</Tabs>
</div>
<DetailModal
open={modalOpen}
onOpenChange={setModalOpen}
kind="pax8_company"
title={`PAX8: ${selectedCompany?.name ?? ''}`}
data={selectedCompany}
/>
</>
);
}