An under-specified tooth was a dead end: the sheet said what was missing and the clinician had to leave and hunt for it on the chart. The readings are enumerable, so the review sheet now renders them as chips — the one interactive part of an otherwise read-only confirmation step. A pick is folded into the result by withChosenTeeth() rather than tracked alongside it, so the rows, the mini chart, the prosthesis warning and applyVoiceResult all keep reading a single VoiceExtractionResult and none of them has to know the chips exist. It unions rather than toggles: a candidate can coincidentally be a tooth the recording already produced, and tapping it must not deselect that one. Two things that would otherwise make the chips look functional while applying nothing: the teeth row is ticked on the first pick (it starts unticked when the recording produced no teeth of its own), and the apply count is now intersected with row availability so it cannot promise to apply a row with nothing in it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
115 lines
4.3 KiB
TypeScript
115 lines
4.3 KiB
TypeScript
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<keyof VoiceApplySelection, boolean>,
|
|
): 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<FdiToothId> {
|
|
const connected = new Set<FdiToothId>();
|
|
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);
|
|
}
|