diff --git a/.planning/phases/14-pax8-ui-surface/14-04-SUMMARY.md b/.planning/phases/14-pax8-ui-surface/14-04-SUMMARY.md new file mode 100644 index 0000000..dd5a197 --- /dev/null +++ b/.planning/phases/14-pax8-ui-surface/14-04-SUMMARY.md @@ -0,0 +1,121 @@ +--- +phase: 14-pax8-ui-surface +plan: 04 +subsystem: frontend +tags: [next.js, react, pax8, datatable, detailmodal, navigation] + +# Dependency graph +requires: + - phase: 14-pax8-ui-surface + plan: 01 + provides: "GET /api/pax8/companies, GET /api/pax8/companies/[id]" + - phase: 14-pax8-ui-surface + plan: 03 + provides: "DetailModal kind='pax8_company' support + subscriptions cost-breakdown table" +provides: + - "app/pax8/page.tsx — top-level /pax8 page: PageHeader + Companies/Needs Review Tabs shell, Companies tab fully wired (DataTable + drill-down modal)" + - "Top-level PAX8 nav entry visible to all authenticated users" +affects: ["14-05 (fills the Needs Review TabsContent placeholder)", "14-06 (manual verification of the full page)"] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Fetch-then-open drill-down: row click fetches /api/pax8/companies/[id] before setting selectedCompany and opening DetailModal, keeping the loading state on the DataTable rather than an empty dialog" + - "DataTable column-key -> API sort-value map (SORT_KEY_MAP) for columns whose key doesn't match the API's whitelisted sort param 1:1 (matched -> match, activeSubscriptionCount -> subscriptions, location -> city)" + +key-files: + created: + - app/pax8/page.tsx + modified: + - components/navigation/app-navigation.tsx + +key-decisions: + - "Companies tab uses page size 25 (1-based page state converted to offset = (page-1)*pageSize for the API's limit/offset contract)" + - "The 'location' column combines city, stateOrProvince, country into one display string but maps to the API's 'city' sort key (no combined-location sort exists server-side)" + - "Needs Review TabsContent is a single-line muted placeholder with an explicit '/* Needs Review tab implemented in Plan 14-05 */' comment, per the plan's interface-first hand-off" + - "Nav icon: ShoppingCart (not previously used at the top level) — added to the existing lucide-react import in app-navigation.tsx" + +requirements-completed: [PAX8-13] + +# Metrics +duration: ~20min +completed: 2026-07-11 +--- + +# Phase 14 Plan 04: /pax8 Page Shell + Companies Tab + Nav Entry Summary + +**New top-level `/pax8` page: a PageHeader + Companies/Needs Review Tabs shell with the Companies tab fully wired to a sortable/searchable/paginated DataTable of PAX8 companies whose row-click fetches the subscriptions/cost-breakdown drill-down and opens the extended DetailModal; plus a top-level PAX8 nav entry visible to every authenticated user.** + +## Performance + +- **Duration:** ~20 min +- **Tasks:** 2 completed +- **Files modified:** 2 (1 new, 1 modified) + +## Accomplishments + +- `app/pax8/page.tsx` — a `'use client'` page rendering `PageHeader` (title "PAX8", `breadcrumbs=[{label:'PAX8'}]`, `accent`) followed by a `container mx-auto px-6 py-6 space-y-6` wrapper holding a `Tabs` (`companies` / `needs-review`). +- Companies tab: `fetchCompanies(page, search?, sort?, order?)` converts the DataTable's 1-based `page` to the API's `offset = (page-1)*pageSize` (page size 25), maps DataTable column keys to the API's whitelisted `sort` values via `SORT_KEY_MAP`, and renders a `DataTable` with 4 columns — `name` (sortable), `matched` (matched Autotask company name as a primary-colored span, or a secondary `Badge` reading "Unmatched"), `activeSubscriptionCount` (labeled "Subs", `font-mono tabular-nums`), and `location` (city/state/country joined, em-dash fallback). +- Row click (`handleRowClick`) fetches `/api/pax8/companies/${id}` first, then sets `selectedCompany` to `{ ...company, subscriptions, costTotal }` and opens `DetailModal` with `kind="pax8_company"` — fetch-then-open, keeping the loading indicator on the DataTable rather than an empty dialog. +- Empty-state copy matches UI-SPEC exactly: "No PAX8 companies synced yet" / "Run the PAX8 sync from /admin/integrations, then refresh this page." +- Needs Review `TabsContent` is a clearly marked placeholder (`{/* Needs Review tab implemented in Plan 14-05 */}` + a muted "Loading…" line) — not blank, not implemented, ready for Plan 05. +- `components/navigation/app-navigation.tsx` — added one top-level `navigationItems` entry (`title: 'PAX8'`, `href: '/pax8'`, `icon: ShoppingCart`, description) placed right after "Configuration Items". Not added to the `Engagement`/`Admin` super-admin `visibleItems` gate, so it's visible to every authenticated user on both the desktop `NavigationMenu` and mobile `Sheet` (both consume the same array) — satisfying D-07. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: /pax8 page shell + Companies tab (DataTable + DetailModal drill-down)** - `51470f8` (feat) +2. **Task 2: Add top-level PAX8 nav entry** - `543ac39` (feat) + +## Files Created/Modified + +- `app/pax8/page.tsx` — new top-level page: tab shell + Companies tab (DataTable + fetch-then-open DetailModal drill-down) + Needs Review placeholder +- `components/navigation/app-navigation.tsx` — added top-level PAX8 nav entry + `ShoppingCart` import + +## Decisions Made + +- Page size fixed at 25 for the Companies tab (no page-size selector this phase). +- `location` column is a display-only composite of `city`/`stateOrProvince`/`country`; its sort maps to the API's `city` sort key since no combined-location server-side sort exists. +- Chose `ShoppingCart` for the nav icon — not previously used at the top level of `navigationItems`. + +## Deviations from Plan + +None — plan executed exactly as written. Both tasks matched their `` and `` blocks without needing any Rule 1-4 deviation. + +## Verification + +- `npx tsc --noEmit --pretty` — passes (run after each task). +- Grep-confirmed: `value="companies"` and `value="needs-review"` both present; `kind="pax8_company"` present; `{/* Needs Review tab implemented in Plan 14-05 */}` present verbatim; no `swr`/`react-query` imports; `/api/pax8/companies` and `/api/pax8/companies/${row.id}` both fetched; `'/pax8'` present in `app-navigation.tsx`; `visibleItems` filter block unchanged (still only special-cases `'Engagement'`/`'Admin'`). +- Manual verification (page loads, row opens modal, nav item visible) is deferred to Plan 06 per this plan's own `` block. + +## Threat Flags + +None — this plan's threat model (T-14-03 info disclosure via `requireAuth()`-gated fetches, T-14-06 XSS via React auto-escaping, T-14-08 nav visibility accepted by design, T-14-SC zero new packages) is fully addressed by construction. No new network endpoints, auth paths, or schema changes were introduced — this plan only consumes existing Plan 01 routes and Plan 03's DetailModal extension. + +## Known Stubs + +- **Needs Review tab** (`app/pax8/page.tsx`, `TabsContent value="needs-review"`): renders a static muted "Loading…" line with no data source wired. This is the plan's intended interface-first hand-off — Plan 14-05 replaces this block with the full review-card UI. Not a defect; explicitly scoped out of this plan's ``. + +## User Setup Required + +None — no external service configuration required. + +## Next Phase Readiness + +- Plan 14-05 can now implement the Needs Review `TabsContent` in `app/pax8/page.tsx` without touching the Companies tab or the tab shell. +- Plan 14-06 (manual verification) can load `/pax8`, confirm the Companies tab lists real companies, click a row to see the cost-breakdown modal, and confirm the PAX8 nav item appears for an authenticated user. + +--- +*Phase: 14-pax8-ui-surface* +*Completed: 2026-07-11* + +## Self-Check: PASSED + +- FOUND: app/pax8/page.tsx +- FOUND: components/navigation/app-navigation.tsx +- FOUND: .planning/phases/14-pax8-ui-surface/14-04-SUMMARY.md +- FOUND commit: 51470f8 +- FOUND commit: 543ac39 +- FOUND commit: 9b6faa9 diff --git a/app/pax8/page.tsx b/app/pax8/page.tsx new file mode 100644 index 0000000..48f286f --- /dev/null +++ b/app/pax8/page.tsx @@ -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 = { + 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([]); + const [totalCount, setTotalCount] = useState(0); + const [page, setPage] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [sortColumn, setSortColumn] = useState(undefined); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc' | undefined>(undefined); + const [searchQuery, setSearchQuery] = useState(undefined); + + const [selectedCompany, setSelectedCompany] = useState | 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[] = [ + { + key: 'name', + label: 'Name', + sortable: true, + render: (value: string) => {value}, + }, + { + key: 'matched', + label: 'Matched Company', + sortable: true, + render: (_value, row) => + row.matchedCompanyName ? ( + {row.matchedCompanyName} + ) : ( + Unmatched + ), + }, + { + key: 'activeSubscriptionCount', + label: 'Subs', + sortable: true, + render: (value: number) => {value ?? 0}, + }, + { + 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(', ') + ) : ( + + ); + }, + }, + ]; + + return ( + <> + +
+ setActiveTab(v as 'companies' | 'needs-review')} + className="space-y-6" + > + + Companies + Needs Review + + + + + + + + {/* Needs Review tab implemented in Plan 14-05 */} +

Loading…

+
+
+
+ + + + ); +} diff --git a/components/navigation/app-navigation.tsx b/components/navigation/app-navigation.tsx index 9c5c7df..abef807 100644 --- a/components/navigation/app-navigation.tsx +++ b/components/navigation/app-navigation.tsx @@ -18,6 +18,7 @@ import { Search, AlertTriangle, ChevronDown, + ShoppingCart, } from 'lucide-react'; import { NavigationMenu, @@ -60,6 +61,12 @@ const navigationItems: NavItem[] = [ icon: Server, description: 'Manage IT assets and devices' }, + { + title: 'PAX8', + href: '/pax8', + icon: ShoppingCart, + description: 'PAX8 companies, subscriptions, and cost breakdown' + }, { title: 'Backup Status', icon: HardDrive,