diff --git a/.planning/phases/14-pax8-ui-surface/14-03-SUMMARY.md b/.planning/phases/14-pax8-ui-surface/14-03-SUMMARY.md new file mode 100644 index 0000000..1192e62 --- /dev/null +++ b/.planning/phases/14-pax8-ui-surface/14-03-SUMMARY.md @@ -0,0 +1,70 @@ +--- +phase: 14-pax8-ui-surface +plan: 03 +subsystem: shared-ui +tags: [detail-modal, pax8, cost-breakdown] +dependency-graph: + requires: [] + provides: + - "DetailModal kind='pax8_company' support" + - "DetailModal subscriptions cost-breakdown table" + affects: + - "Phase 14 Plan 04 (/pax8 page — will call DetailModal with kind='pax8_company')" +tech-stack: + added: [] + patterns: + - "Additive-extension pattern: new prop + new constant + new detection branch, all guarded so existing callers are byte-for-byte unaffected" +key-files: + created: [] + modified: + - components/admin/DetailModal.tsx +decisions: + - "website field placed in PAX8_COMPANY_GROUPS' Identity group (not System) per plan task action text's explicit final clause, even though the UI-SPEC's shorthand list omitted it" + - "Identity/System groups paired side-by-side (paired: 'Identity'/'System') rather than stacked — consistent with COMPANY_GROUPS' existing Address/System pairing convention" + - "Total row uses the first subscription's currency as the display currency (single-currency assumption, consistent with a single-company drill-down)" +metrics: + duration: "~15 minutes" + completed: 2026-07-11 +--- + +# Phase 14 Plan 03: DetailModal PAX8 Extension Summary + +Extended `components/admin/DetailModal.tsx` additively with a `kind='pax8_company'` field-group set and an unconditional subscriptions/cost-breakdown table, so the upcoming `/pax8` drill-down (Plan 04) renders formatted PAX8 identity fields and a cost table instead of falling into the flat unstyled key/value fallback. + +## What Was Built + +**Task 1 — `kind` prop + `PAX8_COMPANY_GROUPS`:** +- Added `kind?: 'ticket' | 'company' | 'pax8_company'` to `DetailModalProps`, destructured in the component signature. +- Added `PAX8_COMPANY_GROUPS: FieldGroup[]` next to `COMPANY_GROUPS` — an `Identity` group (`name`, `status`, `city`, `stateOrProvince`, `country`, `website` as `url`) paired with a `System` group (`id` as `id`, `syncedAt` as `date`, `isDeleted` as `bool`), using camelCase keys since the `/pax8` page passes an already-transformed object. +- `detectGroups(data, kind?)` now checks `kind === 'pax8_company'` as the first branch, before the existing `'ticket_number' in data` / `'company_name' in data` sniffs, which remain the fallback when `kind` is absent. +- The single call site updated to `detectGroups(data, kind)`. +- Header else-branch (title + Record ID + optional `is_active` badge) left untouched — it already renders correctly for a pax8 company object (no `is_active` field means no badge). + +**Task 2 — Subscriptions & cost-breakdown table:** +- New section in the Formatted tab, guarded by `Array.isArray(data.subscriptions)`, rendered as the first child of the `space-y-6` container (above the field-group map), per UI-SPEC's Visual Hierarchy (cost table is the primary focal point). +- Header row (Product / Qty / Billing Term / Amount), one row per subscription with product label fallback chain `sub.productName || sub.sku || 'Unknown item'` (Pitfall 4), amount rendered directly from `sub.latestBilledAmount` (never recomputed from `unit_price × quantity` — Pitfall 3) in `font-mono tabular-nums`. +- Summed total row across all subscriptions, also `font-mono tabular-nums`. +- Empty array (`data.subscriptions.length === 0`) renders a muted "No subscriptions" line inside the same bordered container instead of an empty table or crash. +- Styled with the same `rounded-lg border overflow-hidden` card look and `text-xs`/`text-muted-foreground` label conventions already used elsewhere in the file (copied shape from the ticket "Description block" section). +- Raw tab required no change — `data.subscriptions` already serializes as JSON through the existing object branch of `renderRaw`. + +## Deviations from Plan + +None — plan executed exactly as written. The plan's Task 1 action text contained one internally contradictory sentence about where `website` should live; resolved by following its explicit final clause ("in the Identity group"), which is what was implemented. + +## Verification + +- `npx tsc --noEmit --pretty` — passes (run after each task and again at the end of the plan). +- Grep-confirmed: `TICKET_GROUPS`, `COMPANY_GROUPS`, `'ticket_number' in data`, `'company_name' in data` all present verbatim and unchanged. +- Grep-confirmed: `kind === 'pax8_company'`, `Array.isArray(data.subscriptions)`, `tabular-nums`, `productName || sub.sku || 'Unknown item'`, `No subscriptions` all present. +- Manual verification of the rendered drill-down (open a PAX8 company from `/pax8` and confirm cost table + identity fields) is deferred to Plan 06 per the plan's own `` block — Plan 04 (which builds the `/pax8` page and its `DetailModal` call site) has not yet executed in this wave. + +## Threat Flags + +None — this plan's threat model (T-14-06 stored XSS, T-14-07 DoS, T-14-SC package tampering) is fully addressed by construction: all rendered values are JSX text children (auto-escaped, no `dangerouslySetInnerHTML`), the subscriptions array size is bounded per company (tens of rows, not the full order-item history), and zero new packages were introduced. + +## Self-Check: PASSED + +- FOUND: components/admin/DetailModal.tsx (modified, both tasks present) +- FOUND: commit 3492a16 (Task 1 — kind prop + PAX8_COMPANY_GROUPS) +- FOUND: commit 04c75be (Task 2 — subscriptions cost-breakdown table) diff --git a/components/admin/DetailModal.tsx b/components/admin/DetailModal.tsx index ec3a476..0ae2d66 100644 --- a/components/admin/DetailModal.tsx +++ b/components/admin/DetailModal.tsx @@ -131,6 +131,34 @@ const COMPANY_GROUPS: FieldGroup[] = [ }, ]; +// PAX8 company drill-down (kind="pax8_company") — additive only, does not +// touch TICKET_GROUPS/COMPANY_GROUPS or their existing detection branches. +// Field keys are camelCase because the /pax8 page passes an already- +// transformed object (see 14-03-PLAN.md interfaces block). +const PAX8_COMPANY_GROUPS: FieldGroup[] = [ + { + label: 'Identity', + paired: 'System', + fields: [ + { key: 'name', label: 'Name' }, + { key: 'status', label: 'Status' }, + { key: 'city', label: 'City' }, + { key: 'stateOrProvince', label: 'State/Province' }, + { key: 'country', label: 'Country' }, + { key: 'website', label: 'Website', type: 'url' }, + ], + }, + { + label: 'System', + paired: 'Identity', + fields: [ + { key: 'id', label: 'Record ID', type: 'id' }, + { key: 'syncedAt', label: 'Synced At', type: 'date' }, + { key: 'isDeleted', label: 'Deleted', type: 'bool' }, + ], + }, +]; + // ── Helpers ──────────────────────────────────────────────────────────────────── function resolveLabel(key: string, value: any, type: FieldType | undefined, lookups: Lookups, tz: string): { display: React.ReactNode; isEmpty: boolean } { @@ -253,7 +281,8 @@ function resolveLabel(key: string, value: any, type: FieldType | undefined, look return { display: {String(value)}, isEmpty: false }; } -function detectGroups(data: Record): FieldGroup[] { +function detectGroups(data: Record, kind?: 'ticket' | 'company' | 'pax8_company'): FieldGroup[] { + if (kind === 'pax8_company') return PAX8_COMPANY_GROUPS; if ('ticket_number' in data) return TICKET_GROUPS; if ('company_name' in data) return COMPANY_GROUPS; return [{ label: 'Fields', fields: Object.keys(data).map(k => ({ key: k, label: k })) }]; @@ -269,9 +298,10 @@ interface DetailModalProps { title: string; data: Record | null; fields?: Array<{ key: string; label: string; render?: (value: any) => React.ReactNode }>; + kind?: 'ticket' | 'company' | 'pax8_company'; } -export default function DetailModal({ open, onOpenChange, title, data, fields }: DetailModalProps) { +export default function DetailModal({ open, onOpenChange, title, data, fields, kind }: DetailModalProps) { const tz = useUserTimezone(); const [copiedField, setCopiedField] = useState(null); const [lookups, setLookups] = useState(EMPTY_LOOKUPS); @@ -328,7 +358,7 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }: setTimeout(() => setCopiedField(null), 2000); }; - const groups = detectGroups(data); + const groups = detectGroups(data, kind); // Raw tab: all fields const rawFields = fields || Object.keys(data).map(k => ({ key: k, label: k, render: undefined })); @@ -413,6 +443,50 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }: {/* ── Formatted Tab ── */}
+ {/* Subscriptions & cost-breakdown — renders above field groups (UI-SPEC: focal point) */} + {Array.isArray(data.subscriptions) && ( +
+

Subscriptions & Cost

+
+ {data.subscriptions.length === 0 ? ( +
No subscriptions
+ ) : ( + <> +
+
Product
+
Qty
+
Billing Term
+
Amount
+
+ {data.subscriptions.map((sub: any, idx: number) => { + const productLabel = sub.productName || sub.sku || 'Unknown item'; + const amount = Number(sub.latestBilledAmount ?? 0); + const currency = sub.currency ?? 'USD'; + return ( +
+ {idx > 0 && } +
+
{productLabel}
+
{sub.quantity ?? '—'}
+
{sub.billingTerm ?? '—'}
+
{currency} {amount.toFixed(2)}
+
+
+ ); + })} + +
+
Total
+
+ {(data.subscriptions[0]?.currency ?? 'USD')} {data.subscriptions.reduce((s: number, sub: any) => s + Number(sub.latestBilledAmount ?? 0), 0).toFixed(2)} +
+
+ + )} +
+
+ )} + {(() => { const rendered = new Set(); return groups.map((group) => {