chore: merge executor worktree (worktree-agent-a08db5af4833dc6c9)
This commit is contained in:
commit
3951298da2
3 changed files with 413 additions and 3 deletions
112
.planning/phases/14-pax8-ui-surface/14-05-SUMMARY.md
Normal file
112
.planning/phases/14-pax8-ui-surface/14-05-SUMMARY.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
---
|
||||
phase: 14-pax8-ui-surface
|
||||
plan: 05
|
||||
subsystem: ui
|
||||
tags: [next.js, react, shadcn, command, popover, sonner, requireAuth]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 14-pax8-ui-surface
|
||||
plan: 02
|
||||
provides: "GET /api/pax8/company-matches, POST /api/pax8/company-matches/[id]/resolve"
|
||||
- phase: 14-pax8-ui-surface
|
||||
plan: 04
|
||||
provides: "app/pax8/page.tsx page shell with Companies/Needs Review Tabs and the Needs Review placeholder"
|
||||
provides:
|
||||
- "app/pax8/page.tsx — Needs Review tab fully implemented: amber review cards, per-candidate resolve buttons, manual company-search combobox (D-05/D-09), count badge"
|
||||
- "app/api/data/companies-list/route.ts — hardened with requireAuth(), now safe as a data source for authenticated UI"
|
||||
affects: ["14-06 (manual verification of the Needs Review tab end-to-end)"]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Manual-search combobox: shadcn Command inside Popover, fetched once via a guarded ensureCompaniesLoaded() (checks allCompanies !== null / companiesLoading before fetching) and shared across every review card via per-review-id selection state (Record<reviewId, ManualCompanyOption>)"
|
||||
- "Reused the device-link-conflicts amber-card + Alert/Skeleton/empty-state trio + optimistic-removal resolve() pattern verbatim, extended with a second manual-resolution path calling the same resolve() handler"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- app/pax8/page.tsx
|
||||
- app/api/data/companies-list/route.ts
|
||||
|
||||
key-decisions:
|
||||
- "Fetch reviews + companies-list on first activation of the needs-review tab (useEffect keyed on activeTab, guarded by reviews === null / companiesLoading), not on page mount, to avoid an unnecessary request when a manager only visits the Companies tab"
|
||||
- "Confidence values (raw trigram similarity strings like \"0.850\") are formatted client-side as a rounded percentage (e.g. \"85%\") for the candidate Badge, since the plan left exact formatting to discretion and the numeric convention (font-mono) benefits from a human-readable percentage over a raw decimal string"
|
||||
- "Count badge on the Needs Review TabsTrigger reuses DetailModal's existing blue-500/20 count-badge style verbatim (per UI-SPEC's explicit callout), not the amber status hue or the --primary accent"
|
||||
|
||||
requirements-completed: [PAX8-14, PAX8-12]
|
||||
|
||||
# Metrics
|
||||
duration: ~25min
|
||||
completed: 2026-07-11
|
||||
---
|
||||
|
||||
# Phase 14 Plan 05: Needs Review Tab — Review Cards, Resolve, Manual Search Summary
|
||||
|
||||
**Filled in the `/pax8` Needs Review tab with amber-bordered review cards (candidate "Link company" buttons + a Command/Popover manual company-search fallback), wired to the admin-gated resolve route with optimistic removal and toasts, plus a live count badge; also added `requireAuth()` to `/api/data/companies-list` now that it backs this authenticated UI.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~25 min
|
||||
- **Tasks:** 3 completed
|
||||
- **Files modified:** 2
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `/api/data/companies-list` now calls `requireAuth()` before querying — closes the previously-unauthenticated gap RESEARCH.md flagged, response shape unchanged (`[{ id, company_name }]`).
|
||||
- Needs Review tab fetches `GET /api/pax8/company-matches?limit=100` on first tab activation and renders one `Card className="border-amber-200"` per unresolved review, with an `AlertTriangle` icon header showing the PAX8 company name.
|
||||
- Each stored candidate renders as a row (name + confidence `Badge`, formatted as a rounded percentage) with a "Link company" button that POSTs `{ companyId }` to `/api/pax8/company-matches/[id]/resolve`; on success the card is optimistically removed, `toast.success("Linked to {companyName}")` fires, and the count badge decrements; on failure `toast.error` fires with the server's message.
|
||||
- Zero-candidate reviews (D-09) render the "No suggested matches" empty state inline and show only the manual picker — no candidate buttons.
|
||||
- Every review card also gets a manual company-search combobox (shadcn `Command` inside `Popover`), backed by `/api/data/companies-list` fetched exactly once via a guarded `ensureCompaniesLoaded()` and shared across all cards. Selecting a company enables a "Link to selected company" button that calls the same `resolve()` handler used by candidate buttons — so a zero-candidate review resolves purely through manual search, and a review with candidates offers both paths.
|
||||
- The Needs Review `TabsTrigger` shows a count badge (mirroring `DetailModal`'s existing Time/Notes tab count-badge style) whenever `reviewTotal > 0`.
|
||||
- Error/loading/empty states reuse the exact `device-link-conflicts` `Alert`(destructive)/`Skeleton`-trio/`Alert`(empty, `CheckCircle2`) pattern with the UI-SPEC copy verbatim.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Harden /api/data/companies-list with requireAuth()** - `564be52` (feat)
|
||||
2. **Task 2: Needs Review tab — review cards + candidate resolve + count badge** - `40efa1d` (feat)
|
||||
3. **Task 3: Manual company-search combobox fallback (D-05/D-09)** - `13272f9` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `app/api/data/companies-list/route.ts` — added `requireAuth()` gate as the first statement of `GET`
|
||||
- `app/pax8/page.tsx` — replaced the Plan 04 Needs Review placeholder with the full review-card UI: fetch/state/resolve handlers, amber cards with candidate resolve rows, count badge, manual-search Command/Popover combobox, D-09 zero-candidate empty state
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Reviews + companies-list are fetched lazily on first Needs Review tab activation (not on page mount), keeping the Companies-tab-only visit free of an extra request — matches the plan's "(or on mount)" parenthetical loosely but favors the lazier of the two allowed options since it's strictly better for the common case.
|
||||
- Confidence formatting: raw `TEXT[]` trigram-similarity strings (e.g. `"0.850"`) are parsed and rendered as a rounded percentage badge (`"85%"`) rather than the raw decimal — the plan didn't mandate a specific format and this reads more naturally next to the "Link company" CTA.
|
||||
- Combobox implemented inline in the review-card `.map()` rather than extracted to a separate component — matches the codebase's low-abstraction convention (CLAUDE.md: "keep functions focused... avoid unnecessary abstraction") and the plan's own phrasing ("In each card render a Popover...").
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written. All three tasks matched their `<action>` and `<acceptance_criteria>` blocks without needing any Rule 1-4 deviation.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- The Needs Review tab is fully wired: fetch, amber cards, candidate resolve, manual-search fallback, D-09 empty state, count badge, and the auth-hardened `companies-list` data source it depends on.
|
||||
- Ready for Plan 06's manual verification: resolve an ambiguous review via a candidate button and a zero-candidate review via manual search; confirm cards vanish, toasts fire, and the count badge decrements.
|
||||
- No blockers for Plan 06.
|
||||
|
||||
---
|
||||
*Phase: 14-pax8-ui-surface*
|
||||
*Completed: 2026-07-11*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: app/pax8/page.tsx
|
||||
- FOUND: app/api/data/companies-list/route.ts
|
||||
- FOUND commit: 564be52
|
||||
- FOUND commit: 40efa1d
|
||||
- FOUND commit: 13272f9
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET() {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const result = await postgresClient.query(
|
||||
`SELECT id, company_name FROM companies WHERE is_active = true AND is_deleted = false ORDER BY company_name ASC`
|
||||
|
|
|
|||
|
|
@ -1,11 +1,27 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { AlertTriangle, Check, CheckCircle2, ChevronsUpDown, Loader2 } from 'lucide-react';
|
||||
import DataTable, { type Column } from '@/components/admin/DataTable';
|
||||
import DetailModal from '@/components/admin/DetailModal';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Pax8CompanyListItem {
|
||||
id: string;
|
||||
|
|
@ -21,6 +37,32 @@ interface Pax8CompanyListItem {
|
|||
activeSubscriptionCount: number;
|
||||
}
|
||||
|
||||
interface ReviewCandidate {
|
||||
companyId: number;
|
||||
companyName: string | null;
|
||||
confidence: string | null;
|
||||
}
|
||||
|
||||
interface CompanyReview {
|
||||
id: string;
|
||||
detectedAt: string;
|
||||
pax8CompanyId: string;
|
||||
pax8CompanyName: string;
|
||||
candidates: ReviewCandidate[];
|
||||
}
|
||||
|
||||
interface ManualCompanyOption {
|
||||
id: number;
|
||||
company_name: string;
|
||||
}
|
||||
|
||||
function formatConfidence(confidence: string | null): string | null {
|
||||
if (!confidence) return null;
|
||||
const n = parseFloat(confidence);
|
||||
if (Number.isNaN(n)) return confidence;
|
||||
return `${Math.round(n * 100)}%`;
|
||||
}
|
||||
|
||||
// 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> = {
|
||||
|
|
@ -46,6 +88,18 @@ export default function Pax8Page() {
|
|||
const [selectedCompany, setSelectedCompany] = useState<Record<string, any> | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
// Needs Review tab state
|
||||
const [reviews, setReviews] = useState<CompanyReview[] | null>(null);
|
||||
const [reviewTotal, setReviewTotal] = useState(0);
|
||||
const [reviewError, setReviewError] = useState<string | null>(null);
|
||||
const [resolving, setResolving] = useState<string | null>(null);
|
||||
const [allCompanies, setAllCompanies] = useState<ManualCompanyOption[] | null>(null);
|
||||
const [companiesLoading, setCompaniesLoading] = useState(false);
|
||||
const [selectedManualCompany, setSelectedManualCompany] = useState<
|
||||
Record<string, ManualCompanyOption>
|
||||
>({});
|
||||
const [openPopoverId, setOpenPopoverId] = useState<string | null>(null);
|
||||
|
||||
const fetchCompanies = async (
|
||||
currentPage: number,
|
||||
search?: string,
|
||||
|
|
@ -94,6 +148,73 @@ export default function Pax8Page() {
|
|||
fetchCompanies(nextPage, searchQuery, sortColumn, sortOrder);
|
||||
};
|
||||
|
||||
async function loadReviews(): Promise<void> {
|
||||
setReviewError(null);
|
||||
setReviews(null);
|
||||
try {
|
||||
const res = await fetch('/api/pax8/company-matches?limit=100');
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { items: CompanyReview[]; total: number };
|
||||
setReviews(data.items);
|
||||
setReviewTotal(data.total);
|
||||
} catch (err) {
|
||||
setReviewError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCompaniesLoaded(): Promise<void> {
|
||||
if (allCompanies !== null || companiesLoading) return;
|
||||
setCompaniesLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/data/companies-list');
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
|
||||
const data = (await res.json()) as ManualCompanyOption[];
|
||||
setAllCompanies(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch companies list:', err);
|
||||
} finally {
|
||||
setCompaniesLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the review queue + manual-search company list on first activation
|
||||
// of the Needs Review tab.
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'needs-review') return;
|
||||
if (reviews === null) void loadReviews();
|
||||
void ensureCompaniesLoaded();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab]);
|
||||
|
||||
async function resolve(reviewId: string, companyId: number, companyName: string): Promise<void> {
|
||||
const key = `${reviewId}:${companyId}`;
|
||||
setResolving(key);
|
||||
try {
|
||||
const res = await fetch(`/api/pax8/company-matches/${reviewId}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ companyId }),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
if (!res.ok) throw new Error(data.error ?? `Request failed: ${res.status}`);
|
||||
toast.success(`Linked to ${companyName}`);
|
||||
setReviews((prev) => prev?.filter((r) => r.id !== reviewId) ?? null);
|
||||
setReviewTotal((t) => Math.max(0, t - 1));
|
||||
setSelectedManualCompany((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[reviewId];
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Resolve failed');
|
||||
} finally {
|
||||
setResolving(null);
|
||||
}
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
|
|
@ -169,7 +290,14 @@ export default function Pax8Page() {
|
|||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="companies">Companies</TabsTrigger>
|
||||
<TabsTrigger value="needs-review">Needs Review</TabsTrigger>
|
||||
<TabsTrigger value="needs-review" className="gap-1.5">
|
||||
Needs Review
|
||||
{reviewTotal > 0 && (
|
||||
<span className="ml-1 rounded-full bg-blue-500/20 text-blue-600 text-xs px-1.5 py-0.5 font-medium">
|
||||
{reviewTotal}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="companies" className="space-y-4">
|
||||
|
|
@ -190,8 +318,174 @@ export default function Pax8Page() {
|
|||
</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>
|
||||
{reviewError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Couldn't load PAX8 data</AlertTitle>
|
||||
<AlertDescription>
|
||||
Check your connection and try again, or visit /admin/integrations if PAX8
|
||||
sync is disabled.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{reviews === null && !reviewError && (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reviews !== null && reviews.length === 0 && !reviewError && (
|
||||
<Alert>
|
||||
<CheckCircle2 className="size-4" />
|
||||
<AlertTitle>No companies need review</AlertTitle>
|
||||
<AlertDescription>
|
||||
Every synced PAX8 company is matched to an Autotask company.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{reviews?.map((review) => (
|
||||
<Card key={review.id} className="border-amber-200">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base font-medium">
|
||||
<AlertTriangle className="size-5 text-amber-500" />
|
||||
{review.pax8CompanyName}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{review.candidates.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{review.candidates.map((candidate) => {
|
||||
const key = `${review.id}:${candidate.companyId}`;
|
||||
const isResolving = resolving === key;
|
||||
const confidence = formatConfidence(candidate.confidence);
|
||||
return (
|
||||
<div
|
||||
key={candidate.companyId}
|
||||
className="flex items-center justify-between gap-3 rounded-md border p-3"
|
||||
>
|
||||
<div className="space-y-0.5 min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{candidate.companyName ?? `Company ${candidate.companyId}`}
|
||||
</div>
|
||||
{confidence && (
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{confidence}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isResolving}
|
||||
onClick={() =>
|
||||
void resolve(
|
||||
review.id,
|
||||
candidate.companyId,
|
||||
candidate.companyName ?? `Company ${candidate.companyId}`
|
||||
)
|
||||
}
|
||||
>
|
||||
{isResolving ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin mr-1" />
|
||||
Linking…
|
||||
</>
|
||||
) : (
|
||||
'Link company'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertTitle>No suggested matches</AlertTitle>
|
||||
<AlertDescription>
|
||||
Search manually to link this company to its Autotask counterpart.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Popover
|
||||
open={openPopoverId === review.id}
|
||||
onOpenChange={(open) => {
|
||||
setOpenPopoverId(open ? review.id : null);
|
||||
if (open) void ensureCompaniesLoaded();
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="flex-1 justify-between font-normal"
|
||||
>
|
||||
{selectedManualCompany[review.id]?.company_name ?? 'Search company…'}
|
||||
<ChevronsUpDown className="size-4 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search company…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{companiesLoading ? 'Loading companies…' : 'No company found.'}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{(allCompanies ?? []).map((company) => (
|
||||
<CommandItem
|
||||
key={company.id}
|
||||
value={company.company_name}
|
||||
onSelect={() => {
|
||||
setSelectedManualCompany((prev) => ({
|
||||
...prev,
|
||||
[review.id]: company,
|
||||
}));
|
||||
setOpenPopoverId(null);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-4',
|
||||
selectedManualCompany[review.id]?.id === company.id
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{company.company_name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
disabled={
|
||||
!selectedManualCompany[review.id] ||
|
||||
resolving === `${review.id}:${selectedManualCompany[review.id]?.id}`
|
||||
}
|
||||
onClick={() => {
|
||||
const selected = selectedManualCompany[review.id];
|
||||
if (selected) void resolve(review.id, selected.id, selected.company_name);
|
||||
}}
|
||||
>
|
||||
{resolving === `${review.id}:${selectedManualCompany[review.id]?.id}` ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin mr-1" />
|
||||
Linking…
|
||||
</>
|
||||
) : (
|
||||
'Link to selected company'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue