bugfix: Treatment detail auto-fill bug fixed.

This commit is contained in:
2026-08-22 18:51:57 +03:30
parent de46da216f
commit f52ad6b84a
8 changed files with 109 additions and 213 deletions

View File

@@ -17,9 +17,9 @@ alwaysApply: false
- **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.
- **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`. - **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`.
- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — `LabShipmentBlockedNotice`, inline banner, toast on add shipment. - **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — same inline amber banner on Treatment and Lab (`labBlockedBannerClass`); toast on add shipment. Empty plan (`[]`) still shows type-first overlay (not a blank Add-only card).
- **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`). - **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`).
- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted. - **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted. New dispatch lines start empty (lab + apply-all / per-tooth prosthesis type). Chips = last 3 **sent** labs (`labDispatchDefaults.ts`) — never auto-select. Clear the search box when switching details.
- **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only. - **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only.
Full map: `.cursor/skills/treatment-workspace/SKILL.md` Full map: `.cursor/skills/treatment-workspace/SKILL.md`

View File

@@ -41,11 +41,11 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet
| Stage | UI | When | | Stage | UI | When |
|-------|-----|------| |-------|-----|------|
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` (Cases scale), full-width Notes | Always | | **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` (Cases scale), full-width Notes | Always |
| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. Last-used lab and prosthesis type are remembered. Comments stay on the dispatch panel. | | **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. |
- Prosthesis uses `WizardStepper` (Treatment → Lab) with Back/Next. Lab dispatch keeps comments. - Prosthesis uses `WizardStepper` (Treatment → Lab) with Back/Next. Lab dispatch keeps comments.
- Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch. - Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch.
- Detail type may differ from appointment purpose (purpose only defaults new details). - Detail type may differ from appointment purpose. Purpose only seeds the **first** line of an empty draft — **Add detail** starts with an empty type.
- Switching `activeDetailId` resets to the treatment form, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus). - Switching `activeDetailId` resets to the treatment form, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus).
- Live draft is **not** duplicated in the left rail preview; preview is for history browse only. - Live draft is **not** duplicated in the left rail preview; preview is for history browse only.
@@ -120,7 +120,7 @@ Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`.
Saved lab-dependent detail with **no teeth** can autosave but **cannot** create a lab shipment. Saved lab-dependent detail with **no teeth** can autosave but **cannot** create a lab shipment.
- Inline banner in `TreatmentDetailsEditor` + `LabShipmentBlockedNotice` above dispatch when active detail qualifies (`isLabDependentDetailMissingTeeth`). - Inline amber banner in `TreatmentDetailsEditor` on **both** Treatment and Lab steps when the active detail qualifies (`isLabDependentDetailMissingTeeth`). Do not use a separate notice card.
- `handleAddLabCase` shows toast with `labShipmentBlockedBody`. - `handleAddLabCase` shows toast with `labShipmentBlockedBody`.
- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth). - Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth).
@@ -155,7 +155,7 @@ On today: in-progress slot first, else nearest start time to `now`. Other days:
`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops. `LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). Chips under the search are the last **3 labs this clinic sent a case to** (`rememberRecentLab` after successful send). They are shortcuts, not defaults: a new details lab and prosthesis type (apply-all and per-tooth) stay empty until the user chooses. Switching details clears the search box. No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops.

View File

@@ -53,8 +53,9 @@ frontend/src/
- **Schedule date:** Treatment strip and Appointments page headers use `ScheduleDayPicker` `compact` (date centered in a 3-col header; no “Schedule date” label; Today on the navigator). - **Schedule date:** Treatment strip and Appointments page headers use `ScheduleDayPicker` `compact` (date centered in a 3-col header; no “Schedule date” label; Today on the navigator).
**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** — same inline amber banner (`labShipmentBlockedBody`) on Treatment **and** Lab steps; toast on dispatch add.
- Detail treatment type need **not** match appointment purpose — purpose only pre-fills new details. - New prosthesis dispatch lines start **empty** (no default lab, no apply-all / per-tooth prosthesis type), even for siblings in the same plan. Recent-lab chips are the last **3 sent** destinations — pick is explicit, never auto-selected.
- Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty draft; **Add detail** starts with an empty type.
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering. - **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the **Lab** send sheet. - **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the **Lab** send sheet.
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit). - **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit).

View File

@@ -1,73 +1,60 @@
const STORAGE_PREFIX = 'dyolink.labDispatchDefaults.'; const STORAGE_PREFIX = 'dyolink.labDispatchDefaults.';
export type LabDispatchDefaults = { /** Chips under lab search — last destinations this clinic actually sent a case to. */
lastLabId: string | null; export const MAX_RECENT_LABS = 3;
lastProsthesisByLab: Record<string, string>;
};
const EMPTY: LabDispatchDefaults = {
lastLabId: null,
lastProsthesisByLab: {},
};
function storageKey(clinicOrganizationId: string): string { function storageKey(clinicOrganizationId: string): string {
return `${STORAGE_PREFIX}${clinicOrganizationId}`; return `${STORAGE_PREFIX}${clinicOrganizationId}`;
} }
export function loadLabDispatchDefaults(clinicOrganizationId: string | null | undefined): LabDispatchDefaults { function uniqueIds(ids: string[]): string[] {
if (!clinicOrganizationId || typeof window === 'undefined') return EMPTY; const out: string[] = [];
for (const id of ids) {
if (id && !out.includes(id)) out.push(id);
}
return out;
}
export function loadRecentLabIds(clinicOrganizationId: string | null | undefined): string[] {
if (!clinicOrganizationId || typeof window === 'undefined') return [];
try { try {
const raw = window.localStorage.getItem(storageKey(clinicOrganizationId)); const raw = window.localStorage.getItem(storageKey(clinicOrganizationId));
if (!raw) return EMPTY; if (!raw) return [];
const parsed = JSON.parse(raw) as Partial<LabDispatchDefaults>; const parsed = JSON.parse(raw) as {
return { recentLabIds?: unknown;
lastLabId: typeof parsed.lastLabId === 'string' ? parsed.lastLabId : null, lastLabId?: unknown;
lastProsthesisByLab:
parsed.lastProsthesisByLab && typeof parsed.lastProsthesisByLab === 'object'
? parsed.lastProsthesisByLab
: {},
}; };
const fromList = Array.isArray(parsed.recentLabIds)
? parsed.recentLabIds.filter((id): id is string => typeof id === 'string')
: [];
// Older builds stored a single lastLabId used as an auto-selected default.
const fromLegacy = typeof parsed.lastLabId === 'string' ? [parsed.lastLabId] : [];
return uniqueIds([...fromList, ...fromLegacy]).slice(0, MAX_RECENT_LABS);
} catch { } catch {
return EMPTY; return [];
} }
} }
function writeDefaults(clinicOrganizationId: string, next: LabDispatchDefaults): void { function writeRecentLabIds(clinicOrganizationId: string, recentLabIds: string[]): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
window.localStorage.setItem(storageKey(clinicOrganizationId), JSON.stringify(next)); window.localStorage.setItem(
storageKey(clinicOrganizationId),
JSON.stringify({ recentLabIds }),
);
} catch { } catch {
// Ignore quota / private-mode failures. // Ignore quota / private-mode failures.
} }
} }
export function rememberLastLab(clinicOrganizationId: string | null | undefined, labId: string): void { /** Record a lab after a successful send — suggestion chips only, never a form default. */
if (!clinicOrganizationId || !labId) return; export function rememberRecentLab(
const current = loadLabDispatchDefaults(clinicOrganizationId);
writeDefaults(clinicOrganizationId, { ...current, lastLabId: labId });
}
export function rememberLastProsthesisType(
clinicOrganizationId: string | null | undefined, clinicOrganizationId: string | null | undefined,
labId: string, labId: string,
prosthesisTypeCode: string,
): void { ): void {
if (!clinicOrganizationId || !labId || !prosthesisTypeCode) return; if (!clinicOrganizationId || !labId) return;
const current = loadLabDispatchDefaults(clinicOrganizationId); writeRecentLabIds(
writeDefaults(clinicOrganizationId, { clinicOrganizationId,
...current, uniqueIds([labId, ...loadRecentLabIds(clinicOrganizationId)]).slice(0, MAX_RECENT_LABS),
lastLabId: labId, );
lastProsthesisByLab: {
...current.lastProsthesisByLab,
[labId]: prosthesisTypeCode,
},
});
}
export function lastProsthesisTypeForLab(
clinicOrganizationId: string | null | undefined,
labId: string | null | undefined,
): string | null {
if (!clinicOrganizationId || !labId) return null;
return loadLabDispatchDefaults(clinicOrganizationId).lastProsthesisByLab[labId] ?? null;
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Checkbox } from '@/components/ui/shared/Checkbox';
@@ -16,12 +16,6 @@ import { LabCaseTrackerCard } from '@/components/ui/treatment/LabCaseTrackerCard
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import { treatmentsApi } from '@/lib/api/treatments'; import { treatmentsApi } from '@/lib/api/treatments';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import {
lastProsthesisTypeForLab,
loadLabDispatchDefaults,
rememberLastLab,
rememberLastProsthesisType,
} from '@/components/treatment/labDispatchDefaults';
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment'; import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
import type { PatientLabCaseSummary } from '@/types/lab-case-activity'; import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
@@ -34,7 +28,6 @@ interface LabCasesDispatchPanelProps {
labCases: LabCaseDraft[]; labCases: LabCaseDraft[];
labDependentCodes: Set<string>; labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[]; treatmentCatalog: TreatmentCatalogEntry[];
clinicOrganizationId?: string | null;
labCaseSummary?: PatientLabCaseSummary | null; labCaseSummary?: PatientLabCaseSummary | null;
locale: string; locale: string;
onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void; onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void;
@@ -48,7 +41,6 @@ interface LabCasesDispatchPanelProps {
organizationSearch: string; organizationSearch: string;
onOrganizationSearchChange: (value: string) => void; onOrganizationSearchChange: (value: string) => void;
recentOrganizationIds: string[]; recentOrganizationIds: string[];
onRecentOrganizationPick: (orgId: string) => void;
canInviteLab?: boolean; canInviteLab?: boolean;
onInviteLab?: () => void; onInviteLab?: () => void;
sendBusyId: string | null; sendBusyId: string | null;
@@ -124,7 +116,6 @@ export function LabCasesDispatchPanel({
labCases, labCases,
labDependentCodes, labDependentCodes,
treatmentCatalog, treatmentCatalog,
clinicOrganizationId,
labCaseSummary, labCaseSummary,
locale, locale,
onLabCaseSummaryChange, onLabCaseSummaryChange,
@@ -138,7 +129,6 @@ export function LabCasesDispatchPanel({
organizationSearch, organizationSearch,
onOrganizationSearchChange, onOrganizationSearchChange,
recentOrganizationIds, recentOrganizationIds,
onRecentOrganizationPick,
canInviteLab = false, canInviteLab = false,
onInviteLab, onInviteLab,
sendBusyId, sendBusyId,
@@ -150,7 +140,6 @@ export function LabCasesDispatchPanel({
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]); const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState(''); const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const [pendingComment, setPendingComment] = useState(''); const [pendingComment, setPendingComment] = useState('');
const autoFilledCaseRef = useRef<string | null>(null);
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId); const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
const activeLinkedOrganizations = orgs.filter((o) => o.active); const activeLinkedOrganizations = orgs.filter((o) => o.active);
@@ -168,7 +157,14 @@ export function LabCasesDispatchPanel({
const activeLabCase = const activeLabCase =
labCaseForActiveDetail ?? labCaseForActiveDetail ??
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null); (activeLabCaseId
? labCases.find(
(lc) =>
lc.clientId === activeLabCaseId &&
(lc.detailClientId == null || lc.detailClientId === activeDetailId),
)
: null) ??
null;
const sent = Boolean(activeLabCase?.sentAt); const sent = Boolean(activeLabCase?.sentAt);
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1; const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
@@ -208,6 +204,7 @@ export function LabCasesDispatchPanel({
useEffect(() => { useEffect(() => {
setPendingComment(''); setPendingComment('');
setApplyAllProsthesis('');
}, [activeLabCase?.clientId]); }, [activeLabCase?.clientId]);
function updateActiveLabCase(patch: Partial<LabCaseDraft>) { function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
@@ -226,45 +223,6 @@ export function LabCasesDispatchPanel({
// eslint-disable-next-line react-hooks/exhaustive-deps -- only sync newly uploaded files // eslint-disable-next-line react-hooks/exhaustive-deps -- only sync newly uploaded files
}, [activeDetail?.attachmentMetas, activeLabCase?.clientId, sent]); }, [activeDetail?.attachmentMetas, activeLabCase?.clientId, sent]);
useEffect(() => {
if (!activeLabCase || sent || activeLabCase.destinationOrganizationId) return;
const lastLabId = loadLabDispatchDefaults(clinicOrganizationId).lastLabId;
const lastLab = lastLabId
? activeLinkedOrganizations.find((o) => o.id === lastLabId)
: undefined;
if (!lastLab) return;
updateActiveLabCase({ destinationOrganizationId: lastLab.id, toothProsthesis: [] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeLabCase?.clientId, clinicOrganizationId, sent]);
useEffect(() => {
if (!activeLabCase || sent) return;
if (!activeLabCase.destinationOrganizationId) return;
if (prosthesisOptions.length === 0 || prosthesisRows.length === 0) return;
const fillKey = `${activeLabCase.clientId}:${prosthesisRows.length}`;
if (autoFilledCaseRef.current === fillKey) return;
if (isProsthesisMapComplete(activeLabCase, prosthesisRows)) {
autoFilledCaseRef.current = fillKey;
return;
}
const lastCode = lastProsthesisTypeForLab(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
);
if (!lastCode || !prosthesisOptions.some((opt) => opt.code === lastCode)) return;
autoFilledCaseRef.current = fillKey;
setApplyAllProsthesis(lastCode);
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, lastCode) });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeLabCase?.clientId,
activeLabCase?.destinationOrganizationId,
clinicOrganizationId,
prosthesisOptions,
prosthesisRows.length,
sent,
]);
if (!activeDetail || !isLabDependentDetail) { if (!activeDetail || !isLabDependentDetail) {
return null; return null;
} }
@@ -287,25 +245,11 @@ export function LabCasesDispatchPanel({
] ]
: rest; : rest;
updateActiveLabCase({ toothProsthesis: next }); updateActiveLabCase({ toothProsthesis: next });
if (prosthesisTypeCode && activeLabCase.destinationOrganizationId) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
prosthesisTypeCode,
);
}
} }
function applyProsthesisToAll(code: string) { function applyProsthesisToAll(code: string) {
if (!activeLabCase || !code) return; if (!activeLabCase || !code) return;
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, code) }); updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, code) });
if (activeLabCase.destinationOrganizationId) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
code,
);
}
} }
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) { function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
@@ -317,13 +261,11 @@ export function LabCasesDispatchPanel({
} }
function handleSelectOrganization(org: LinkedOrganizationOption) { function handleSelectOrganization(org: LinkedOrganizationOption) {
autoFilledCaseRef.current = null;
updateActiveLabCase({ updateActiveLabCase({
destinationOrganizationId: org.id, destinationOrganizationId: org.id,
toothProsthesis: [], toothProsthesis: [],
}); });
setApplyAllProsthesis(''); setApplyAllProsthesis('');
rememberLastLab(clinicOrganizationId, org.id);
} }
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress); const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
@@ -463,10 +405,7 @@ export function LabCasesDispatchPanel({
key={o.id} key={o.id}
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => { onClick={() => handleSelectOrganization(o)}
handleSelectOrganization(o);
onRecentOrganizationPick(o.id);
}}
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50" className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
> >
{o.name} {o.name}
@@ -615,26 +554,7 @@ export function LabCasesDispatchPanel({
!prosthesisComplete !prosthesisComplete
} }
isLoading={sendBusyId === activeLabCase.clientId} isLoading={sendBusyId === activeLabCase.clientId}
onClick={() => { onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
if (activeLabCase.destinationOrganizationId) {
rememberLastLab(clinicOrganizationId, activeLabCase.destinationOrganizationId);
const codes = [
...new Set(
activeLabCase.toothProsthesis
.map((tp) => tp.prosthesisTypeCode)
.filter(Boolean),
),
];
if (codes.length === 1) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
codes[0],
);
}
}
return onSendLabCase(activeLabCase, pendingComment.trim());
}}
> >
{t('sendToLab')} {t('sendToLab')}
</Button> </Button>

View File

@@ -1,20 +0,0 @@
'use client';
import { AlertCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
export function LabShipmentBlockedNotice() {
const t = useTranslations('treatment');
return (
<div className="surface-card p-4 border border-amber-500/35 bg-amber-500/5">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5 icon-flat" />
<div className="min-w-0 space-y-1">
<h3 className="text-sm font-semibold text-text-primary">{t('labShipmentBlockedTitle')}</h3>
<p className="text-xs text-text-muted">{t('labShipmentBlockedBody')}</p>
</div>
</div>
</div>
);
}

View File

@@ -28,7 +28,7 @@ interface TreatmentDetailsEditorProps {
canEdit: boolean; canEdit: boolean;
saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error'; saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
uploadBusy: boolean; uploadBusy: boolean;
onAddDetail: () => void; onAddDetail: (initialType?: string) => void;
onRemoveDetail?: (detailClientId: string) => void; onRemoveDetail?: (detailClientId: string) => void;
onUploadFiles: (files: File[], onProgress: (percent: number) => void) => Promise<void>; onUploadFiles: (files: File[], onProgress: (percent: number) => void) => Promise<void>;
onRemoveAttachment?: (attachmentId: string) => void; onRemoveAttachment?: (attachmentId: string) => void;
@@ -113,7 +113,7 @@ export function TreatmentDetailsEditor({
type="button" type="button"
variant="primary" variant="primary"
disabled={!canEdit || disabled} disabled={!canEdit || disabled}
onClick={onAddDetail} onClick={() => onAddDetail()}
fullWidth fullWidth
className="sm:w-auto shrink-0" className="sm:w-auto shrink-0"
> >
@@ -203,12 +203,12 @@ export function TreatmentDetailsEditor({
{stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null} {stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null}
{activeDetail && showMissingTeethLabBlock ? (
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
) : null}
{showFields && activeDetail ? ( {showFields && activeDetail ? (
<div className="space-y-3"> <div className="space-y-3">
{showMissingTeethLabBlock && (
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
<Dropdown <Dropdown
label={t('treatmentType')} label={t('treatmentType')}
@@ -268,6 +268,35 @@ export function TreatmentDetailsEditor({
{footer ? <div className="pt-1">{footer}</div> : null} {footer ? <div className="pt-1">{footer}</div> : null}
</div> </div>
) : showFields && !activeDetail ? (
<div className="space-y-3">
<Dropdown
label={t('treatmentType')}
value=""
onChange={(e) => {
const nextType = e.target.value;
if (nextType) onAddDetail(nextType);
}}
disabled={!canEdit || disabled}
>
<option value="">{t('treatmentTypePlaceholder')}</option>
{treatmentCatalog.map((entry, index) => (
<option key={entry.code} value={entry.code} style={treatmentTypeOptionStyle(entry.code, index)}>
{entry.label}
</option>
))}
</Dropdown>
{chart ? (
<div className="relative min-w-0 w-full">
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-[var(--radius-md)] bg-background-card/70 px-4">
<p className="text-sm text-text-secondary text-center">{chartLockMessage}</p>
</div>
<div className="pointer-events-none opacity-40">{chart}</div>
</div>
) : chartLockMessage ? (
<p className="text-sm text-text-secondary">{chartLockMessage}</p>
) : null}
</div>
) : null} ) : null}
</div> </div>
); );

View File

@@ -15,7 +15,6 @@ import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { NewTreatmentPatientPicker } from '@/components/ui/treatment/NewTreatmentPatientPicker'; import { NewTreatmentPatientPicker } from '@/components/ui/treatment/NewTreatmentPatientPicker';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel'; import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
import { LabShipmentBlockedNotice } from '@/components/ui/treatment/LabShipmentBlockedNotice';
import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel'; import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel'; import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor'; import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
@@ -57,7 +56,11 @@ import {
} from '@/components/treatment/toothSelectionGroups'; } from '@/components/treatment/toothSelectionGroups';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention'; import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention'; import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import { loadLabDispatchDefaults, rememberLastLab } from '@/components/treatment/labDispatchDefaults'; import {
loadRecentLabIds,
MAX_RECENT_LABS,
rememberRecentLab,
} from '@/components/treatment/labDispatchDefaults';
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions'; import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain'; import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts'; import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
@@ -735,7 +738,7 @@ export function TreatmentWorkspace({
mapped.length > 0 mapped.length > 0
? mapped ? mapped
: options?.seedBlankIfEmpty : options?.seedBlankIfEmpty
? [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))] ? [newDetail()]
: []; : [];
setDetails(nextDetails); setDetails(nextDetails);
setActiveDetailId((prev) => { setActiveDetailId((prev) => {
@@ -750,7 +753,7 @@ export function TreatmentWorkspace({
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null); setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch(''); setOrganizationSearch('');
setSaveStatus('idle'); setSaveStatus('idle');
}, [treatmentCatalog]); }, []);
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]); const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const connectedSelectedTeeth = useMemo( const connectedSelectedTeeth = useMemo(
@@ -803,6 +806,7 @@ export function TreatmentWorkspace({
useEffect(() => { useEffect(() => {
setShowWholeTreatmentPlan(false); setShowWholeTreatmentPlan(false);
rangeAnchorRef.current = null; rangeAnchorRef.current = null;
setOrganizationSearch('');
const pending = pendingEntryStepRef.current; const pending = pendingEntryStepRef.current;
pendingEntryStepRef.current = null; pendingEntryStepRef.current = null;
setEntryStep(pending ?? 'treatment'); setEntryStep(pending ?? 'treatment');
@@ -910,11 +914,11 @@ export function TreatmentWorkspace({
setLabDependentCodes( setLabDependentCodes(
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)), new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
); );
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId; const recentIds = loadRecentLabIds(currentOrganization?.id).filter((id) =>
if (lastLabId && orgsResponse.data.some((o) => o.id === lastLabId && o.active)) { orgsResponse.data.some((o) => o.id === id && o.active),
setRecentOrganizationIds((prev) =>
prev.includes(lastLabId) ? prev : [lastLabId, ...prev].slice(0, 10),
); );
if (recentIds.length > 0) {
setRecentOrganizationIds(recentIds);
} }
} catch (error: unknown) { } catch (error: unknown) {
if (!cancelled) { if (!cancelled) {
@@ -1959,14 +1963,9 @@ export function TreatmentWorkspace({
return; return;
} }
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
const lastLabStillActive = lastLabId
? orgs.some((o) => o.id === lastLabId && o.active)
: false;
const next: LabCaseDraft = { const next: LabCaseDraft = {
...newLabCaseDraft(), ...newLabCaseDraft(),
detailClientId: shouldIncludeActive ? activeDetailId : null, detailClientId: shouldIncludeActive ? activeDetailId : null,
destinationOrganizationId: lastLabStillActive ? lastLabId! : null,
attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [], attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [],
}; };
const updatedLabCases = [...cleaned, next]; const updatedLabCases = [...cleaned, next];
@@ -1982,11 +1981,9 @@ export function TreatmentWorkspace({
}, [ }, [
activeDetailId, activeDetailId,
canEditTreatmentForDay, canEditTreatmentForDay,
currentOrganization?.id,
details, details,
labCaseDrafts, labCaseDrafts,
labDependentCodes, labDependentCodes,
orgs,
persistDraft, persistDraft,
persistLabCases, persistLabCases,
selectedAppointment, selectedAppointment,
@@ -2106,8 +2103,8 @@ export function TreatmentWorkspace({
setRecentOrganizationIds((prev) => { setRecentOrganizationIds((prev) => {
const orgId = labCase.destinationOrganizationId!; const orgId = labCase.destinationOrganizationId!;
rememberLastLab(currentOrganization?.id, orgId); rememberRecentLab(currentOrganization?.id, orgId);
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10); return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, MAX_RECENT_LABS);
}); });
showSuccess(t('successCaseSent')); showSuccess(t('successCaseSent'));
notifyTabBadgesChanged(); notifyTabBadgesChanged();
@@ -2345,10 +2342,8 @@ export function TreatmentWorkspace({
canEdit={canEdit} canEdit={canEdit}
saveStatus={saveStatus} saveStatus={saveStatus}
uploadBusy={uploadBusyDetailId === activeDetailId} uploadBusy={uploadBusyDetailId === activeDetailId}
onAddDetail={() => { onAddDetail={(initialType) => {
const next = newDetail( const next = newDetail(initialType);
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
);
setDetails((prev) => [...prev, next]); setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId); setActiveDetailId(next.clientId);
setEntryStep('treatment'); setEntryStep('treatment');
@@ -2587,7 +2582,6 @@ export function TreatmentWorkspace({
{entryStep === 'lab' ? ( {entryStep === 'lab' ? (
<div ref={labPanelRef} className="space-y-3"> <div ref={labPanelRef} className="space-y-3">
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
{showLabDispatchPanel ? ( {showLabDispatchPanel ? (
<LabCasesDispatchPanel <LabCasesDispatchPanel
details={details} details={details}
@@ -2595,7 +2589,6 @@ export function TreatmentWorkspace({
labCases={labCaseDrafts} labCases={labCaseDrafts}
labDependentCodes={labDependentCodes} labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog} treatmentCatalog={treatmentCatalog}
clinicOrganizationId={currentOrganization?.id}
labCaseSummary={activeLabCaseSummary} labCaseSummary={activeLabCaseSummary}
locale={locale} locale={locale}
onLabCaseSummaryChange={handleLabCaseSummaryChange} onLabCaseSummaryChange={handleLabCaseSummaryChange}
@@ -2613,27 +2606,13 @@ export function TreatmentWorkspace({
organizationSearch={organizationSearch} organizationSearch={organizationSearch}
onOrganizationSearchChange={setOrganizationSearch} onOrganizationSearchChange={setOrganizationSearch}
recentOrganizationIds={recentOrganizationIds} recentOrganizationIds={recentOrganizationIds}
onRecentOrganizationPick={(orgId) => {
setLabCaseDrafts((prev) => {
const targetId =
activeLabCaseId ??
prev.find((lc) => !lc.sentAt && lc.detailClientId === activeDetailId)
?.clientId;
if (!targetId) return prev;
return prev.map((lc) =>
lc.clientId === targetId && !lc.sentAt
? { ...lc, destinationOrganizationId: orgId }
: lc,
);
});
}}
sendBusyId={sendBusyId} sendBusyId={sendBusyId}
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)} onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
onCommentError={showError} onCommentError={showError}
canInviteLab={canAccessOrganizations} canInviteLab={canAccessOrganizations}
onInviteLab={() => router.push('/organizations?action=invite-lab')} onInviteLab={() => router.push('/organizations?action=invite-lab')}
/> />
) : ( ) : showLabShipmentBlocked ? null : (
<p className="text-sm text-text-muted surface-card p-4"> <p className="text-sm text-text-muted surface-card p-4">
{t('entryStepLabUnavailable')} {t('entryStepLabUnavailable')}
</p> </p>