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:
parent
8e1114aeec
commit
51470f8892
1 changed files with 208 additions and 0 deletions
208
app/pax8/page.tsx
Normal file
208
app/pax8/page.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue