chore: merge executor worktree (worktree-agent-a77f9b3a365cc0b6d)
This commit is contained in:
commit
8ce2d3fc9b
2 changed files with 147 additions and 3 deletions
70
.planning/phases/14-pax8-ui-surface/14-03-SUMMARY.md
Normal file
70
.planning/phases/14-pax8-ui-surface/14-03-SUMMARY.md
Normal file
|
|
@ -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 `<verification>` 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)
|
||||||
|
|
@ -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 ────────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function resolveLabel(key: string, value: any, type: FieldType | undefined, lookups: Lookups, tz: string): { display: React.ReactNode; isEmpty: boolean } {
|
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: <span className="text-sm">{String(value)}</span>, isEmpty: false };
|
return { display: <span className="text-sm">{String(value)}</span>, isEmpty: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectGroups(data: Record<string, any>): FieldGroup[] {
|
function detectGroups(data: Record<string, any>, kind?: 'ticket' | 'company' | 'pax8_company'): FieldGroup[] {
|
||||||
|
if (kind === 'pax8_company') return PAX8_COMPANY_GROUPS;
|
||||||
if ('ticket_number' in data) return TICKET_GROUPS;
|
if ('ticket_number' in data) return TICKET_GROUPS;
|
||||||
if ('company_name' in data) return COMPANY_GROUPS;
|
if ('company_name' in data) return COMPANY_GROUPS;
|
||||||
return [{ label: 'Fields', fields: Object.keys(data).map(k => ({ key: k, label: k })) }];
|
return [{ label: 'Fields', fields: Object.keys(data).map(k => ({ key: k, label: k })) }];
|
||||||
|
|
@ -269,9 +298,10 @@ interface DetailModalProps {
|
||||||
title: string;
|
title: string;
|
||||||
data: Record<string, any> | null;
|
data: Record<string, any> | null;
|
||||||
fields?: Array<{ key: string; label: string; render?: (value: any) => React.ReactNode }>;
|
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 tz = useUserTimezone();
|
||||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||||
const [lookups, setLookups] = useState<Lookups>(EMPTY_LOOKUPS);
|
const [lookups, setLookups] = useState<Lookups>(EMPTY_LOOKUPS);
|
||||||
|
|
@ -328,7 +358,7 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
||||||
setTimeout(() => setCopiedField(null), 2000);
|
setTimeout(() => setCopiedField(null), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const groups = detectGroups(data);
|
const groups = detectGroups(data, kind);
|
||||||
|
|
||||||
// Raw tab: all fields
|
// Raw tab: all fields
|
||||||
const rawFields = fields || Object.keys(data).map(k => ({ key: k, label: k, render: undefined }));
|
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 ── */}
|
{/* ── Formatted Tab ── */}
|
||||||
<TabsContent value="formatted" className="flex-1 overflow-y-auto px-6 py-4 mt-0">
|
<TabsContent value="formatted" className="flex-1 overflow-y-auto px-6 py-4 mt-0">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Subscriptions & cost-breakdown — renders above field groups (UI-SPEC: focal point) */}
|
||||||
|
{Array.isArray(data.subscriptions) && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">Subscriptions & Cost</h3>
|
||||||
|
<div className="rounded-lg border overflow-hidden">
|
||||||
|
{data.subscriptions.length === 0 ? (
|
||||||
|
<div className="px-4 py-3 text-sm text-muted-foreground italic">No subscriptions</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-[1fr_80px_140px_120px] gap-2 px-4 py-2 bg-muted/40 text-xs font-medium text-muted-foreground border-b">
|
||||||
|
<div>Product</div>
|
||||||
|
<div className="text-right">Qty</div>
|
||||||
|
<div>Billing Term</div>
|
||||||
|
<div className="text-right">Amount</div>
|
||||||
|
</div>
|
||||||
|
{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 (
|
||||||
|
<div key={sub.subscriptionId ?? idx}>
|
||||||
|
{idx > 0 && <Separator />}
|
||||||
|
<div className="grid grid-cols-[1fr_80px_140px_120px] gap-2 px-4 py-2.5 items-center text-sm">
|
||||||
|
<div className="min-w-0 truncate">{productLabel}</div>
|
||||||
|
<div className="text-right font-mono tabular-nums">{sub.quantity ?? '—'}</div>
|
||||||
|
<div className="text-muted-foreground">{sub.billingTerm ?? '—'}</div>
|
||||||
|
<div className="text-right font-mono tabular-nums">{currency} {amount.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Separator />
|
||||||
|
<div className="grid grid-cols-[1fr_80px_140px_120px] gap-2 px-4 py-2.5 items-center text-sm font-semibold bg-muted/20">
|
||||||
|
<div className="col-span-3">Total</div>
|
||||||
|
<div className="text-right font-mono tabular-nums">
|
||||||
|
{(data.subscriptions[0]?.currency ?? 'USD')} {data.subscriptions.reduce((s: number, sub: any) => s + Number(sub.latestBilledAmount ?? 0), 0).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const rendered = new Set<string>();
|
const rendered = new Set<string>();
|
||||||
return groups.map((group) => {
|
return groups.map((group) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue