improvement: FDI chart tool selection modes updated. shift+ and crtl+ both are assigned with a selection mode.

This commit is contained in:
2026-07-17 11:36:02 +03:30
parent 1b63bfff00
commit 58676f702e
9 changed files with 164 additions and 92 deletions

View File

@@ -9,7 +9,7 @@ alwaysApply: false
- **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load. - **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. - **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. - **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. - **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. - **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. - **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all.

View File

@@ -40,7 +40,7 @@ Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx
| Step | UI | Notes | | 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** | | **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`) | | **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`. 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). - **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`. - On group change, prune/remap `labCase.toothProsthesis` via `pruneToothProsthesisForGroups`.
- Connected UI label: `ConnectedSelectionBadge` (shared `Badge` + primary tint) in dispatch + lab case lists. - Connected UI label: `ConnectedSelectionBadge` (shared `Badge` + primary tint) in dispatch + lab case lists.

View File

@@ -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. - 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`). - 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). - **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):** **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. - Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add.

View File

@@ -809,7 +809,7 @@
"entryStepNext": "Next", "entryStepNext": "Next",
"entryStepLabUnavailable": "Lab dispatch is available after you save a lab-dependent detail with teeth selected.", "entryStepLabUnavailable": "Lab dispatch is available after you save a lab-dependent detail with teeth selected.",
"toothChartTitleCompact": "Tooth chart", "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", "toothConnectedHint": "Connected selection",
"toothConnectedSuffix": " (connected)", "toothConnectedSuffix": " (connected)",
"connectedBadge": "Connected", "connectedBadge": "Connected",

View File

@@ -803,7 +803,7 @@
"confirmSend": "تأیید ارسال", "confirmSend": "تأیید ارسال",
"toothChartTitle": "نمودار دندان‌ها FDI", "toothChartTitle": "نمودار دندان‌ها FDI",
"toothChartTitleCompact": "نمودار دندان", "toothChartTitleCompact": "نمودار دندان",
"toothChartHint": "برای انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک بازه متصل می‌سازد.", "toothChartHint": "برای انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک بازه متصل می‌سازد. Ctrl+کلیک (⌘ در مک) به همان شکل هر دندان را جداگانه انتخاب می‌کند.",
"toothConnectedHint": "انتخاب متصل", "toothConnectedHint": "انتخاب متصل",
"toothConnectedSuffix": " (متصل)", "toothConnectedSuffix": " (متصل)",
"connectedBadge": "متصل", "connectedBadge": "متصل",

View File

@@ -802,7 +802,7 @@
"confirmSend": "Bevestig verzending", "confirmSend": "Bevestig verzending",
"toothChartTitle": "FDI-tanddiagram", "toothChartTitle": "FDI-tanddiagram",
"toothChartTitleCompact": "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", "toothConnectedHint": "Verbonden selectie",
"toothConnectedSuffix": " (verbonden)", "toothConnectedSuffix": " (verbonden)",
"connectedBadge": "Verbonden", "connectedBadge": "Verbonden",

View File

@@ -146,6 +146,38 @@ export function applyShiftRange(
return next; 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 * Drop prosthesis rows for teeth no longer selected on a detail, and refresh
* selectionGroupId so connected→single collapses do not leave stale group ids. * selectionGroupId so connected→single collapses do not leave stale group ids.

View File

@@ -23,7 +23,7 @@ interface FdiToothChartProps {
selected: ReadonlySet<FdiToothId>; selected: ReadonlySet<FdiToothId>;
/** Teeth that belong to a connected selection span (dots above/below). */ /** Teeth that belong to a connected selection span (dots above/below). */
connectedTeeth?: ReadonlySet<FdiToothId>; connectedTeeth?: ReadonlySet<FdiToothId>;
onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void; onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean; ctrlKey: boolean }) => void;
disabled?: boolean; disabled?: boolean;
/** Non-interactive display (Cases / connection history). */ /** Non-interactive display (Cases / connection history). */
readOnly?: boolean; readOnly?: boolean;
@@ -144,7 +144,48 @@ export function FdiToothChart({
return accent ? { color: accent } : undefined; return accent ? { color: accent } : undefined;
}; };
/** 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;
return (
<div
className={`flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit ${upper ? 'mb-1' : 'mt-1'}`}
aria-hidden
>
{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 (
<div
key={`c-${fdi}`}
className={`relative flex h-2.5 items-center justify-center ${size.wrapper}`}
title={isConnected ? t('toothConnectedHint') : undefined}
>
{linkToNext ? (
<span
className="pointer-events-none absolute top-1/2 left-1/2 z-0 h-[2px] -translate-y-1/2 bg-primary/75"
style={{ width: 'calc(100% + 0.25rem)' }}
/>
) : null}
{isConnected ? (
<span className="relative z-10 block h-2 w-2 rounded-full bg-primary shadow-sm" />
) : (
<span className="block h-2 w-2" />
)}
</div>
);
})}
</div>
);
};
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => ( const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
<div className="flex flex-col items-center min-w-max mx-auto w-fit">
{upper ? <ConnectedRail teeth={teeth} upper /> : null}
<div className="flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit"> <div className="flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit">
{teeth.map((fdi, i) => { {teeth.map((fdi, i) => {
const kind = getToothShapeKind(fdi); const kind = getToothShapeKind(fdi);
@@ -172,31 +213,23 @@ export function FdiToothChart({
/> />
); );
const connectedDot = isConnected ? (
<span
className={`block h-1.5 w-1.5 rounded-full bg-primary ${upper ? 'mb-0.5' : 'mt-0.5'}`}
aria-hidden
title={t('toothConnectedHint')}
/>
) : (
<span className={`block h-1.5 w-1.5 ${upper ? 'mb-0.5' : 'mt-0.5'}`} aria-hidden />
);
return ( return (
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}> <div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
{upper ? connectedDot : null}
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}> <div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
{interactive ? ( {interactive ? (
<button <button
type="button" type="button"
disabled={isDisabled} disabled={isDisabled}
onMouseDown={(e) => { onMouseDown={(e) => {
// Shift+click otherwise triggers browser text-selection / sticky focus boxes. // Modifier+click otherwise triggers browser text-selection / sticky focus boxes.
if (e.shiftKey) e.preventDefault(); if (e.shiftKey || e.ctrlKey || e.metaKey) e.preventDefault();
}} }}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
onToggle?.(fdi, { shiftKey: e.shiftKey }); onToggle?.(fdi, {
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey || e.metaKey,
});
}} }}
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }} style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className={` className={`
@@ -225,11 +258,12 @@ export function FdiToothChart({
</div> </div>
)} )}
</div> </div>
{!upper ? connectedDot : null}
</div> </div>
); );
})} })}
</div> </div>
{!upper ? <ConnectedRail teeth={teeth} /> : null}
</div>
); );
const cardClass = compact const cardClass = compact

View File

@@ -41,6 +41,7 @@ import {
isLabDependentDetailMissingTeeth, isLabDependentDetailMissingTeeth,
} from '@/components/treatment/treatmentDetailRules'; } from '@/components/treatment/treatmentDetailRules';
import { import {
applyCtrlRange,
applyShiftRange, applyShiftRange,
connectedTeethSet, connectedTeethSet,
deriveTeethFromGroups, deriveTeethFromGroups,
@@ -1937,14 +1938,17 @@ export function TreatmentWorkspace({
let nextGroups: ReturnType<typeof toggleToothInGroups> | null = null; let nextGroups: ReturnType<typeof toggleToothInGroups> | null = null;
if (event.shiftKey) { // Shift wins if both modifiers are held (connected span).
if (event.shiftKey || event.ctrlKey) {
const anchor = rangeAnchorRef.current; 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) { if (!anchor || anchor === fdi) {
rangeAnchorRef.current = fdi; rangeAnchorRef.current = fdi;
return; return;
} }
nextGroups = applyShiftRange(currentGroups, anchor, fdi); nextGroups = event.shiftKey
? applyShiftRange(currentGroups, anchor, fdi)
: applyCtrlRange(currentGroups, anchor, fdi);
rangeAnchorRef.current = fdi; rangeAnchorRef.current = fdi;
if (!nextGroups) return; if (!nextGroups) return;
} else { } else {