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,91 +144,125 @@ export function FdiToothChart({
return accent ? { color: accent } : undefined; return accent ? { color: accent } : undefined;
}; };
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => ( /** Dots + thin links — only for Shift-connected spans (not plain / Ctrl singles). */
<div className="flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit"> const ConnectedRail = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => {
{teeth.map((fdi, i) => { const hasConnected = teeth.some((fdi) => connectedTeeth?.has(fdi));
const kind = getToothShapeKind(fdi); if (!hasConnected) return null;
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 (
<ToothGlyph <div
fdi={fdi} className={`flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit ${upper ? 'mb-1' : 'mt-1'}`}
kind={kind} aria-hidden
gradientId={gid} >
selected={isSel} {teeth.map((fdi, i) => {
upper={upper} const size = toothSizeClass(fdi);
mirrored={quadrantMirrored(fdi)} const isConnected = Boolean(connectedTeeth?.has(fdi));
upsideDown={upper} const next = teeth[i + 1];
className={tweak?.glyph ?? size.glyph} const linkToNext = Boolean(isConnected && next && connectedTeeth?.has(next));
accentColor={isSel ? accent : undefined} return (
/> <div
); key={`c-${fdi}`}
className={`relative flex h-2.5 items-center justify-center ${size.wrapper}`}
const connectedDot = isConnected ? ( title={isConnected ? t('toothConnectedHint') : undefined}
<span >
className={`block h-1.5 w-1.5 rounded-full bg-primary ${upper ? 'mb-0.5' : 'mt-0.5'}`} {linkToNext ? (
aria-hidden <span
title={t('toothConnectedHint')} 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)' }}
) : ( />
<span className={`block h-1.5 w-1.5 ${upper ? 'mb-0.5' : 'mt-0.5'}`} aria-hidden /> ) : null}
); {isConnected ? (
<span className="relative z-10 block h-2 w-2 rounded-full bg-primary shadow-sm" />
return (
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
{upper ? connectedDot : null}
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
{interactive ? (
<button
type="button"
disabled={isDisabled}
onMouseDown={(e) => {
// Shift+click otherwise triggers browser text-selection / sticky focus boxes.
if (e.shiftKey) e.preventDefault();
}}
onClick={(e) => {
e.preventDefault();
onToggle?.(fdi, { shiftKey: e.shiftKey });
}}
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className={`
rounded-[var(--radius-sm)] p-0.5 transition-transform select-none
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
`}
aria-pressed={isSel}
aria-label={
isSel
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}${
isConnected ? t('toothConnectedSuffix') : ''
}`
: t('toothAria', { fdi })
}
>
{glyph}
</button>
) : ( ) : (
<div <span className="block h-2 w-2" />
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className="rounded-[var(--radius-sm)] p-0.5"
aria-hidden={!isSel}
>
{glyph}
</div>
)} )}
</div> </div>
{!upper ? connectedDot : null} );
</div> })}
); </div>
})} );
};
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">
{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 = (
<ToothGlyph
fdi={fdi}
kind={kind}
gradientId={gid}
selected={isSel}
upper={upper}
mirrored={quadrantMirrored(fdi)}
upsideDown={upper}
className={tweak?.glyph ?? size.glyph}
accentColor={isSel ? accent : undefined}
/>
);
return (
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
{interactive ? (
<button
type="button"
disabled={isDisabled}
onMouseDown={(e) => {
// Modifier+click otherwise triggers browser text-selection / sticky focus boxes.
if (e.shiftKey || e.ctrlKey || e.metaKey) e.preventDefault();
}}
onClick={(e) => {
e.preventDefault();
onToggle?.(fdi, {
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey || e.metaKey,
});
}}
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className={`
rounded-[var(--radius-sm)] p-0.5 transition-transform select-none
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
`}
aria-pressed={isSel}
aria-label={
isSel
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}${
isConnected ? t('toothConnectedSuffix') : ''
}`
: t('toothAria', { fdi })
}
>
{glyph}
</button>
) : (
<div
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className="rounded-[var(--radius-sm)] p-0.5"
aria-hidden={!isSel}
>
{glyph}
</div>
)}
</div>
</div>
);
})}
</div>
{!upper ? <ConnectedRail teeth={teeth} /> : null}
</div> </div>
); );

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 {