---
name: dyolink-lab-cases
description: Lab Cases tab — list, filters, detail panel, assignment, share QR, card UX. Use when changing CasesPage, cases API, CaseDetailPanel, or case list cards.
---

# Lab Cases tab

**UI:** [`frontend/src/components/ui/lab/CasesPage.tsx`](frontend/src/components/ui/lab/CasesPage.tsx)  
**Backend:** [`backend/src/modules/cases/`](backend/src/modules/cases/)  
**Shared prosthesis rows:** [`LabCaseProsthesisGroupsList.tsx`](frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx) (also used in Treatment lab shipments rail)

## List behavior

- **Default sort:** `startedAt` / `sentAt` desc (newest first). Lab-origin drafts (`origin === LAB_INTERNAL && !startedAt`) show a **Draft** badge. All list/detail cards also show **Received** (`CLINIC_DISPATCH`) or **Generated** (`LAB_INTERNAL`) via `LabCaseOriginBadge`.
- **Task group headers:** prosthesis type is **colored catalog text** (`prosthesisTypeColorFromCatalog`), not a Badge pill. Same on Tasks rows/headers. Titles use the picker path to the **leaf** (`prosthesisJobPath.ts` via `ProsthesisStackedTypeLabel`) — e.g. Crowns · PFM Crown, not “Crowns” alone. Indirect jobs skip the long parent title: Inlay · Layered ceramic, not Veneer/Inlay/Onlay/Overlay · Inlay · …. Join ancestors/leaf with `PROSTHESIS_JOB_PATH_SEP` (`\u00a0·\u00a0`).
- **Page size:** `PAGE_SIZE = 10` in `CasesPage.tsx`.
- **Auto-select:** On tab open / after filter reload, select first list item if none selected; keep selection when still in list; `?caseId=` URL wins.
- **Add case:** `TAB_CASES_EDIT` — `POST /cases` then replace the **right pane** with `CaseCreatePanel` (inline wizard, not a modal). Prosthesis-only lines, `ProsthesisAssignChart` (same picker/stacking as Treatment). Summary rows: `LabCaseToothJobsList` (connected same-type teeth share a row; otherwise tooth · types). Header: referring clinic/dentist, patient name/mobile, optional ACTIVE partner clinic, due date (`AppDateInput`). **Start** (`POST /cases/:id/start`) generates tasks; no `LabCaseSend`, no clinic inbox/`CASE_SENT`. **Delete** (`DELETE /cases/:id`) only for unstarted lab-origin drafts (`LAB_INTERNAL` && `!startedAt`); confirm in the create-panel header next to Start. Received clinic cases and started generated cases cannot be deleted.
- **Right panel:** Draft lab-origin → wizard; started/received → `CaseDetailPanel`.
- **Left rail layout:** section is `flex flex-col` + `lg:min-h-[420px]` / `h-full` with `lg:items-stretch` on the grid; case list wrapper is `flex-1 min-h-0 overflow-y-auto` (do **not** use a fixed `max-h-[55vh]` — that leaves empty space above pagination).

List `where`: received clinic cases (`sends` + `sentAt`) **OR** `origin = LAB_INTERNAL` and `destinationOrganizationId = lab`. Map clinic/patient from `treatment` **or** snapshots / partner org. Search includes snapshot names.

Share QR remains clinic-sent only (`sentAt` / `shareUrl`). Lab-origin has no share link in this pass. Clinic Treatment **Lab dispatch** shows the same QR after send (see lab-case-share-link skill).

## Filters (`GET /cases`)

| Param | Behavior |
|-------|----------|
| `prosthesisTypeCode` | Cases with **≥1 task** of that prosthesis type (`tasks.some`) — not treatment-type filter |
| `clinicOrganizationId` | From URL (`Today` case partners chart) or dropdown |
| `q`, `sentFrom`, `sentTo` | Search + date range |

Filter options: `GET /cases/filter-options` → `clinics`, `prosthesisTypes` (distinct codes from sent-case tasks, catalog-ordered).

## List card UI

Match Treatment shipment cards: patient name, clinic, **colored prosthesis groups + teeth** (`LabCaseProsthesisGroupsList`), sent date, progress bar, due-date badge, unread dot. **No patient mobile** on list cards. Prosthesis row order: **type · teeth**, then Connected badge when that group is a bridge. Type text is the picker path to the leaf (`prosthesisJobPath.ts`).

List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task teeth aggregation. Unconnected teeth stay one group each (stacked types on that tooth share one `prosthesisTypeCode` key like `crown+abutment`). **Connected** teeth that share a type are one group whose `teeth` lists the bridge. **Partial denture** is one group whose `teeth` is every selected unit.

## Detail panel

- Task assignment: `PATCH /cases/:caseId/tasks/:taskId/assign` (`TAB_CASES_EDIT`)
- Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes (`viewerSide="LAB"`). Newest-first; sent/received use logical start/end alignment (RTL-safe). Compact `h-9` composer with primary send + visibility controls. Lab-origin cases (`LAB_INTERNAL`, including after Start) are visible to lab comments/mark-read — do not require `sentAt` / `LabCaseSend`. Generated cases hide clinic-visibility controls (`clinicVisibility={false}`) — there is no clinic inbox.
- Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge)
- FDI chart (`CaseToothChartPanel`): prosthesis colors + **connected bridge dots** from `selectionGroupId` (`buildCaseConnectedTeeth` / `buildCaseProsthesisRows` in `caseDetailUtils.ts`).
- **Important + external code** (`TAB_CASES_EDIT`): row is **Important Case** label then checkbox (`Checkbox` `labelPosition="start"`), then optional external-code input (no title; placeholder only). Save code on blur → `PATCH /cases/:id/external-code`.
- **Case Sheet PDF** — see below.
- **Share link (sent cases):** `shareUrl` on detail; attachment preview left + QR thumb right; `LabCaseShareQrDialog` (`react-qr-code`). Full flow: `.cursor/skills/lab-case-share-link/SKILL.md`.

## Case Sheet PDF

**Entry:** Download PDF beside Comments on `CaseDetailPanel`.  
**Builder:** [`caseSheetPdf.ts`](frontend/src/components/lab/caseSheetPdf.ts) → off-screen [`CaseSheetPrintLayout.tsx`](frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx) → `html2canvas` → `jspdf` A4 (ISO √2 — prints cleanly onto A3–A6).

### html2canvas + Tailwind v4 (required)

Stock `html2canvas` **cannot** parse `lab()` / `oklch()` from app CSS. Do **not** mount live `FdiToothChart` / Tailwind color classes into the capture tree.

| Do | Don't |
|----|--------|
| Hex/rgb **inline styles** only in print layout | Tailwind color utilities / CSS variables in capture |
| [`CaseSheetFdiChart`](frontend/src/components/ui/lab/CaseSheetFdiChart.tsx) (SVG, catalog hex colors) | Reuse interactive `FdiToothChart` for PDF |
| `onclone`: strip `style` + `link[rel=stylesheet]` | Rely on global stylesheets during capture |
| Prosthesis labels from **`prosthesisJobPathLabel`** (category · … · leaf) | Fallback `code.replace(/_/g,' ')` alone (breaks casing like `ti base…`) |

### Layout conventions

- Sections: meta → parties → **Prosthesis** rows → **Tooth Chart** heading *above* the horizontal rule → chart → Comments.
- Connected badge: bold + underline (no border box — html2canvas centering is unreliable).
- Order number: `externalCode.trim()` when set, else short case id (`id` without hyphens, first 8, uppercased).
- Share QR when `shareUrl` present.

### API

- `LabCase.externalCode` (optional string, max 64)
- `PATCH /cases/:id/external-code` `{ externalCode: string | null }` — same edit permission as important flag

## Live soft refresh

Inbox Socket.IO `notification.created` → `notifyTabBadgesChanged()` → silent `loadCases` + selected `loadDetail` (keeps filters/selection; no full remount). Same event refreshes sidebar Cases badge via `useTabBadgeCounts`. See `.cursor/skills/notifications-inbox/SKILL.md`.

## Permissions

`TAB_CASES_READ` / `TAB_CASES_EDIT`; owner always has Cases access. `LabOrgGuard` on routes.
