import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups'; import type { FdiToothId } from '@/types/treatment'; import type { VoiceApplySelection, VoiceExtractionResult, VoiceProsthesisResult, } from '@/types/voice'; /** Which rows the review sheet renders at all — a row with nothing extracted is noise. */ export function voiceRowAvailability(result: VoiceExtractionResult) { return { treatmentType: result.treatmentType != null, teeth: result.teeth.length > 0, comment: Boolean(result.comment?.trim()), prosthesis: result.prosthesis != null, lab: result.labId != null, dueDate: result.dueDate != null, }; } /** * Which rows start ticked. * * Everything available ticks itself, with two deliberate exceptions: * * - **lab, when the name only approximately matched.** Shipping a case to a lab is the one * extracted value whose error leaves the building, so it always requires a deliberate tick. * - **prosthesis, when the map is incomplete.** A prosthesis detail with an untyped tooth * cannot ship at all, so applying it would just move the failure to dispatch. */ export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApplySelection { const available = voiceRowAvailability(result); return { treatmentType: available.treatmentType, teeth: available.teeth, comment: available.comment, prosthesis: available.prosthesis && result.prosthesis?.complete === true, lab: available.lab && result.labMatchExact, dueDate: available.dueDate, }; } /** * How many rows will actually be applied — drives the confirm button's label. * * Intersected with availability rather than counting ticks: a row can be ticked and then * lose its content (the last candidate tooth un-picked), and "Apply 1 item" that applies * nothing is worse than a wrong number. */ export function countSelected( selection: VoiceApplySelection, available: Record, ): number { return (Object.keys(selection) as (keyof VoiceApplySelection)[]).filter( (key) => selection[key] && available[key], ).length; } /** Mirrors the backend's rule: every selected tooth needs a code, or the case cannot ship. */ function recheckProsthesis( prosthesis: VoiceProsthesisResult, teeth: readonly FdiToothId[], ): VoiceProsthesisResult { const missingTeeth = teeth.filter((tooth) => !prosthesis.byTooth[tooth]); return { ...prosthesis, missingTeeth, complete: missingTeeth.length === 0 }; } /** * Fold the clinician's candidate picks into the extracted result. * * Everything downstream reads a `VoiceExtractionResult` — row availability, the mini * chart, the prosthesis warning, `applyVoiceResult` — so resolving the picks into one here * means none of them has to know the chips exist. * * Union rather than toggle, for two reasons: a candidate can coincidentally be a tooth the * recording already produced ("۱۲ و دو"), where tapping it must not deselect that tooth; * and `groupsFromFlatTeeth` keeps the bridges intact while giving every remaining tooth a * single group, so no tooth can be lost on the way through. */ export function withChosenTeeth( result: VoiceExtractionResult, chosen: readonly FdiToothId[], ): VoiceExtractionResult { if (chosen.length === 0) return result; const teeth = [...new Set([...result.teeth, ...chosen])].sort() as FdiToothId[]; return { ...result, teeth, toothSelectionGroups: groupsFromFlatTeeth(teeth, result.toothSelectionGroups), prosthesis: result.prosthesis ? recheckProsthesis(result.prosthesis, teeth) : null, }; } /** Teeth that are part of a bridge, for the read-only chart's connection marks. */ export function connectedTeethFromResult(result: VoiceExtractionResult): Set { const connected = new Set(); for (const group of result.toothSelectionGroups) { if (group.kind !== 'connected') continue; for (const tooth of group.teeth) connected.add(tooth); } return connected; } /** * Whether the sheet has anything worth showing. * * A recording that produced nothing usable should say so plainly rather than present an * empty form of checkboxes. */ export function hasAnythingToApply(result: VoiceExtractionResult): boolean { return Object.values(voiceRowAvailability(result)).some(Boolean); }