chore: merge executor worktree (worktree-agent-a91f4d8cb6bd9f301)
This commit is contained in:
commit
30d8c9d46f
3 changed files with 336 additions and 0 deletions
121
.planning/phases/14-pax8-ui-surface/14-04-SUMMARY.md
Normal file
121
.planning/phases/14-pax8-ui-surface/14-04-SUMMARY.md
Normal file
|
|
@ -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 `<action>` and `<acceptance_criteria>` 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 `<verification>` 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 `<objective>`.
|
||||
|
||||
## 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
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue