improvement: fdi tooth chart selection modes polished. bugs related to teeth selecyion fixed.
This commit is contained in:
@@ -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