feat(treatment): add voice detail entry #65
@@ -909,12 +909,13 @@
|
||||
"voiceProsthesisIncomplete": "No prosthesis type for {teeth} — the case cannot be sent until every tooth has one.",
|
||||
"voiceLabInexact": "The spoken name only partly matched this lab. Confirm before sending.",
|
||||
"voiceNotUnderstood": "Not understood",
|
||||
"voicePickTooth": "Which tooth?",
|
||||
"voiceDiscard": "Discard",
|
||||
"voiceApply": "{count, plural, one {Apply # field} other {Apply # fields}}",
|
||||
"voiceUnresolved": {
|
||||
"not_permanent_tooth": "not a permanent tooth",
|
||||
"position_out_of_range": "not a valid tooth position",
|
||||
"tooth_missing_quadrant": "quadrant not said — e.g. “upper right two”",
|
||||
"tooth_missing_quadrant": "not a whole tooth number — say e.g. “twenty-six”",
|
||||
"malformed": "could not be read",
|
||||
"span_not_same_arch": "a bridge cannot span both jaws",
|
||||
"unknown_catalog_code": "not in this clinic’s list",
|
||||
|
||||
@@ -910,12 +910,13 @@
|
||||
"voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندانها نوع داشته باشند، کیس ارسال نمیشود.",
|
||||
"voiceLabInexact": "نام گفتهشده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.",
|
||||
"voiceNotUnderstood": "شناسایی نشد",
|
||||
"voicePickTooth": "کدام دندان؟",
|
||||
"voiceDiscard": "انصراف",
|
||||
"voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}",
|
||||
"voiceUnresolved": {
|
||||
"not_permanent_tooth": "دندان دائمی نیست",
|
||||
"position_out_of_range": "شماره دندان معتبر نیست",
|
||||
"tooth_missing_quadrant": "بالا/پایین و چپ/راست گفته نشد — مثلاً «دو بالا راست»",
|
||||
"tooth_missing_quadrant": "شماره کامل دندان نیست — مثلاً «بیست و شش»",
|
||||
"malformed": "قابل خواندن نبود",
|
||||
"span_not_same_arch": "بریج نمیتواند بین دو فک باشد",
|
||||
"unknown_catalog_code": "در فهرست این مطب نیست",
|
||||
|
||||
@@ -909,12 +909,13 @@
|
||||
"voiceProsthesisIncomplete": "Geen prothesetype voor {teeth} — de casus kan pas worden verstuurd als elk element er een heeft.",
|
||||
"voiceLabInexact": "De uitgesproken naam kwam slechts deels overeen met dit lab. Bevestig voor verzending.",
|
||||
"voiceNotUnderstood": "Niet begrepen",
|
||||
"voicePickTooth": "Welk element?",
|
||||
"voiceDiscard": "Verwerpen",
|
||||
"voiceApply": "{count, plural, one {# veld toepassen} other {# velden toepassen}}",
|
||||
"voiceUnresolved": {
|
||||
"not_permanent_tooth": "geen blijvend element",
|
||||
"position_out_of_range": "geen geldige elementpositie",
|
||||
"tooth_missing_quadrant": "kwadrant niet genoemd — bijv. “rechtsboven twee”",
|
||||
"tooth_missing_quadrant": "geen volledig elementnummer — bijv. “zesentwintig”",
|
||||
"malformed": "kon niet worden gelezen",
|
||||
"span_not_same_arch": "een brug kan niet over beide kaken lopen",
|
||||
"unknown_catalog_code": "staat niet in de lijst van deze praktijk",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
|
||||
import type { FdiToothId } from '@/types/treatment';
|
||||
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
|
||||
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) {
|
||||
@@ -35,9 +40,57 @@ export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApply
|
||||
};
|
||||
}
|
||||
|
||||
/** How many rows will actually be applied — drives the confirm button's label. */
|
||||
export function countSelected(selection: VoiceApplySelection): number {
|
||||
return Object.values(selection).filter(Boolean).length;
|
||||
/**
|
||||
* 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. */
|
||||
|
||||
@@ -2842,7 +2842,7 @@ export function TreatmentWorkspace({
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
prosthesisCatalog={prosthesisCatalog}
|
||||
labs={orgs}
|
||||
onApply={(selection) => applyVoiceResult(voiceResult, selection)}
|
||||
onApply={(selection, applied) => applyVoiceResult(applied, selection)}
|
||||
onDiscard={() => setVoiceResult(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -16,12 +16,13 @@ import {
|
||||
hasAnythingToApply,
|
||||
initialVoiceSelection,
|
||||
voiceRowAvailability,
|
||||
withChosenTeeth,
|
||||
} from '@/components/treatment/voiceReviewRows';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { useAppFormatters } from '@/lib/hooks/useAppFormatters';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { LinkedOrganizationOption } from '@/types/treatment';
|
||||
import type { FdiToothId, LinkedOrganizationOption } from '@/types/treatment';
|
||||
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
|
||||
|
||||
interface VoiceReviewSheetProps {
|
||||
@@ -29,7 +30,8 @@ interface VoiceReviewSheetProps {
|
||||
treatmentCatalog: TreatmentCatalogEntry[];
|
||||
prosthesisCatalog: ProsthesisCatalogEntry[];
|
||||
labs: LinkedOrganizationOption[];
|
||||
onApply: (selection: VoiceApplySelection) => void;
|
||||
/** The result is handed back because the sheet may have added teeth the model missed. */
|
||||
onApply: (selection: VoiceApplySelection, result: VoiceExtractionResult) => void;
|
||||
onDiscard: () => void;
|
||||
}
|
||||
|
||||
@@ -54,12 +56,26 @@ export function VoiceReviewSheet({
|
||||
const [selection, setSelection] = useState<VoiceApplySelection>(() =>
|
||||
initialVoiceSelection(result),
|
||||
);
|
||||
const [chosen, setChosen] = useState<FdiToothId[]>([]);
|
||||
|
||||
const available = useMemo(() => voiceRowAvailability(result), [result]);
|
||||
const connectedTeeth = useMemo(() => connectedTeethFromResult(result), [result]);
|
||||
const selectedTeeth = useMemo(() => new Set(result.teeth), [result.teeth]);
|
||||
const nothingToApply = !hasAnythingToApply(result);
|
||||
const selectedCount = countSelected(selection);
|
||||
// Everything below renders from `effective`, never from `result` — a tooth picked from
|
||||
// the candidate chips has to reach the rows, the chart and the apply count alike.
|
||||
const effective = useMemo(() => withChosenTeeth(result, chosen), [result, chosen]);
|
||||
|
||||
const available = useMemo(() => voiceRowAvailability(effective), [effective]);
|
||||
const connectedTeeth = useMemo(() => connectedTeethFromResult(effective), [effective]);
|
||||
const selectedTeeth = useMemo(() => new Set(effective.teeth), [effective.teeth]);
|
||||
const nothingToApply = !hasAnythingToApply(effective);
|
||||
const selectedCount = countSelected(selection, available);
|
||||
|
||||
const pickCandidate = (tooth: FdiToothId) => {
|
||||
setChosen((prev) =>
|
||||
prev.includes(tooth) ? prev.filter((t) => t !== tooth) : [...prev, tooth],
|
||||
);
|
||||
// The teeth row starts unticked whenever the recording produced no teeth of its own,
|
||||
// and a picked tooth that is not ticked applies nothing.
|
||||
setSelection((prev) => (prev.teeth ? prev : { ...prev, teeth: true }));
|
||||
};
|
||||
|
||||
const labelFor = (code: string | null, catalog: { code: string; label: string }[]) =>
|
||||
catalog.find((entry) => entry.code === code)?.label ?? code ?? '';
|
||||
@@ -80,7 +96,7 @@ export function VoiceReviewSheet({
|
||||
</h2>
|
||||
|
||||
<p className="mt-2 rounded-[var(--radius-md)] bg-background-card/60 px-3 py-2 text-sm text-text-secondary">
|
||||
{result.transcript}
|
||||
{effective.transcript}
|
||||
</p>
|
||||
|
||||
{nothingToApply ? (
|
||||
@@ -94,7 +110,7 @@ export function VoiceReviewSheet({
|
||||
onChange={toggle('treatmentType')}
|
||||
>
|
||||
<span className="text-sm text-text-primary">
|
||||
{labelFor(result.treatmentType, treatmentCatalog)}
|
||||
{labelFor(effective.treatmentType, treatmentCatalog)}
|
||||
</span>
|
||||
</Row>
|
||||
) : null}
|
||||
@@ -120,26 +136,26 @@ export function VoiceReviewSheet({
|
||||
onChange={toggle('comment')}
|
||||
>
|
||||
<span className="text-sm whitespace-pre-wrap text-text-primary">
|
||||
{result.comment}
|
||||
{effective.comment}
|
||||
</span>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{available.prosthesis && result.prosthesis ? (
|
||||
{available.prosthesis && effective.prosthesis ? (
|
||||
<Row
|
||||
label={t('prosthesisColType')}
|
||||
checked={selection.prosthesis}
|
||||
onChange={toggle('prosthesis')}
|
||||
warning={
|
||||
result.prosthesis.complete
|
||||
effective.prosthesis.complete
|
||||
? undefined
|
||||
: t('voiceProsthesisIncomplete', {
|
||||
teeth: formatToothList(result.prosthesis.missingTeeth, locale),
|
||||
teeth: formatToothList(effective.prosthesis.missingTeeth, locale),
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="text-sm text-text-primary">
|
||||
{Object.entries(result.prosthesis.byTooth)
|
||||
{Object.entries(effective.prosthesis.byTooth)
|
||||
.map(
|
||||
([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`,
|
||||
)
|
||||
@@ -153,38 +169,62 @@ export function VoiceReviewSheet({
|
||||
label={t('entryStepLab')}
|
||||
checked={selection.lab}
|
||||
onChange={toggle('lab')}
|
||||
warning={result.labMatchExact ? undefined : t('voiceLabInexact')}
|
||||
warning={effective.labMatchExact ? undefined : t('voiceLabInexact')}
|
||||
>
|
||||
<span className="text-sm text-text-primary">
|
||||
{labs.find((lab) => lab.id === result.labId)?.name ?? result.labId}
|
||||
{labs.find((lab) => lab.id === effective.labId)?.name ?? effective.labId}
|
||||
</span>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{available.dueDate && result.dueDate ? (
|
||||
{available.dueDate && effective.dueDate ? (
|
||||
<Row
|
||||
label={t('dueDateLabel')}
|
||||
checked={selection.dueDate}
|
||||
onChange={toggle('dueDate')}
|
||||
>
|
||||
<span className="text-sm text-text-primary">
|
||||
{formatDate(civilDateToLocalDate(result.dueDate))}
|
||||
{formatDate(civilDateToLocalDate(effective.dueDate))}
|
||||
</span>
|
||||
</Row>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.unresolved.length > 0 ? (
|
||||
{effective.unresolved.length > 0 ? (
|
||||
<div className="mt-4 rounded-[var(--radius-md)] border border-amber-500/40 bg-amber-500/10 px-3 py-2">
|
||||
<p className="text-xs font-medium text-amber-700 dark:text-amber-400">
|
||||
{t('voiceNotUnderstood')}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{result.unresolved.map((item, index) => (
|
||||
{effective.unresolved.map((item, index) => (
|
||||
<li key={`${item.spoken}-${index}`} className="text-xs text-text-secondary">
|
||||
{item.spoken ? `“${item.spoken}” — ` : ''}
|
||||
{t(`voiceUnresolved.${item.reason}`)}
|
||||
{item.candidates && item.candidates.length > 0 ? (
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span className="text-text-muted">{t('voicePickTooth')}</span>
|
||||
{item.candidates.map((tooth) => {
|
||||
const picked = chosen.includes(tooth as FdiToothId);
|
||||
return (
|
||||
<button
|
||||
key={tooth}
|
||||
type="button"
|
||||
aria-pressed={picked}
|
||||
aria-label={t('toothAria', { fdi: tooth })}
|
||||
onClick={() => pickCandidate(tooth as FdiToothId)}
|
||||
className={`rounded-full border px-2 py-0.5 text-xs transition-colors ${
|
||||
picked
|
||||
? 'border-transparent bg-primary text-white'
|
||||
: 'border-border text-text-primary hover:border-border-strong'
|
||||
}`}
|
||||
>
|
||||
{tooth}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -199,7 +239,7 @@ export function VoiceReviewSheet({
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={selectedCount === 0}
|
||||
onClick={() => onApply(selection)}
|
||||
onClick={() => onApply(selection, effective)}
|
||||
fullWidth
|
||||
className="sm:w-auto"
|
||||
>
|
||||
|
||||
@@ -16,6 +16,11 @@ export interface VoiceUnresolvedItem {
|
||||
/** The transcript span that could not be resolved, so the clinician sees what was heard. */
|
||||
spoken: string;
|
||||
reason: VoiceUnresolvedReason;
|
||||
/**
|
||||
* FDI codes still consistent with what was heard, when a choice would settle it — the
|
||||
* review sheet offers them as chips. Only `tooth_missing_quadrant` carries these.
|
||||
*/
|
||||
candidates?: string[];
|
||||
}
|
||||
|
||||
export interface VoiceProsthesisResult {
|
||||
|
||||
Reference in New Issue
Block a user