diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc index 08c6bd7..9f0c3a5 100644 --- a/.cursor/rules/treatment-workspace.mdc +++ b/.cursor/rules/treatment-workspace.mdc @@ -9,7 +9,7 @@ alwaysApply: false - **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load. - **Current draft** preview: heading “Current draft”; no load button while editing live. - **Entry wizard:** Teeth → Content → Lab via `WizardStepper` (not detail chips). Lab step only for lab-dependent (prosthesis) active detail. Appointment purpose does not gate type or Next. -- **Tooth groups:** Shift+same-arch range = connected; click in connected span → collapse to that single only. `toothSelectionGroups.ts`; prune lab `toothProsthesis` on change. +- **Tooth groups:** Shift+same-arch range = connected (linked dots); Ctrl/⌘+same-arch range = singles (one lab row each); click in connected span → collapse to that single only. `toothSelectionGroups.ts`; prune lab `toothProsthesis` on change. - **Detail chrome:** chips + Add at top; **Remove** = trash on chip (unsent, >1 detail; disabled when day-locked / no edit / uploading). No delete in Content step. - **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch. - **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all. diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index ade77c9..ca981ea 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -40,7 +40,7 @@ Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx | Step | UI | Notes | |------|-----|--------| -| **Teeth** | `FdiToothChart` | Shift+click range; connected dots | +| **Teeth** | `FdiToothChart` | Shift+click connected range; Ctrl/⌘+click single range; linked dots | | **Content** | `TreatmentDetailsEditor` fields only (`showChrome={false}`) | Type / notes / attachments — **no delete button** | | **Lab** | `LabCasesDispatchPanel` | Shown in stepper **only** when active detail type is lab-dependent (`labDependentCodes`) | @@ -56,7 +56,9 @@ Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted as `toothSelectionGroups` on the detail; lab tasks group by `selectionGroupId`. - **Plain click:** add/remove single; if tooth is in a **connected** group → drop the whole span and keep **only** that tooth as a single (no peel / no connected+single pair for the same span). -- **Shift+click:** inclusive same-arch range between prior anchor and target; replaces overlapping groups. Prevent browser selection artifacts (`select-none`, Shift `preventDefault` on mousedown). +- **Shift+click:** inclusive same-arch range → one **connected** group (linked dots above/below). Shift wins if Ctrl also held. +- **Ctrl/⌘+click:** same range, but each tooth as its own **single** (one lab-dispatch row per tooth). +- Prevent browser selection artifacts (`select-none`, modifier `preventDefault` on mousedown). - On group change, prune/remap `labCase.toothProsthesis` via `pruneToothProsthesisForGroups`. - Connected UI label: `ConnectedSelectionBadge` (shared `Badge` + primary tint) in dispatch + lab case lists. diff --git a/AGENTS.md b/AGENTS.md index 0a39188..a05d991 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ frontend/src/ - Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. - Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash icon on each detail chip (not in the wizard Content step) — only unsent and not the last line. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`). - **Entry wizard:** `WizardStepper` — Teeth → Content → Lab; Lab step only when active detail type is lab-dependent (prosthesis). Detail chips ≠ wizard chrome (chips stay bordered tabs; stepper is numbered rail). -- **Tooth selection:** Shift+click same-arch range = connected group; plain click on a connected tooth collapses the whole span to that single tooth (no peel). Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge` (shared `Badge` + primary tint). +- **Tooth selection:** Shift+click same-arch range = connected group (linked dots); Ctrl/⌘+click same range = individual singles (one lab row each); plain click on a connected tooth collapses the whole span to that single tooth (no peel). Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge` (shared `Badge` + primary tint). **Treatment lab rules (quick ref):** - Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add. diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b858ad8..950994c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -809,7 +809,7 @@ "entryStepNext": "Next", "entryStepLabUnavailable": "Lab dispatch is available after you save a lab-dependent detail with teeth selected.", "toothChartTitleCompact": "Tooth chart", - "toothChartHint": "Tap teeth to select. Shift-click another tooth in the same arch to select a connected span.", + "toothChartHint": "Tap teeth to select. Shift-click another tooth in the same arch for a connected span. Ctrl-click (⌘ on Mac) the same way to select each tooth separately.", "toothConnectedHint": "Connected selection", "toothConnectedSuffix": " (connected)", "connectedBadge": "Connected", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index ef983fa..9c19539 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -803,7 +803,7 @@ "confirmSend": "تأیید ارسال", "toothChartTitle": "نمودار دندان‌ها FDI", "toothChartTitleCompact": "نمودار دندان", - "toothChartHint": "برای انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک بازه متصل می‌سازد.", + "toothChartHint": "برای انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک بازه متصل می‌سازد. Ctrl+کلیک (⌘ در مک) به همان شکل هر دندان را جداگانه انتخاب می‌کند.", "toothConnectedHint": "انتخاب متصل", "toothConnectedSuffix": " (متصل)", "connectedBadge": "متصل", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 6f23238..7e8308b 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -802,7 +802,7 @@ "confirmSend": "Bevestig verzending", "toothChartTitle": "FDI-tanddiagram", "toothChartTitleCompact": "Tanddiagram", - "toothChartHint": "Tik op tanden om te selecteren. Shift-klik op een andere tand in dezelfde boog voor een verbonden span.", + "toothChartHint": "Tik op tanden om te selecteren. Shift-klik op een andere tand in dezelfde boog voor een verbonden span. Ctrl-klik (⌘ op Mac) op dezelfde manier om elke tand apart te selecteren.", "toothConnectedHint": "Verbonden selectie", "toothConnectedSuffix": " (verbonden)", "connectedBadge": "Verbonden", diff --git a/frontend/src/components/treatment/toothSelectionGroups.ts b/frontend/src/components/treatment/toothSelectionGroups.ts index 727ce98..a86bf1d 100644 --- a/frontend/src/components/treatment/toothSelectionGroups.ts +++ b/frontend/src/components/treatment/toothSelectionGroups.ts @@ -146,6 +146,38 @@ export function applyShiftRange( return next; } +/** + * Ctrl-click selects every tooth in the same-arch range as its own single group + * (not connected). Lab dispatch gets one prosthesis row per tooth. + */ +export function applyCtrlRange( + groups: ToothSelectionGroup[], + anchor: FdiToothId, + target: FdiToothId, +): ToothSelectionGroup[] | null { + if (anchor === target) return null; + const span = teethBetweenInclusive(anchor, target); + if (!span || span.length < 2) return null; + + const spanSet = new Set(span); + const next = groups + .map((g) => ({ + ...g, + teeth: g.teeth.filter((t) => !spanSet.has(t)) as FdiToothId[], + })) + .filter((g) => g.teeth.length > 0) + .map((g) => + g.kind === 'connected' && g.teeth.length < 2 + ? { ...g, kind: 'single' as const } + : g, + ); + + for (const tooth of span) { + next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] }); + } + return next; +} + /** * Drop prosthesis rows for teeth no longer selected on a detail, and refresh * selectionGroupId so connected→single collapses do not leave stale group ids. diff --git a/frontend/src/components/ui/treatment/FdiToothChart.tsx b/frontend/src/components/ui/treatment/FdiToothChart.tsx index 7660ba3..ce1fdc5 100644 --- a/frontend/src/components/ui/treatment/FdiToothChart.tsx +++ b/frontend/src/components/ui/treatment/FdiToothChart.tsx @@ -23,7 +23,7 @@ interface FdiToothChartProps { selected: ReadonlySet; /** Teeth that belong to a connected selection span (dots above/below). */ connectedTeeth?: ReadonlySet; - onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void; + onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean; ctrlKey: boolean }) => void; disabled?: boolean; /** Non-interactive display (Cases / connection history). */ readOnly?: boolean; @@ -144,91 +144,125 @@ export function FdiToothChart({ return accent ? { color: accent } : undefined; }; - const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => ( -
- {teeth.map((fdi, i) => { - const kind = getToothShapeKind(fdi); - const gid = `${uid}-g-${fdi}-${i}`; - const isSel = selected.has(fdi); - const isConnected = Boolean(connectedTeeth?.has(fdi)); - const size = toothSizeClass(fdi); - const tweak = TOOTH_TWEAKS[fdi]; - const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper); - const rotate = tweak ? tweak.rotate : archRotate(i, teeth.length); - const alignItems = upper ? 'items-end' : 'items-start'; - const accent = toothAccent(fdi); + /** Dots + thin links — only for Shift-connected spans (not plain / Ctrl singles). */ + const ConnectedRail = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => { + const hasConnected = teeth.some((fdi) => connectedTeeth?.has(fdi)); + if (!hasConnected) return null; - const glyph = ( - - ); - - const connectedDot = isConnected ? ( - - ) : ( - - ); - - return ( -
- {upper ? connectedDot : null} -
- {interactive ? ( - + return ( +
+ {teeth.map((fdi, i) => { + const size = toothSizeClass(fdi); + const isConnected = Boolean(connectedTeeth?.has(fdi)); + const next = teeth[i + 1]; + const linkToNext = Boolean(isConnected && next && connectedTeeth?.has(next)); + return ( +
+ {linkToNext ? ( + + ) : null} + {isConnected ? ( + ) : ( -
- {glyph} -
+ )}
- {!upper ? connectedDot : null} -
- ); - })} + ); + })} +
+ ); + }; + + const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => ( +
+ {upper ? : null} +
+ {teeth.map((fdi, i) => { + const kind = getToothShapeKind(fdi); + const gid = `${uid}-g-${fdi}-${i}`; + const isSel = selected.has(fdi); + const isConnected = Boolean(connectedTeeth?.has(fdi)); + const size = toothSizeClass(fdi); + const tweak = TOOTH_TWEAKS[fdi]; + const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper); + const rotate = tweak ? tweak.rotate : archRotate(i, teeth.length); + const alignItems = upper ? 'items-end' : 'items-start'; + const accent = toothAccent(fdi); + + const glyph = ( + + ); + + return ( +
+
+ {interactive ? ( + + ) : ( +
+ {glyph} +
+ )} +
+
+ ); + })} +
+ {!upper ? : null}
); diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index b8b4565..f430e44 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -41,6 +41,7 @@ import { isLabDependentDetailMissingTeeth, } from '@/components/treatment/treatmentDetailRules'; import { + applyCtrlRange, applyShiftRange, connectedTeethSet, deriveTeethFromGroups, @@ -1937,14 +1938,17 @@ export function TreatmentWorkspace({ let nextGroups: ReturnType | null = null; - if (event.shiftKey) { + // Shift wins if both modifiers are held (connected span). + if (event.shiftKey || event.ctrlKey) { const anchor = rangeAnchorRef.current; - // Need a prior click as range start; shift alone on one tooth does nothing. + // Need a prior click as range start; modifier alone on one tooth does nothing. if (!anchor || anchor === fdi) { rangeAnchorRef.current = fdi; return; } - nextGroups = applyShiftRange(currentGroups, anchor, fdi); + nextGroups = event.shiftKey + ? applyShiftRange(currentGroups, anchor, fdi) + : applyCtrlRange(currentGroups, anchor, fdi); rangeAnchorRef.current = fdi; if (!nextGroups) return; } else {