Deal detail page redesign
Replace the slide-out modal with a full-page deal detail view at /admin/queue/$dealId. Unify timeline + pipeline trail into one strip; promote the per-item view to a top-level Items section; add a VCC Appraisal card backed by the /api/Appraisal/appraisal-contents endpoint; move per-stage logs into a unified slide-up drawer with stage-boundary markers.
TL;DRSummary
The current detail surface is a Sheet slide-out on /admin/production/queue?run=<uuid> with four tabs (IO / Logs / Error / Timeline) and a header PipelineTrail. Two pain points: (1) the timeline appears in two places, (2) the most informative content β per-item appraisal state β sits buried inside the Categorization stage's IO tab.
The redesign: new route /admin/queue/$dealId with stacked layout β Header β unified clickable Timeline β always-visible Items grid β leaner per-stage detail panel β VCC Appraisal card β slide-up Logs drawer. Auto-poll 3 s while running. One new VCC client method (get_appraisal_contents), one new backend route (GET /admin/queue/deals/{deal_uuid}/appraisal), seven new frontend components. Removes five existing components. No DB migrations.
Β§1Context
What exists
web/src/routes/admin.production.queue.tsxβ Digital Appraisal Queue list. Row click sets?run=<uuid>, opensRunDetailSheet.web/src/components/queue/RunDetailSheet.tsxβSheet-based modal with header +PipelineTrail+ 4 tabs.web/src/components/queue/PipelineTrail.tsxβ horizontal clickable stage bubbles. Will be folded into the new timeline.web/src/components/queue/tabs/{TimelineTab,IOTab,LogsTab,ErrorTab}.tsxβ redundant with each other in places (TimelineTab vs PipelineTrail; LogsTab is per-stage when operators want one stream).web/src/components/queue/tabs/io/{Intake,Segmentation,Categorization,Appraisal}IO.tsxβ per-stage IO panels. Kept but trimmed (items move out of CategorizationIO).web/src/components/queue/Lightbox.tsxβ reused unchanged for full-size crop view.web/src/lib/api/adapters/deal-detail.tsβ existingadaptDealDetail;ItemInfoextended with 9 new appraisal fields.backend/src/vcc_backend/features/deals/schemas.pyβDealDetailResponse+DealItemBrief. Brief extended with nullable appraisal fields.backend/src/vcc_backend/integrations/vcc/client.py:262β existingget_checked_in_dealas the pattern for the newget_appraisal_contents.checkin-pipeline/app/vcc_api.py:69-97β legacyget_appraisal_contentsdocuments VCC's quirky 500-with-"No Appraisal Found" semantics.checkin-pipeline/app/steps/appraise/_shared.pyβ canonicalAPPRAISE_SCHEMA; the new per-item brief fields mirror this shape.checkin-pipeline/app/templates/appraiser/partials/item_card.htmlβ reference for visual content per item (read-only translation).
What does not exist yet
- No
web/src/routes/admin.queue.$dealId.tsx. - No
appraisal_resultJSONB column ondeal_items. We don't add this yet β defer to sub-project B when the appraisal stage actually runs. - No
GET /admin/queue/deals/{deal_uuid}/appraisalroute. - No
VccClient.get_appraisal_contentsmethod.
Β§2Scope (in / out)
In scope
- New route
web/src/routes/admin.queue.$dealId.tsx. - New components under
web/src/components/deal-detail/:DealHeader,DealTimeline,ItemsGrid,ItemCard,StageDetailPanel,VccAppraisalCard,LogsDrawer. - Adapter update:
ItemInfogrows 9 new nullable appraisal fields. - Backend:
VccClient.get_appraisal_contents+GET /admin/queue/deals/{deal_uuid}/appraisal. - Schema extension:
DealItemBrief+ 9 new nullable fields. - Route navigation: queue row β
/admin/queue/$dealId. One-line backwards-compat redirect for old?run=URLs. - Remove
RunDetailSheet,PipelineTrail,TimelineTab,IOTab,LogsTab,ErrorTab+ tests. - Trim
SegmentationIO,CategorizationIO: remove the items/crop rendering (now in page-levelItemsGrid); keep settings / LLM-usage / stats. - Auto-poll: 3s while any stage is
running; idle when terminal. - OpenAPI regeneration.
Out of scope
- The per-item appraisal stage itself β sub-project B. The fields stay null until B ships.
- DB column for storing
appraisal_resultβ defer to B. - All-history overview page β sub-project C.
- VCC re-polling for status drift β sub-project D. Header shows "as of last ingest, N min ago" without auto-refresh.
- Customer details. Fetched only at DOR submission; never persisted; never shown here.
- Joining VCC
appraisal-contents.itemsβ ourDealItemrows. Granularities differ; no 1:1 mapping. Both views are shown, not reconciled. - Editing items / values on the deal detail page (read-only summary).
- Redesign of the queue list page itself β only the row-click target changes.
- VCC's second images endpoint (
/api/DealImages/D-{id}withimageType=1= appraisal-process photos). Defer; can be a follow-up.
Β§3Architecture
Route topology
Data sources per page section
Two main page-level queries (detail and appraisal) load independently. Failure or slowness of one never blocks the other. Auto-poll applies to both at 3 s intervals while any stage is running (gated on the detail response's runs array).
Page layout mockup
Β§4Items grid & card
ItemCard content
| Field | Source | Display |
|---|---|---|
| Thumbnail | it.crop_key β /files/{crop_key} | Square image; click opens Lightbox |
| AI item name | it.ai_item_name | Bold title; falls back to it.subcategory |
| Category / sub / confidence | it.category + it.subcategory + it.category_confidence | "Jewellery Β· Gold Β· 92%" |
| Size | it.width_cm Γ it.height_cm | "2.3cm Γ 1.1cm"; hidden if either null |
| Value range | it.value_low β it.value_high + it.value_currency | "AI: Β£45βΒ£85"; hidden if either null |
| Condition / material | it.condition + it.material | "Condition: very good Β· 9ct gold"; either part can be absent |
| Triage route | it.triage_route | Prominent badge; red_bag = red, precious_metals = gold, etc. |
| Signal chips | it.signals (true-valued keys) | Small badges; only true flags render |
| Status | it.status / it.appraisal_status | Lifecycle badge: pending / classified / appraised / failed |
| Source reference | sourceLabelFor(it.source_image, imageById) | URL basename (already-built helper) |
Null fields produce no element on the card β no "β" placeholders. The card naturally collapses to category + thumbnail + status when the appraisal stage hasn't run yet.
Β§5Backend additions
VccClient.get_appraisal_contents
# vcc/client.py β additions
# Returns the JSON payload, or None when VCC reports "No Appraisal Found".
# Raises VccError on any other 4xx/5xx or transport failure.
async def get_appraisal_contents(self, hubspot_deal_id: str) -> dict[str, Any] | None:
path = f"/api/Appraisal/appraisal-contents/D-{hubspot_deal_id}"
url = f"{self._base_url}{path}"
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.get(url, headers=self._headers)
except httpx.HTTPError as exc:
raise VccError(f"appraisal-contents transport error: {exc}") from exc
if resp.status_code == 500:
# VCC quirk: 500 with "No Appraisal Found" = deal not yet appraised.
try:
body = resp.json()
except ValueError:
body = {}
if isinstance(body, dict) and "No Appraisal Found" in (body.get("detail") or ""):
return None
raise VccError(f"appraisal-contents 500", status=500, body=resp.text[:500])
if resp.status_code >= 400:
raise VccError(
f"appraisal-contents {resp.status_code}",
status=resp.status_code,
body=resp.text[:500],
)
data: dict[str, Any] = resp.json()
return data
Route GET /admin/queue/deals/{deal_uuid}/appraisal
# api/routers/admin.py β additions
@router.get(
"/queue/deals/{deal_uuid}/appraisal",
response_model=AppraisalContentsResponse | None,
)
async def get_admin_deal_appraisal(
deal_uuid: UUID,
session: AsyncSession = Depends(db_session),
settings: Settings = Depends(get_settings),
) -> dict[str, Any] | None:
deal = await session.scalar(select(Deal).where(Deal.id == deal_uuid))
if deal is None:
raise HTTPException(status_code=404, detail="deal not found")
client = VccClient(
api_key=settings.vcc_api_key.get_secret_value(),
base_url=settings.vcc_api_base_url,
)
try:
return await client.get_appraisal_contents(deal.hubspot_deal_id)
except VccError as exc:
raise HTTPException(status_code=502, detail=f"VCC error: {exc}") from exc
Schema extension β DealItemBrief
# features/deals/schemas.py β extension
class DealItemBrief(BaseModel):
# existing fields...
id: UUID
crop_key: str
source_image: str
bbox: dict[str, float]
width_cm: float | None
height_cm: float | None
status: ItemStatus
category: str | None = None
subcategory: str | None = None
category_confidence: float | None = None
subcategory_confidence: float | None = None
# NEW (all nullable until the appraisal stage exists)
ai_item_name: str | None = None
condition: str | None = None
material: str | None = None
value_low: float | None = None
value_high: float | None = None
value_currency: str | None = None
triage_route: Literal["no_value", "quantity", "fixed_price", "precious_metals", "red_bag"] | None = None
signals: dict[str, bool] | None = None
appraisal_status: Literal["pending", "appraised", "failed"] | None = None
Β§6Failure modes
| Scenario | Behaviour |
|---|---|
| Deal id in URL doesn't exist | Detail endpoint returns 404. Empty state with "Back to Queue". |
VCC appraisal-contents returns 500 with "No Appraisal Found" | Backend swallows, returns null. Card shows "Not yet appraised" placeholder. |
VCC appraisal-contents 4xx/5xx other | Backend raises VccError β 502. Card shows error sub-state. Rest of page renders fine. |
| VCC transport timeout | Same as above β 502, in-card error sub-state. |
| Auto-poll fires during navigation | TanStack Query cancels stale refetches on unmount automatically. |
Stage clicked has no PipelineRun yet | StageDetailPanel shows "Awaiting prior stage" via the relevant *IO's empty state. |
| Deal reaches DONE | Polling gate drops to false; refetchInterval disabled. |
Stage's last PipelineRun is failed | StageDetailPanel prepends inline error block. Timeline node shows red β. |
| Items array empty | "No items detected. Segmentation produced 0 crops." |
| Logs drawer with no entries | "No logs yet for this deal." |
| Browser back from detail page | Returns to queue with filter/scroll state preserved. |
appraisal-contents valid but items empty | Render metadata + notes; "No items recorded" sub-state for the items table. |
Β§7Testing
Backend
tests/test_vcc_client_appraisal_contents.pyβ NEW.respx-mocked unit tests forVccClient.get_appraisal_contents: happy path; 500 with "No Appraisal Found" βNone; other 500 βVccError(status=500); 4xx βVccError; transport error βVccError;x-api-keyheader set.tests/test_appraisal_route.pyβ NEW. Route tests: unknown deal β 404; payload β 200 + body;Noneβ 200 +null;VccErrorβ 502.tests/test_deal_detail_schemas.pyβ EXTEND. Assert the 9 new fields exist; verifynullserialization; round-trip a populated fixture.tests/test_deal_queue_service_detail.pyβ EXTEND. One case populating the new fields via direct ORM writes; assert they thread through toDealItemBrief.
Frontend (Vitest + RTL + MSW)
DealHeader.test.tsxβ identity / pipeline / VCC state; freshness hint; copy on click.DealTimeline.test.tsxβ 6 nodes; correct status icons; click updates?stage=; running shows live elapsed; failed shows β + "β".ItemCard.test.tsxβ three fixtures: (a) categorization-only, (b) full appraisal with all signal chips, (c)status=failed. Null fields collapse cleanly; triage badge color matches route.ItemsGrid.test.tsxβ N cards; empty state; thumbnail click opensLightbox.StageDetailPanel.test.tsxβ dispatch by?stage=; error block on failure; "Awaiting prior stage" when not run.VccAppraisalCard.test.tsxβ payload renders;nullβ placeholder; 502 β error sub-state.LogsDrawer.test.tsxβ opens via button; stage-boundary markers between entries with differentextra.stage; Esc + close button work;?logs=openpre-opens.admin.queue.$dealId.test.tsxβ composition; auto-poll fires every 3 s while running; stops when terminal (vi.useFakeTimers); deep-link?stage=segmentation&logs=openworks; old?run=URLs redirect.deal-detail.test.tsβ adapter test: 9 new fields preserved through adaptation;imageById+sourceLabelForunchanged.
Manual / smoke (not in CI)
- Click through queue row β land on detail page. Back returns to queue with state.
- Process a deal in docker compose; verify polling animates timeline; Items grid populates; drawer streams logs; VCC Appraisal card renders or shows the placeholder depending on real VCC state.
- Visual: 70+ items render correctly; cards collapse when appraisal fields are absent; triage badges have distinguishable colors.
Β§8Decisions made
- Route: new
/admin/queue/$dealIdpage replaces the slide-out. Backwards-compat shim redirects old?run=URLs. - Macro layout: stacked vertical β Header β Timeline β Items β Selected-stage detail β VCC Appraisal β Logs button β Logs drawer (when open).
- Timeline: single horizontal strip of 6 stage nodes. Replaces
PipelineTrail+TimelineTab. - Items section: top-level, always visible. Replaces buried crops inside
CategorizationIO. - Item card content: thumbnail + AI summary + triage route + signal chips + status. Read-only.
- Stage panel: leaner. Only settings / LLM usage / errors. Items removed from per-stage IO.
- Logs: slide-up drawer behind a button. Unified deal-wide stream with stage-boundary markers. Replaces per-stage
LogsTab. - VCC Appraisal: new endpoint + new card. Separate granularity from our Items; not joined.
- Customer details: not shown on the deal detail page; fetched transiently only at DOR submission.
- Refresh: auto-poll 3 s while any stage is running; idle otherwise.
- Default stage selection: latest with data.
?stage=overrides. - Appraisal fields on DealItem: schema additions, all nullable; no DB column yet (defer to sub-project B).
- DB migrations: none in this slice.
- VCC re-poll for status drift: deferred to sub-project D. Header shows "as of last ingest, N min ago".