improvement: FDI chart tool selection modes updated. shift+ and crtl+ both are assigned with a selection mode.
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -803,7 +803,7 @@
|
|||||||
"confirmSend": "تأیید ارسال",
|
"confirmSend": "تأیید ارسال",
|
||||||
"toothChartTitle": "نمودار دندانها FDI",
|
"toothChartTitle": "نمودار دندانها FDI",
|
||||||
"toothChartTitleCompact": "نمودار دندان",
|
"toothChartTitleCompact": "نمودار دندان",
|
||||||
"toothChartHint": "برای انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک بازه متصل میسازد.",
|
"toothChartHint": "برای انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک بازه متصل میسازد. Ctrl+کلیک (⌘ در مک) به همان شکل هر دندان را جداگانه انتخاب میکند.",
|
||||||
"toothConnectedHint": "انتخاب متصل",
|
"toothConnectedHint": "انتخاب متصل",
|
||||||
"toothConnectedSuffix": " (متصل)",
|
"toothConnectedSuffix": " (متصل)",
|
||||||
"connectedBadge": "متصل",
|
"connectedBadge": "متصل",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user