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.

Spec Draft 2026-05-23 Β· sub-project A of 4 Β· pending user review

TL;DRSummary

What this changes

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

What does not exist yet

Β§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: ItemInfo grows 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-level ItemsGrid); 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 ↔ our DealItem rows. 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} with imageType=1 = appraisal-process photos). Defer; can be a follow-up.

Β§3Architecture

Route topology

/admin/production/queue (existing β€” Digital Appraisal Queue list) β”‚ β”‚ row click β†’ navigate(`/admin/queue/${dealId}`) β–Ό /admin/queue/$dealId (NEW β€” deal detail page) β”‚ β”œβ”€β”€ ?stage=<id> deep-link to a selected pipeline stage └── ?logs=open opens the slide-up drawer on first paint

Data sources per page section

β”Œβ”€ /admin/queue/$dealId ────────────────────────────────────────────────────┐ β”‚ β”‚ β”‚ DealHeader ← GET /admin/queue/deals/{dealId}/detail β”‚ β”‚ β”‚ β”‚ DealTimeline ← GET /admin/queue/deals/{dealId}/detail (runs[] array) β”‚ β”‚ β”‚ β”‚ ItemsGrid ← GET /admin/queue/deals/{dealId}/detail (items[] array) β”‚ β”‚ β”‚ β”‚ StageDetailPanel ← GET /admin/queue/deals/{dealId}/detail β”‚ β”‚ (renders one of {Intake,Segmentation,Categorization,Appraisal}IO β”‚ β”‚ trimmed; reads from detail's images / runs / current_settings) β”‚ β”‚ β”‚ β”‚ VccAppraisalCard ← GET /admin/queue/deals/{dealId}/appraisal β”‚ β”‚ β”‚ β”‚ LogsDrawer (toggle button) β”‚ β”‚ ← GET /admin/logs?deal_id={dealId} (existing endpoint; SSE for live) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

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

β”Œβ”€ /admin/queue/<dealId> ──────────────────────────────────────────────────────────┐ β”‚ β”‚ β”‚ ← Back to Queue [Re-process] [View in HubSpot β†—] [View in VCC β†—] β”‚ β”‚ β”‚ β”‚ β”Œβ”€ Header card ────────────────────────────────────────────────────────────┐ β”‚ β”‚ β”‚ hs:DEAL-12345 Β· internal: c165...5332 Β· πŸ‡¬πŸ‡§ GB β”‚ β”‚ β”‚ β”‚ ───────────────────────────────────────────────────────────────────── β”‚ β”‚ β”‚ β”‚ Our pipeline: in_progress Β· stage: segmentation Β· 0:42 in stage β”‚ β”‚ β”‚ β”‚ VCC state: "Awaiting Review" Β· not terminal Β· Β£-- β”‚ β”‚ β”‚ β”‚ checked-in 2026-05-21 09:14 Β· vcc-created 2026-05-21 09:14β”‚ β”‚ β”‚ β”‚ (as of last ingest β€” refreshed N min ago) β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”Œβ”€ Timeline (horizontal, clickable nodes) ──────────────────────────────────┐ β”‚ β”‚ β”‚ ●─── ●─── ●─── ◐─── ○─── β—‹ β”‚ β”‚ β”‚ β”‚ Intake Seg Cat ⟳ App Rev Done β”‚ β”‚ β”‚ β”‚ βœ“ 1s βœ“ 4s βœ“ 3s 0:42 β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”Œβ”€ Items grid (always visible) ────────────────────────────────────────────┐ β”‚ β”‚ β”‚ [ItemCard] [ItemCard] [ItemCard] [ItemCard] β”‚ β”‚ β”‚ β”‚ [ItemCard] [ItemCard] [ItemCard] [ItemCard] β”‚ β”‚ β”‚ β”‚ ... β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”Œβ”€ Selected stage: Segmentation ───────────────────────────────────────────┐ β”‚ β”‚ β”‚ Segmenter settings Β· last run Β· errors (if any) Β· LLM usage β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”Œβ”€ VCC Appraisal (from /api/Appraisal/appraisal-contents) ────────────────┐ β”‚ β”‚ β”‚ Items Β· notes Β· box count Β· appraisal id (or "Not yet appraised") β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ [πŸ“‹ Logs (full stream)] β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Β§4Items grid & card

ItemCard content

β”Œβ”€ ItemCard ──────────────────────────────────────────┐ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ β”‚ Gold ring (9ct) β”‚ β”‚ β”‚ thumbnail β”‚ Jewellery Β· Gold Β· 92% β”‚ β”‚ β”‚ (clickableβ”‚ 2.3cm Γ— 1.1cm β”‚ β”‚ β”‚ β†’ Lightboxβ”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ AI: Β£45–£85 β”‚ β”‚ Condition: very good Β· 9ct gold β”‚ β”‚ β”‚ β”‚ [precious_metals] ← triage route badge β”‚ β”‚ [precious_metal] [condition_check] [expert_hint] β”‚ β”‚ Status: classified β”‚ β”‚ Source: 0d8d5dc...jpg β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
FieldSourceDisplay
Thumbnailit.crop_key β†’ /files/{crop_key}Square image; click opens Lightbox
AI item nameit.ai_item_nameBold title; falls back to it.subcategory
Category / sub / confidenceit.category + it.subcategory + it.category_confidence"Jewellery Β· Gold Β· 92%"
Sizeit.width_cm Γ— it.height_cm"2.3cm Γ— 1.1cm"; hidden if either null
Value rangeit.value_low – it.value_high + it.value_currency"AI: Β£45–£85"; hidden if either null
Condition / materialit.condition + it.material"Condition: very good Β· 9ct gold"; either part can be absent
Triage routeit.triage_routeProminent badge; red_bag = red, precious_metals = gold, etc.
Signal chipsit.signals (true-valued keys)Small badges; only true flags render
Statusit.status / it.appraisal_statusLifecycle badge: pending / classified / appraised / failed
Source referencesourceLabelFor(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

ScenarioBehaviour
Deal id in URL doesn't existDetail 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 otherBackend raises VccError β†’ 502. Card shows error sub-state. Rest of page renders fine.
VCC transport timeoutSame as above β€” 502, in-card error sub-state.
Auto-poll fires during navigationTanStack Query cancels stale refetches on unmount automatically.
Stage clicked has no PipelineRun yetStageDetailPanel shows "Awaiting prior stage" via the relevant *IO's empty state.
Deal reaches DONEPolling gate drops to false; refetchInterval disabled.
Stage's last PipelineRun is failedStageDetailPanel 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 pageReturns to queue with filter/scroll state preserved.
appraisal-contents valid but items emptyRender metadata + notes; "No items recorded" sub-state for the items table.
Single rule Partial failure on one section never breaks another. Header, Timeline, Items, Stage panel, VCC Appraisal, Logs drawer each load from their own data and degrade independently.

Β§7Testing

Backend

Frontend (Vitest + RTL + MSW)

Manual / smoke (not in CI)

Β§8Decisions made

  1. Route: new /admin/queue/$dealId page replaces the slide-out. Backwards-compat shim redirects old ?run= URLs.
  2. Macro layout: stacked vertical β€” Header β†’ Timeline β†’ Items β†’ Selected-stage detail β†’ VCC Appraisal β†’ Logs button β†’ Logs drawer (when open).
  3. Timeline: single horizontal strip of 6 stage nodes. Replaces PipelineTrail + TimelineTab.
  4. Items section: top-level, always visible. Replaces buried crops inside CategorizationIO.
  5. Item card content: thumbnail + AI summary + triage route + signal chips + status. Read-only.
  6. Stage panel: leaner. Only settings / LLM usage / errors. Items removed from per-stage IO.
  7. Logs: slide-up drawer behind a button. Unified deal-wide stream with stage-boundary markers. Replaces per-stage LogsTab.
  8. VCC Appraisal: new endpoint + new card. Separate granularity from our Items; not joined.
  9. Customer details: not shown on the deal detail page; fetched transiently only at DOR submission.
  10. Refresh: auto-poll 3 s while any stage is running; idle otherwise.
  11. Default stage selection: latest with data. ?stage= overrides.
  12. Appraisal fields on DealItem: schema additions, all nullable; no DB column yet (defer to sub-project B).
  13. DB migrations: none in this slice.
  14. VCC re-poll for status drift: deferred to sub-project D. Header shows "as of last ingest, N min ago".