diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 66fb8d5..1c05114 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -930,6 +930,7 @@ "voiceNotUnderstood": "Not understood", "voicePickTooth": "Which tooth?", "voicePickJaw": "Which jaw?", + "voiceTypeForcedByProsthesis": "Prosthesis work sets this type — it cannot be unticked.", "voiceTeethAndProsthesis": "Teeth and prosthesis", "voiceNoProsthesisHeard": "no prosthesis heard", "voiceStackRefused": "not added — cannot be combined", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 9dd9436..23f800d 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -931,6 +931,7 @@ "voiceNotUnderstood": "شناسایی نشد", "voicePickTooth": "کدام دندان؟", "voicePickJaw": "کدام فک؟", + "voiceTypeForcedByProsthesis": "کار پروتز این نوع درمان را تعیین می‌کند — قابل برداشتن نیست.", "voiceTeethAndProsthesis": "دندان‌ها و پروتز", "voiceNoProsthesisHeard": "پروتزی شنیده نشد", "voiceStackRefused": "افزوده نشد — قابل ترکیب نیست", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 08f94ed..749a281 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -930,6 +930,7 @@ "voiceNotUnderstood": "Niet begrepen", "voicePickTooth": "Welk element?", "voicePickJaw": "Welke kaak?", + "voiceTypeForcedByProsthesis": "Prothesewerk bepaalt dit type — dit kan niet worden uitgevinkt.", "voiceTeethAndProsthesis": "Elementen en prothese", "voiceNoProsthesisHeard": "geen prothese gehoord", "voiceStackRefused": "niet toegevoegd — kan niet worden gecombineerd", diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 6287483..dd659c9 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -62,10 +62,7 @@ import { unlinkAdjacentTeeth, } from '@/components/treatment/toothSelectionGroups'; import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree'; -import { - isLabDependentResult, - prosthesisTargetLines, -} from '@/components/treatment/voiceReviewRows'; +import { prosthesisTargetLines } from '@/components/treatment/voiceReviewRows'; import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention'; import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention'; import { @@ -2211,7 +2208,10 @@ export function TreatmentWorkspace({ // "teeth" independently of "prosthesis" could save an empty detail, since // `persistDraft` prunes a lab-dependent detail down to its jobs. `selection.prosthesis` // alone drives both below; `selection.teeth` only ever applies to the plain row. - const labDependent = isLabDependentResult(result, labDependentCodes); + // Derived from the detail actually being written, never from `result.treatmentType`. + // Reading the result meant that unticking the type row still took the prosthesis branch, + // saving lab rows on whatever type the appointment purpose had seeded (decision 41). + const labDependent = labDependentCodes.has(detail.treatmentType); if (!labDependent && selection.teeth) { detail.teeth = [...result.teeth]; detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({ diff --git a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx index 71bc0ad..072263f 100644 --- a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx +++ b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx @@ -110,6 +110,12 @@ export function VoiceReviewSheet({ () => prosthesisChartData(prosthesisLines, joblessTargets, prosthesisCatalog), [prosthesisLines, joblessTargets, prosthesisCatalog], ); + // Decision 41: prosthesis jobs exist on one treatment type only, so a ticked prosthesis row + // pins the type row. Locking it is what makes "restoration detail carrying lab rows" + // unreachable rather than merely discouraged. + const typeForcedByProsthesis = + available.prosthesis && selection.prosthesis && effective.treatmentType != null; + const nothingToApply = !hasAnythingToApply(effective, labDependentCodes); const selectedCount = countSelected(selection, available); @@ -134,6 +140,12 @@ export function VoiceReviewSheet({ return chosenTeeth.includes(code as FdiToothId); }; + /** + * A picked candidate has no meaning unless its own row is ticked, so each branch ticks the + * row it feeds. Never derive that from `available`: it is memoised from `effective`, which + * this handler is in the middle of changing, so it still reports the row as unavailable and + * the pick would apply nothing. + */ const pickCandidate = (item: VoiceUnresolvedItem, code: string) => { const index = item.assignmentIndex; if (item.reason === 'arch_not_spoken' && index != null) { @@ -145,12 +157,18 @@ export function VoiceReviewSheet({ [index]: cur.includes(arch) ? cur.filter((a) => a !== arch) : [...cur, arch], }; }); - } else if (item.reason === 'prosthesis_type_ambiguous' && index != null) { + setSelection((prev) => ({ ...prev, prosthesis: true })); + return; + } + if (item.reason === 'prosthesis_type_ambiguous' && index != null) { setChosenLeaves((prev) => { const cur = prev[index] ?? []; return { ...prev, [index]: cur.includes(code) ? cur.filter((l) => l !== code) : [...cur, code] }; }); - } else if (index != null) { + setSelection((prev) => ({ ...prev, prosthesis: true })); + return; + } + if (index != null) { setChosenAssignmentTeeth((prev) => { const cur = prev[index] ?? []; const tooth = code as FdiToothId; @@ -159,16 +177,12 @@ export function VoiceReviewSheet({ [index]: cur.includes(tooth) ? cur.filter((t2) => t2 !== tooth) : [...cur, tooth], }; }); - } else { - const tooth = code as FdiToothId; - setChosenTeeth((prev) => (prev.includes(tooth) ? prev.filter((t2) => t2 !== tooth) : [...prev, tooth])); + setSelection((prev) => ({ ...prev, prosthesis: true })); + return; } - // A picked candidate has no meaning unless its row is ticked. - setSelection((prev) => ({ - ...prev, - teeth: prev.teeth || available.teeth, - prosthesis: true, - })); + const tooth = code as FdiToothId; + setChosenTeeth((prev) => (prev.includes(tooth) ? prev.filter((t2) => t2 !== tooth) : [...prev, tooth])); + setSelection((prev) => ({ ...prev, teeth: true })); }; const candidateLabel = (item: VoiceUnresolvedItem, code: string): string => { @@ -209,8 +223,10 @@ export function VoiceReviewSheet({ {available.treatmentType ? ( {labelFor(effective.treatmentType, treatmentCatalog)} @@ -378,7 +394,13 @@ export function VoiceReviewSheet({ type="button" variant="primary" disabled={selectedCount === 0} - onClick={() => onApply(selection, effective)} + onClick={() => + onApply( + // The locked type row is ticked in the UI, so it must be ticked in what applies. + typeForcedByProsthesis ? { ...selection, treatmentType: true } : selection, + effective, + ) + } fullWidth className="sm:w-auto" > @@ -395,17 +417,20 @@ function Row({ checked, onChange, warning, + locked, children, }: { label: string; checked: boolean; onChange: (checked: boolean) => void; warning?: string; + /** Ticked and not untickable — the value is implied by another row (decision 41). */ + locked?: boolean; children: React.ReactNode; }) { return (
- +
{children}
{warning ? (