feat(treatment): add voice detail entry #65

Merged
admin merged 32 commits from feat/voice-treatment-entry into master 2026-08-24 11:13:48 +03:30
7 changed files with 130 additions and 29 deletions
Showing only changes of commit 754efdee09 - Show all commits

View File

@@ -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 clinics list",

View File

@@ -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": "در فهرست این مطب نیست",

View File

@@ -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",

View File

@@ -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. */

View File

@@ -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}

View File

@@ -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"
>

View File

@@ -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 {