improvement: fdi tooth chart selection modes polished. bugs related to teeth selecyion fixed.
This commit is contained in:
@@ -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 for a connected span. Ctrl-click (⌘ on Mac) the same way to select each tooth separately.",
|
||||
"toothChartHint": "Tap teeth to select or deselect. Shift-click another tooth in the same arch for a connected bridge. Click a bridge tooth to cut it out (still selected as a single); ends keep one bridge, middle splits into two.",
|
||||
"toothConnectedHint": "Connected selection",
|
||||
"toothConnectedSuffix": " (connected)",
|
||||
"connectedBadge": "Connected",
|
||||
|
||||
@@ -803,7 +803,7 @@
|
||||
"confirmSend": "تأیید ارسال",
|
||||
"toothChartTitle": "نمودار دندانها FDI",
|
||||
"toothChartTitleCompact": "نمودار دندان",
|
||||
"toothChartHint": "برای انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک بازه متصل میسازد. Ctrl+کلیک (⌘ در مک) به همان شکل هر دندان را جداگانه انتخاب میکند.",
|
||||
"toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک پل متصل میسازد. کلیک روی دندان داخل پل آن را جدا میکند (همچنان انتخابشده بهصورت تک)؛ انتها یک پل میماند، وسط به دو پل تقسیم میشود.",
|
||||
"toothConnectedHint": "انتخاب متصل",
|
||||
"toothConnectedSuffix": " (متصل)",
|
||||
"connectedBadge": "متصل",
|
||||
|
||||
@@ -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. Ctrl-klik (⌘ op Mac) op dezelfde manier om elke tand apart te selecteren.",
|
||||
"toothChartHint": "Tik op tanden om te selecteren of deselecteren. Shift-klik op een andere tand in dezelfde boog voor een verbonden brug. Klik op een brugtand om die eruit te snijden (blijft geselecteerd als single); uiteinden houden één brug, midden splitst in twee.",
|
||||
"toothConnectedHint": "Verbonden selectie",
|
||||
"toothConnectedSuffix": " (verbonden)",
|
||||
"connectedBadge": "Verbonden",
|
||||
|
||||
@@ -76,6 +76,15 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
);
|
||||
}
|
||||
|
||||
// Do not mount clinic-only pages for lab orgs (avoids PERMISSION_CLINIC_ONLY toasts during redirect).
|
||||
if (!canAccessDashboardRoute(currentOrganization, pathname)) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center app-web-bg">
|
||||
{t('loadingWorkspace')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="app-dashboard-shell flex h-[100dvh] app-web-bg text-text-primary">
|
||||
|
||||
@@ -11,10 +11,10 @@ export default function HomePage() {
|
||||
const t = useTranslations('landing');
|
||||
const tAuth = useTranslations('auth');
|
||||
const tCommon = useTranslations('common');
|
||||
const { user } = useAuth();
|
||||
const { user, isAuthReady } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-[100dvh] app-web-bg text-text-primary">
|
||||
<div className="min-h-[100dvh] flex flex-col app-web-bg text-text-primary">
|
||||
<header className="border-b border-border/70 bg-background-secondary/65 backdrop-blur-sm fixed top-0 w-full z-10">
|
||||
<div className="container mx-auto px-4 py-3 sm:py-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
|
||||
<Link href="/" className="text-xl sm:text-2xl font-semibold text-text-primary shrink-0">
|
||||
@@ -23,7 +23,9 @@ export default function HomePage() {
|
||||
|
||||
<div className="flex items-center gap-1.5 sm:gap-3 ml-auto shrink-0">
|
||||
<TopBarControls />
|
||||
{user ? (
|
||||
{!isAuthReady ? (
|
||||
<span className="inline-block h-9 w-24 sm:w-28 rounded-[var(--radius-md)] bg-background-card/60 animate-pulse" aria-hidden />
|
||||
) : user ? (
|
||||
<Link href="/today">
|
||||
<Button variant="primary" size="sm" className="sm:px-4 sm:py-2 sm:text-sm">
|
||||
{tAuth('dashboard')}
|
||||
@@ -48,7 +50,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto px-4 pt-28 sm:pt-32 pb-16 sm:pb-20">
|
||||
<main className="flex-1 container mx-auto px-4 pt-28 sm:pt-32 pb-16 sm:pb-20">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-semibold mb-4 sm:mb-6 leading-tight">
|
||||
{t('heroTitle')}
|
||||
@@ -59,7 +61,7 @@ export default function HomePage() {
|
||||
{t('heroSubtitle')}
|
||||
</p>
|
||||
|
||||
{!user && (
|
||||
{isAuthReady && !user && (
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-center gap-3">
|
||||
<Link href="/register" className="w-full sm:w-auto">
|
||||
<Button size="lg" variant="primary" fullWidth className="sm:w-auto sm:px-8">
|
||||
@@ -109,7 +111,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-border/70 bg-background-secondary/80">
|
||||
<footer className="mt-auto border-t border-border/70 bg-background-secondary/80">
|
||||
<div className="container mx-auto px-4 py-8 flex flex-col md:flex-row justify-between items-center text-sm text-text-secondary">
|
||||
<div>{t('footerCopyright')}</div>
|
||||
|
||||
|
||||
@@ -23,6 +23,28 @@ export function deriveTeethFromGroups(groups: ToothSelectionGroup[]): FdiToothId
|
||||
return [...set].sort() as FdiToothId[];
|
||||
}
|
||||
|
||||
/** Never allow a 1-tooth connected group (no lone connected dots). */
|
||||
function normalizeGroupKind(kind: ToothSelectionKind, teeth: FdiToothId[]): ToothSelectionKind {
|
||||
if (kind === 'connected' && teeth.length < 2) return 'single';
|
||||
return kind;
|
||||
}
|
||||
|
||||
function makeGroup(kind: ToothSelectionKind, teeth: FdiToothId[]): ToothSelectionGroup | null {
|
||||
if (teeth.length === 0) return null;
|
||||
return {
|
||||
groupId: newGroupId(),
|
||||
kind: normalizeGroupKind(kind, teeth),
|
||||
teeth,
|
||||
};
|
||||
}
|
||||
|
||||
function sortInArchOrder(teeth: FdiToothId[]): FdiToothId[] {
|
||||
if (teeth.length === 0) return [];
|
||||
const arch = archOrder(teeth[0]);
|
||||
if (!arch) return [...teeth];
|
||||
return [...teeth].sort((a, b) => arch.indexOf(a) - arch.indexOf(b));
|
||||
}
|
||||
|
||||
/** Legacy / empty groups: each selected tooth becomes its own single group. */
|
||||
export function groupsFromFlatTeeth(
|
||||
teeth: readonly FdiToothId[],
|
||||
@@ -36,11 +58,10 @@ export function groupsFromFlatTeeth(
|
||||
teeth: g.teeth.filter((t) => flat.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,
|
||||
);
|
||||
.map((g) => ({
|
||||
...g,
|
||||
kind: normalizeGroupKind(g.kind, g.teeth),
|
||||
}));
|
||||
const covered = new Set(next.flatMap((g) => g.teeth));
|
||||
for (const tooth of teeth) {
|
||||
if (!covered.has(tooth)) {
|
||||
@@ -94,8 +115,11 @@ export function teethBetweenInclusive(a: FdiToothId, b: FdiToothId): FdiToothId[
|
||||
/**
|
||||
* Plain click:
|
||||
* - unselected → add as single
|
||||
* - selected single → remove
|
||||
* - selected in connected → drop the whole connected span and keep only this tooth as a single
|
||||
* - selected single → remove (deselect)
|
||||
* - in connected bridge → cut from bridge, keep selected as single:
|
||||
* - end tooth → remainder stays one bridge (or demotes if 1 left)
|
||||
* - middle tooth → left and right become separate bridges/singles
|
||||
* Never leaves a 1-tooth connected group (no lone dots).
|
||||
*/
|
||||
export function toggleToothInGroups(
|
||||
groups: ToothSelectionGroup[],
|
||||
@@ -110,15 +134,26 @@ export function toggleToothInGroups(
|
||||
return groups.filter((g) => g.groupId !== owning.groupId);
|
||||
}
|
||||
|
||||
// Collapse connected span: only the clicked tooth remains, as a separate single.
|
||||
const ordered = sortInArchOrder(owning.teeth);
|
||||
const idx = ordered.indexOf(tooth);
|
||||
if (idx < 0) return groups;
|
||||
|
||||
const left = ordered.slice(0, idx);
|
||||
const right = ordered.slice(idx + 1);
|
||||
const next = groups.filter((g) => g.groupId !== owning.groupId);
|
||||
|
||||
const leftGroup = makeGroup(left.length >= 2 ? 'connected' : 'single', left);
|
||||
const rightGroup = makeGroup(right.length >= 2 ? 'connected' : 'single', right);
|
||||
if (leftGroup) next.push(leftGroup);
|
||||
if (rightGroup) next.push(rightGroup);
|
||||
next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] });
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift-click creates a connected span between anchor and target on the same arch.
|
||||
* Any existing groups that intersect the span are replaced by the new connected group.
|
||||
* Overlapping existing bridges are merged (union) into one connected bridge.
|
||||
* Non-overlapping bridges are left alone. Singles inside the union are absorbed.
|
||||
*/
|
||||
export function applyShiftRange(
|
||||
groups: ToothSelectionGroup[],
|
||||
@@ -129,58 +164,47 @@ export function applyShiftRange(
|
||||
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,
|
||||
);
|
||||
|
||||
next.push({ groupId: newGroupId(), kind: 'connected', teeth: span });
|
||||
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] });
|
||||
const union = new Set<FdiToothId>(span);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const group of groups) {
|
||||
if (group.kind !== 'connected') continue;
|
||||
if (!group.teeth.some((t) => union.has(t))) continue;
|
||||
for (const t of group.teeth) {
|
||||
if (!union.has(t)) {
|
||||
union.add(t);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unionTeeth = sortInArchOrder([...union] as FdiToothId[]);
|
||||
if (unionTeeth.length < 2) return null;
|
||||
|
||||
const next = groups
|
||||
.map((g) => ({
|
||||
...g,
|
||||
teeth: g.teeth.filter((t) => !union.has(t)) as FdiToothId[],
|
||||
}))
|
||||
.filter((g) => g.teeth.length > 0)
|
||||
.map((g) => ({
|
||||
...g,
|
||||
kind: normalizeGroupKind(g.kind, g.teeth),
|
||||
}));
|
||||
|
||||
next.push({
|
||||
groupId: newGroupId(),
|
||||
kind: 'connected',
|
||||
teeth: unionTeeth,
|
||||
});
|
||||
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.
|
||||
* selectionGroupId so group changes do not leave stale group ids.
|
||||
*/
|
||||
export function pruneToothProsthesisForGroups<
|
||||
T extends { detailClientId: string; tooth: string; selectionGroupId?: string },
|
||||
@@ -213,7 +237,7 @@ export function normalizeToothSelectionGroups(value: unknown): ToothSelectionGro
|
||||
if (teeth.length === 0) continue;
|
||||
out.push({
|
||||
groupId,
|
||||
kind: kind === 'connected' && teeth.length < 2 ? 'single' : kind,
|
||||
kind: normalizeGroupKind(kind, teeth),
|
||||
teeth,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import type {
|
||||
PaginatedLabCases,
|
||||
} from '@/types/cases';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export function CasesPage() {
|
||||
const t = useTranslations('cases');
|
||||
@@ -308,9 +308,9 @@ export function CasesPage() {
|
||||
<p className="text-sm text-text-muted mt-1">{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(300px,380px)_1fr]">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(300px,380px)_1fr] lg:items-stretch">
|
||||
<section
|
||||
className={`rounded-lg border border-border bg-surface p-3 sm:p-4 space-y-3 flex flex-col min-h-0 ${
|
||||
className={`rounded-lg border border-border bg-surface p-3 sm:p-4 space-y-3 flex flex-col min-h-0 h-full lg:min-h-[420px] ${
|
||||
mobileDetailOpen && selectedCaseId ? 'hidden lg:flex' : 'flex'
|
||||
}`}
|
||||
>
|
||||
@@ -394,13 +394,13 @@ export function CasesPage() {
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{loadingList ? (
|
||||
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
|
||||
) : cases.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">{t('emptyList')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2 max-h-[55vh] overflow-y-auto pr-1">
|
||||
<ul className="space-y-2 pr-1">
|
||||
{cases.map((item) => {
|
||||
const isActive = item.id === selectedCaseId;
|
||||
|
||||
@@ -466,7 +466,7 @@ export function CasesPage() {
|
||||
</div>
|
||||
|
||||
{pagination.totalPages > 1 ? (
|
||||
<div className="flex items-center justify-between gap-2 pt-2 border-t border-border">
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 pt-2 border-t border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -208,9 +208,9 @@ export function LabCaseCommentsPanel({
|
||||
aria-pressed={visibleToClinic}
|
||||
>
|
||||
{visibleToClinic ? (
|
||||
<Eye className="h-4 w-4 text-white stroke-current" />
|
||||
<Eye className="lucide-on-primary h-4 w-4" color="#fff" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4 text-white stroke-current" />
|
||||
<EyeOff className="lucide-on-primary h-4 w-4" color="#fff" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
@@ -222,7 +222,7 @@ export function LabCaseCommentsPanel({
|
||||
title={t('send')}
|
||||
aria-label={t('send')}
|
||||
>
|
||||
<Send className="h-4 w-4 text-white stroke-current fill-none rtl:-scale-x-100" />
|
||||
<Send className="lucide-on-primary h-4 w-4 rtl:-scale-x-100" color="#fff" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ interface FdiToothChartProps {
|
||||
selected: ReadonlySet<FdiToothId>;
|
||||
/** Teeth that belong to a connected selection span (dots above/below). */
|
||||
connectedTeeth?: ReadonlySet<FdiToothId>;
|
||||
onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean; ctrlKey: boolean }) => void;
|
||||
onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void;
|
||||
disabled?: boolean;
|
||||
/** Non-interactive display (Cases / connection history). */
|
||||
readOnly?: boolean;
|
||||
@@ -144,7 +144,7 @@ export function FdiToothChart({
|
||||
return accent ? { color: accent } : undefined;
|
||||
};
|
||||
|
||||
/** Dots + thin links — only for Shift-connected spans (not plain / Ctrl singles). */
|
||||
/** Dots + thin links — only for Shift-connected spans (not plain singles). */
|
||||
const ConnectedRail = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => {
|
||||
const hasConnected = teeth.some((fdi) => connectedTeeth?.has(fdi));
|
||||
if (!hasConnected) return null;
|
||||
@@ -221,15 +221,12 @@ export function FdiToothChart({
|
||||
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();
|
||||
// Shift+click otherwise triggers browser text-selection / sticky focus boxes.
|
||||
if (e.shiftKey) e.preventDefault();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onToggle?.(fdi, {
|
||||
shiftKey: e.shiftKey,
|
||||
ctrlKey: e.ctrlKey || e.metaKey,
|
||||
});
|
||||
onToggle?.(fdi, { shiftKey: e.shiftKey });
|
||||
}}
|
||||
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
||||
className={`
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
isLabDependentDetailMissingTeeth,
|
||||
} from '@/components/treatment/treatmentDetailRules';
|
||||
import {
|
||||
applyCtrlRange,
|
||||
applyShiftRange,
|
||||
connectedTeethSet,
|
||||
deriveTeethFromGroups,
|
||||
@@ -267,6 +266,26 @@ function isDetailsDirty(
|
||||
return serializeDetails(details) !== savedSnapshot;
|
||||
}
|
||||
|
||||
/** Apply server ids/attachments onto local rows without replacing newer local edits. */
|
||||
function mergeServerIdsIntoDetails(
|
||||
local: TreatmentDetailDraft[],
|
||||
fromServer: TreatmentDetailDraft[],
|
||||
): TreatmentDetailDraft[] {
|
||||
const byClientId = new Map(fromServer.map((d) => [d.clientId, d]));
|
||||
return local.map((d) => {
|
||||
const s = byClientId.get(d.clientId);
|
||||
if (!s) return d;
|
||||
return {
|
||||
...d,
|
||||
id: s.id ?? d.id,
|
||||
labCaseId: s.labCaseId ?? d.labCaseId,
|
||||
taskProgress: s.taskProgress ?? d.taskProgress,
|
||||
attachmentMetas:
|
||||
d.attachmentMetas.length > 0 ? d.attachmentMetas : s.attachmentMetas,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function detailsToPreviewTreatment(
|
||||
details: TreatmentDetailDraft[],
|
||||
meta: { title: string; patientId: string; treatmentAt: string; id?: string },
|
||||
@@ -387,6 +406,8 @@ export function TreatmentWorkspace({
|
||||
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
|
||||
const labPanelRef = useRef<HTMLDivElement>(null);
|
||||
const historyRequestRef = useRef(0);
|
||||
/** When set, activeDetailId effect opens this wizard step instead of resetting to teeth. */
|
||||
const pendingEntryStepRef = useRef<EntryStep | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
pendingAppointmentIdRef.current = initialAppointmentId;
|
||||
@@ -613,10 +634,13 @@ export function TreatmentWorkspace({
|
||||
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
|
||||
|
||||
// Reset whole-plan overview when switching details.
|
||||
// Prefer pendingEntryStepRef (e.g. Lab shipments → Lab step) over defaulting to teeth.
|
||||
useEffect(() => {
|
||||
setShowWholeTreatmentPlan(false);
|
||||
rangeAnchorRef.current = null;
|
||||
setEntryStep('teeth');
|
||||
const pending = pendingEntryStepRef.current;
|
||||
pendingEntryStepRef.current = null;
|
||||
setEntryStep(pending ?? 'teeth');
|
||||
}, [activeDetailId]);
|
||||
|
||||
// Leave Lab step if the active detail is no longer prosthesis / lab-dependent.
|
||||
@@ -927,24 +951,40 @@ export function TreatmentWorkspace({
|
||||
),
|
||||
});
|
||||
const mapped = response.data.details.map(mapDetailFromApi);
|
||||
setDetails(mapped);
|
||||
setActiveDetailId((prev) => {
|
||||
const stillExists = mapped.some((d) => d.clientId === prev);
|
||||
return stillExists ? prev : mapped[0]?.clientId ?? prev;
|
||||
});
|
||||
setSavedSnapshot(serializeDetails(mapped));
|
||||
const sentSnapshot = serializeDetails(currentDetails);
|
||||
const localNow = detailsRef.current;
|
||||
|
||||
// If the user changed details while this save was in flight (e.g. removed a
|
||||
// detail), do not clobber local state with the stale response.
|
||||
if (serializeDetails(localNow) === sentSnapshot) {
|
||||
setDetails(mapped);
|
||||
setActiveDetailId((prev) => {
|
||||
const stillExists = mapped.some((d) => d.clientId === prev);
|
||||
return stillExists ? prev : mapped[0]?.clientId ?? prev;
|
||||
});
|
||||
setSavedSnapshot(serializeDetails(mapped));
|
||||
} else {
|
||||
const merged = mergeServerIdsIntoDetails(localNow, mapped);
|
||||
detailsRef.current = merged;
|
||||
setDetails(merged);
|
||||
setActiveDetailId((prev) => {
|
||||
const stillExists = merged.some((d) => d.clientId === prev);
|
||||
return stillExists ? prev : merged[0]?.clientId ?? prev;
|
||||
});
|
||||
// Keep dirty so the queued autosave persists the newer local state.
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
[selectedAppointment, t],
|
||||
);
|
||||
|
||||
const refreshHistory = useCallback(async (patientId: string) => {
|
||||
const refreshHistory = useCallback(async (patientId: string, options?: { silentLabCases?: boolean }) => {
|
||||
const requestId = ++historyRequestRef.current;
|
||||
try {
|
||||
const response = await treatmentsApi.listPatientHistory(patientId, 50);
|
||||
if (requestId !== historyRequestRef.current) return;
|
||||
setHistory(response.data);
|
||||
void refreshPatientLabCases(patientId);
|
||||
void refreshPatientLabCases(patientId, { silent: options?.silentLabCases ?? false });
|
||||
} catch (error: unknown) {
|
||||
if (requestId !== historyRequestRef.current) return;
|
||||
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
|
||||
@@ -1119,10 +1159,16 @@ export function TreatmentWorkspace({
|
||||
|
||||
skipNextGetDraftRef.current = true;
|
||||
draftHydratingRef.current = true;
|
||||
if (focusDetailClientId && options?.scrollToLabPanel !== false) {
|
||||
pendingEntryStepRef.current = 'lab';
|
||||
}
|
||||
hydrateFromTreatment(treatmentToLoad);
|
||||
draftHydratingRef.current = false;
|
||||
|
||||
if (focusDetailClientId) {
|
||||
if (options?.scrollToLabPanel !== false) {
|
||||
pendingEntryStepRef.current = 'lab';
|
||||
}
|
||||
setActiveDetailId(focusDetailClientId);
|
||||
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
||||
(treatmentToLoad.labCases ?? []).map(mapLabCaseDraftFromApi),
|
||||
@@ -1187,6 +1233,7 @@ export function TreatmentWorkspace({
|
||||
(item: LabDispatchAttentionItem) => {
|
||||
if (item.isCurrentDraft) {
|
||||
exitBrowse();
|
||||
pendingEntryStepRef.current = 'lab';
|
||||
setActiveDetailId(item.detailClientId);
|
||||
const linked = labCaseDrafts.find(
|
||||
(lc) => !lc.sentAt && lc.detailClientId === item.detailClientId,
|
||||
@@ -1268,12 +1315,21 @@ export function TreatmentWorkspace({
|
||||
workspaceMode === 'live' &&
|
||||
!isBrowsing
|
||||
) {
|
||||
pendingEntryStepRef.current = 'lab';
|
||||
setActiveDetailId(item.detailClientId);
|
||||
const matchingDraft = labCaseDrafts.find((lc) => lc.id === item.labCaseId);
|
||||
if (matchingDraft) {
|
||||
setActiveLabCaseId(matchingDraft.clientId);
|
||||
}
|
||||
setEntryStep('lab');
|
||||
requestAnimationFrame(() => {
|
||||
scrollWithinMainScrollContainer(labPanelRef.current);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await loadTreatmentIntoWorkspace(treatment, item.detailClientId, {
|
||||
scrollToLabPanel: false,
|
||||
scrollToLabPanel: true,
|
||||
});
|
||||
})();
|
||||
},
|
||||
@@ -1284,6 +1340,7 @@ export function TreatmentWorkspace({
|
||||
history,
|
||||
historyPanelItems,
|
||||
isBrowsing,
|
||||
labCaseDrafts,
|
||||
loadTreatmentIntoWorkspace,
|
||||
selectedAppointment?.id,
|
||||
selectedAppointmentId,
|
||||
@@ -1456,6 +1513,9 @@ export function TreatmentWorkspace({
|
||||
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ??
|
||||
nextDetails[0]?.clientId)
|
||||
: activeDetailId;
|
||||
|
||||
// Sync ref before any persist triggered by lab-case cleanup (same tick).
|
||||
detailsRef.current = nextDetails;
|
||||
setDetails(nextDetails);
|
||||
if (nextActive) setActiveDetailId(nextActive);
|
||||
|
||||
@@ -1554,6 +1614,9 @@ export function TreatmentWorkspace({
|
||||
const activeDetail = details.find((d) => d.clientId === activeDetailId);
|
||||
if (!activeDetail || !isDetailReadyForLabDispatch(activeDetail, labDependentCodes)) return;
|
||||
if (isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)) return;
|
||||
// Already shipped (or locked) — do not create another draft (avoids post-send flicker).
|
||||
if (isDetailLocked(activeDetail)) return;
|
||||
if (labCaseDrafts.some((lc) => lc.detailClientId === activeDetailId && lc.sentAt)) return;
|
||||
const hasDraft = labCaseDrafts.some((lc) => lc.detailClientId === activeDetailId);
|
||||
if (hasDraft) return;
|
||||
void handleAddLabCase();
|
||||
@@ -1563,6 +1626,7 @@ export function TreatmentWorkspace({
|
||||
details,
|
||||
entryStep,
|
||||
handleAddLabCase,
|
||||
isDetailLocked,
|
||||
labCaseDrafts,
|
||||
labDependentCodes,
|
||||
selectedAppointment,
|
||||
@@ -1608,12 +1672,17 @@ export function TreatmentWorkspace({
|
||||
const draftResponse = await treatmentsApi.getDraft(selectedAppointment.id);
|
||||
if (draftResponse.data?.details?.length) {
|
||||
const mapped = draftResponse.data.details.map(mapDetailFromApi);
|
||||
detailsRef.current = mapped;
|
||||
setDetails(mapped);
|
||||
setActiveDetailId((prev) => {
|
||||
const stillExists = mapped.some((d) => d.clientId === prev);
|
||||
return stillExists ? prev : mapped[0]?.clientId ?? prev;
|
||||
});
|
||||
setSavedSnapshot(serializeDetails(mapped));
|
||||
} else {
|
||||
const sentDetailClientId = labCase.detailClientId;
|
||||
setDetails((prev) =>
|
||||
prev.map((detail) => {
|
||||
setDetails((prev) => {
|
||||
const next = prev.map((detail) => {
|
||||
if (detail.clientId !== sentDetailClientId) return detail;
|
||||
return {
|
||||
...detail,
|
||||
@@ -1624,8 +1693,10 @@ export function TreatmentWorkspace({
|
||||
? [response.data.destinationOrganizationId]
|
||||
: detail.sendToOrganizationIds,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
detailsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setLabCaseDrafts((prev) =>
|
||||
@@ -1637,6 +1708,7 @@ export function TreatmentWorkspace({
|
||||
sentAt: response.data.sentAt,
|
||||
destinationOrganizationId: response.data.destinationOrganizationId,
|
||||
sends: response.data.sends,
|
||||
taskProgress: response.data.taskProgress ?? lc.taskProgress,
|
||||
}
|
||||
: lc,
|
||||
),
|
||||
@@ -1649,8 +1721,8 @@ export function TreatmentWorkspace({
|
||||
showSuccess(t('successCaseSent'));
|
||||
notifyTabBadgesChanged();
|
||||
if (selectedAppointment.patientId) {
|
||||
void refreshPatientLabCases(selectedAppointment.patientId);
|
||||
void refreshHistory(selectedAppointment.patientId);
|
||||
// Silent: avoid rail/history loading flicker while staying on Lab step.
|
||||
void refreshHistory(selectedAppointment.patientId, { silentLabCases: true });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
showError(getUserFacingError(error, tErrors, t('errorSendCase')));
|
||||
@@ -1663,7 +1735,6 @@ export function TreatmentWorkspace({
|
||||
selectedAppointment,
|
||||
persistDraft,
|
||||
persistLabCases,
|
||||
refreshPatientLabCases,
|
||||
refreshHistory,
|
||||
showSuccess,
|
||||
showError,
|
||||
@@ -1959,17 +2030,14 @@ export function TreatmentWorkspace({
|
||||
|
||||
let nextGroups: ReturnType<typeof toggleToothInGroups> | null = null;
|
||||
|
||||
// Shift wins if both modifiers are held (connected span).
|
||||
if (event.shiftKey || event.ctrlKey) {
|
||||
if (event.shiftKey) {
|
||||
const anchor = rangeAnchorRef.current;
|
||||
// Need a prior click as range start; modifier alone on one tooth does nothing.
|
||||
// Need a prior click as range start; shift alone on one tooth does nothing.
|
||||
if (!anchor || anchor === fdi) {
|
||||
rangeAnchorRef.current = fdi;
|
||||
return;
|
||||
}
|
||||
nextGroups = event.shiftKey
|
||||
? applyShiftRange(currentGroups, anchor, fdi)
|
||||
: applyCtrlRange(currentGroups, anchor, fdi);
|
||||
nextGroups = applyShiftRange(currentGroups, anchor, fdi);
|
||||
rangeAnchorRef.current = fdi;
|
||||
if (!nextGroups) return;
|
||||
} else {
|
||||
|
||||
@@ -354,6 +354,12 @@ select option {
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
/* Opt out when an icon sits on a primary/filled control (Send, etc.) */
|
||||
.lucide.lucide-on-primary {
|
||||
color: #fff;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
@keyframes task-row-complete-exit {
|
||||
0% {
|
||||
opacity: 1;
|
||||
|
||||
Reference in New Issue
Block a user