Merge pull request 'feat(treatment): add voice detail entry' (#65) from feat/voice-treatment-entry into master
Reviewed-on: http://host.docker.internal:3000/admin/dyolink/pulls/65 Reviewed-by: admin <admin@localhost>
This commit was merged in pull request #65.
This commit is contained in:
@@ -899,7 +899,30 @@
|
||||
"toothAria": "FDI tooth {fdi}",
|
||||
"toothSelectedSuffix": ", selected",
|
||||
"sentToAt": "Sent to {orgName} at {datetime}",
|
||||
"fallbackOrgName": "organization"
|
||||
"fallbackOrgName": "organization",
|
||||
"voiceStart": "Record treatment",
|
||||
"voiceStop": "Stop recording",
|
||||
"voiceCancel": "Cancel",
|
||||
"voiceProcessing": "Reading the recording…",
|
||||
"voiceReviewTitle": "Check what was understood",
|
||||
"voiceNothingExtracted": "Nothing usable was picked up from that recording.",
|
||||
"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": "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",
|
||||
"tooth_not_selected": "that tooth is not part of this detail",
|
||||
"invalid_date": "not a usable date"
|
||||
},
|
||||
"voiceFailed": "Voice entry failed. Please try again."
|
||||
},
|
||||
"organizations": {
|
||||
"loadingOrganization": "Loading organization...",
|
||||
@@ -1240,6 +1263,14 @@
|
||||
"LAB_CASE_START_INCOMPLETE": "Complete teeth and prosthesis types before starting this case.",
|
||||
"LAB_CASE_CLIENT_REQUIRED": "Enter a clinic name or a patient name for this case.",
|
||||
"BAD_REQUEST": "The request could not be processed.",
|
||||
"INTERNAL_ERROR": "Something went wrong on our end. Please try again later."
|
||||
"INTERNAL_ERROR": "Something went wrong on our end. Please try again later.",
|
||||
"VOICE_MIC_DENIED": "Microphone access was blocked. Allow it in your browser settings and try again.",
|
||||
"VOICE_NOT_AVAILABLE": "Voice entry is not available for this language yet.",
|
||||
"VOICE_CLIP_TOO_LONG": "That recording is too long. Please keep it under two minutes.",
|
||||
"VOICE_UNSUPPORTED_FORMAT": "That recording format is not supported on this device.",
|
||||
"VOICE_ASR_FAILED": "Could not turn the recording into text. Please try again.",
|
||||
"VOICE_EXTRACT_FAILED": "Could not read the treatment details from the recording.",
|
||||
"VOICE_NOTHING_RECOGNIZED": "No speech was recognised. Check the microphone and try again.",
|
||||
"VOICE_RATE_LIMITED": "Too many recordings in a short time. Please wait a moment."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -900,7 +900,30 @@
|
||||
"toothAria": "دندان FDI {fdi}",
|
||||
"toothSelectedSuffix": "، انتخاب شده",
|
||||
"sentToAt": "ارسال به {orgName} در {datetime}",
|
||||
"fallbackOrgName": "سازمان"
|
||||
"fallbackOrgName": "سازمان",
|
||||
"voiceStart": "ثبت گفتاری درمان",
|
||||
"voiceStop": "توقف ضبط",
|
||||
"voiceCancel": "لغو",
|
||||
"voiceProcessing": "در حال پردازش گفتار…",
|
||||
"voiceReviewTitle": "بررسی آنچه دریافت شد",
|
||||
"voiceNothingExtracted": "از این ضبط چیز قابل استفادهای برداشت نشد.",
|
||||
"voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندانها نوع داشته باشند، کیس ارسال نمیشود.",
|
||||
"voiceLabInexact": "نام گفتهشده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.",
|
||||
"voiceNotUnderstood": "شناسایی نشد",
|
||||
"voicePickTooth": "کدام دندان؟",
|
||||
"voiceDiscard": "انصراف",
|
||||
"voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}",
|
||||
"voiceUnresolved": {
|
||||
"not_permanent_tooth": "دندان دائمی نیست",
|
||||
"position_out_of_range": "شماره دندان معتبر نیست",
|
||||
"tooth_missing_quadrant": "شماره کامل دندان نیست — مثلاً «بیست و شش»",
|
||||
"malformed": "قابل خواندن نبود",
|
||||
"span_not_same_arch": "بریج نمیتواند بین دو فک باشد",
|
||||
"unknown_catalog_code": "در فهرست این مطب نیست",
|
||||
"tooth_not_selected": "این دندان بخشی از این مورد نیست",
|
||||
"invalid_date": "تاریخ قابل استفاده نیست"
|
||||
},
|
||||
"voiceFailed": "ثبت گفتاری انجام نشد. لطفاً دوباره تلاش کنید."
|
||||
},
|
||||
"organizations": {
|
||||
"loadingOrganization": "در حال بارگذاری سازمان...",
|
||||
@@ -1241,6 +1264,14 @@
|
||||
"LAB_CASE_START_INCOMPLETE": "قبل از شروع پرونده، دندانها و نوع پروتز را کامل کنید.",
|
||||
"LAB_CASE_CLIENT_REQUIRED": "نام کلینیک یا نام بیمار را وارد کنید.",
|
||||
"BAD_REQUEST": "درخواست قابل پردازش نبود.",
|
||||
"INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید."
|
||||
"INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید.",
|
||||
"VOICE_MIC_DENIED": "دسترسی به میکروفون مسدود شده است. در تنظیمات مرورگر اجازه دهید و دوباره تلاش کنید.",
|
||||
"VOICE_NOT_AVAILABLE": "ثبت گفتاری هنوز برای این زبان در دسترس نیست.",
|
||||
"VOICE_CLIP_TOO_LONG": "مدت ضبط بیش از حد است. لطفاً کمتر از دو دقیقه صحبت کنید.",
|
||||
"VOICE_UNSUPPORTED_FORMAT": "قالب این ضبط پشتیبانی نمیشود.",
|
||||
"VOICE_ASR_FAILED": "تبدیل گفتار به متن انجام نشد. لطفاً دوباره تلاش کنید.",
|
||||
"VOICE_EXTRACT_FAILED": "اطلاعات درمان از روی گفتار استخراج نشد.",
|
||||
"VOICE_NOTHING_RECOGNIZED": "گفتاری شناسایی نشد. میکروفون را بررسی کنید و دوباره تلاش کنید.",
|
||||
"VOICE_RATE_LIMITED": "تعداد ضبطها در بازه کوتاه زیاد بود. کمی صبر کنید."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -899,7 +899,30 @@
|
||||
"toothAria": "FDI-tand {fdi}",
|
||||
"toothSelectedSuffix": ", geselecteerd",
|
||||
"sentToAt": "Verzonden naar {orgName} op {datetime}",
|
||||
"fallbackOrgName": "organisatie"
|
||||
"fallbackOrgName": "organisatie",
|
||||
"voiceStart": "Behandeling inspreken",
|
||||
"voiceStop": "Opname stoppen",
|
||||
"voiceCancel": "Annuleren",
|
||||
"voiceProcessing": "Opname wordt gelezen…",
|
||||
"voiceReviewTitle": "Controleer wat is begrepen",
|
||||
"voiceNothingExtracted": "Uit deze opname is niets bruikbaars opgepikt.",
|
||||
"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": "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",
|
||||
"tooth_not_selected": "dat element hoort niet bij dit onderdeel",
|
||||
"invalid_date": "geen bruikbare datum"
|
||||
},
|
||||
"voiceFailed": "Spraakinvoer is mislukt. Probeer het opnieuw."
|
||||
},
|
||||
"organizations": {
|
||||
"loadingOrganization": "Organisatie laden...",
|
||||
@@ -1240,6 +1263,14 @@
|
||||
"LAB_CASE_START_INCOMPLETE": "Vul tanden en prothesetypes in voordat u deze case start.",
|
||||
"LAB_CASE_CLIENT_REQUIRED": "Voer een klinieknaam of een patiëntnaam in voor deze case.",
|
||||
"BAD_REQUEST": "Het verzoek kon niet worden verwerkt.",
|
||||
"INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw."
|
||||
"INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw.",
|
||||
"VOICE_MIC_DENIED": "Microfoontoegang is geblokkeerd. Sta dit toe in uw browserinstellingen en probeer opnieuw.",
|
||||
"VOICE_NOT_AVAILABLE": "Spraakinvoer is nog niet beschikbaar voor deze taal.",
|
||||
"VOICE_CLIP_TOO_LONG": "Die opname is te lang. Houd het onder twee minuten.",
|
||||
"VOICE_UNSUPPORTED_FORMAT": "Dit opnameformaat wordt niet ondersteund.",
|
||||
"VOICE_ASR_FAILED": "De opname kon niet naar tekst worden omgezet. Probeer het opnieuw.",
|
||||
"VOICE_EXTRACT_FAILED": "De behandelgegevens konden niet uit de opname worden gelezen.",
|
||||
"VOICE_NOTHING_RECOGNIZED": "Er is geen spraak herkend. Controleer de microfoon en probeer opnieuw.",
|
||||
"VOICE_RATE_LIMITED": "Te veel opnames in korte tijd. Wacht even."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
95
frontend/src/components/treatment/voiceReviewRows.ts
Normal file
95
frontend/src/components/treatment/voiceReviewRows.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything available ticks itself, with two exceptions: an inexactly-matched lab, because
|
||||
* it is the one extracted value whose error leaves the building; and an incomplete
|
||||
* prosthesis map, which cannot ship at all and 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Intersected with availability rather than counting ticks: a row can be ticked and then lose
|
||||
* its content, 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 candidate picks into the result, so nothing downstream has to know chips exist.
|
||||
*
|
||||
* Union rather than toggle: a candidate can coincidentally be a tooth the recording already
|
||||
* produced ("۱۲ و دو"), and tapping it must not deselect that one.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** A recording that produced nothing should say so, not show an empty form of checkboxes. */
|
||||
export function hasAnythingToApply(result: VoiceExtractionResult): boolean {
|
||||
return Object.values(voiceRowAvailability(result)).some(Boolean);
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Mic, Square, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||
import { formatDetailChipLabel } from '@/components/treatment/detailChipLabel';
|
||||
import { autosaveStatusClass, labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles';
|
||||
import { TreatmentDetailAttachmentsStrip } from '@/components/ui/treatment/TreatmentDetailAttachmentsStrip';
|
||||
import { VoiceRecordingBar } from '@/components/ui/treatment/VoiceRecordingBar';
|
||||
import type { VoiceCaptureState } from '@/lib/voice/useVoiceCapture';
|
||||
import type { TreatmentDetailDraft } from '@/types/treatment';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import { treatmentTypeColor, treatmentTypeOptionStyle } from '@/components/shared/treatmentTypeDisplay';
|
||||
@@ -42,6 +44,12 @@ interface TreatmentDetailsEditorProps {
|
||||
stepper?: ReactNode;
|
||||
/** Shown below type + chart + notes (e.g. Continue to lab). */
|
||||
footer?: ReactNode;
|
||||
/**
|
||||
* Voice entry. Omit when unavailable — the Add button then renders unsplit, exactly as
|
||||
* before this feature existed. Presence *is* the availability flag, so the two cannot
|
||||
* disagree.
|
||||
*/
|
||||
voice?: VoiceCaptureState;
|
||||
/** Dim the chart until a treatment type is chosen. */
|
||||
chartLocked?: boolean;
|
||||
chartLockMessage?: string;
|
||||
@@ -65,6 +73,7 @@ export function TreatmentDetailsEditor({
|
||||
onRemoveAttachment,
|
||||
showChrome = true,
|
||||
showFields = true,
|
||||
voice,
|
||||
chart,
|
||||
stepper,
|
||||
footer,
|
||||
@@ -109,18 +118,31 @@ export function TreatmentDetailsEditor({
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('detailsTitle')}</h3>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!canEdit || disabled}
|
||||
onClick={onAddDetail}
|
||||
fullWidth
|
||||
className="sm:w-auto shrink-0"
|
||||
>
|
||||
{t('addDetail')}
|
||||
</Button>
|
||||
{voice ? (
|
||||
<AddDetailWithVoice
|
||||
addLabel={t('addDetail')}
|
||||
startLabel={t('voiceStart')}
|
||||
stopLabel={t('voiceStop')}
|
||||
disabled={!canEdit || disabled}
|
||||
onAddDetail={onAddDetail}
|
||||
voice={voice}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!canEdit || disabled}
|
||||
onClick={onAddDetail}
|
||||
fullWidth
|
||||
className="sm:w-auto shrink-0"
|
||||
>
|
||||
{t('addDetail')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{voice ? <VoiceRecordingBar voice={voice} /> : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{details.map((d, idx) => {
|
||||
const detailLocked = isDetailLocked(d);
|
||||
@@ -312,3 +334,77 @@ function NotesField({
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Add detail", split into two segments with the microphone at the logical end.
|
||||
*
|
||||
* Built like the detail chip's trash affordance in this same file — a wrapper holding two
|
||||
* raw `<button>`s divided by `border-s` — rather than two shared `Button`s, which hardcode
|
||||
* their own rounding and would fight a segmented control. `border-s` puts the microphone
|
||||
* visually right in en/nl and left in fa, on the same side as the chip's trash in both.
|
||||
*
|
||||
* Add keeps its exact existing behaviour; the microphone is an independent action.
|
||||
*/
|
||||
function AddDetailWithVoice({
|
||||
addLabel,
|
||||
startLabel,
|
||||
stopLabel,
|
||||
disabled,
|
||||
onAddDetail,
|
||||
voice,
|
||||
}: {
|
||||
addLabel: string;
|
||||
startLabel: string;
|
||||
stopLabel: string;
|
||||
disabled: boolean;
|
||||
onAddDetail: () => void;
|
||||
voice: VoiceCaptureState;
|
||||
}) {
|
||||
const isRecording = voice.phase === 'recording';
|
||||
const isBusy = voice.phase !== 'idle';
|
||||
const micLabel = isRecording ? stopLabel : startLabel;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
inline-flex w-full items-stretch overflow-hidden rounded-[var(--radius-md)]
|
||||
bg-primary text-white shrink-0 sm:w-auto
|
||||
${disabled ? 'opacity-60' : ''}
|
||||
`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddDetail}
|
||||
disabled={disabled || isBusy}
|
||||
className="
|
||||
flex-1 px-4 py-2 text-sm font-medium transition-all duration-200
|
||||
hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset
|
||||
focus-visible:ring-white/60
|
||||
disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:opacity-60
|
||||
"
|
||||
>
|
||||
{addLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={isRecording ? voice.onStop : voice.onStart}
|
||||
disabled={disabled || voice.phase === 'processing'}
|
||||
title={micLabel}
|
||||
aria-label={micLabel}
|
||||
className={`
|
||||
inline-flex items-center justify-center border-s border-white/25 px-3
|
||||
transition-all duration-200 focus:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-inset focus-visible:ring-white/60
|
||||
disabled:cursor-not-allowed disabled:opacity-60
|
||||
${isRecording ? 'bg-red-600 hover:bg-red-700' : 'hover:opacity-90'}
|
||||
`}
|
||||
>
|
||||
{isRecording ? (
|
||||
<Square className="h-4 w-4 fill-current" aria-hidden />
|
||||
) : (
|
||||
<Mic className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ import {
|
||||
import { appointmentsApi } from '@/lib/api/appointments';
|
||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||
import { voiceApi } from '@/lib/api/voice';
|
||||
import { useVoiceCapture } from '@/lib/voice/useVoiceCapture';
|
||||
import { VoiceReviewSheet } from '@/components/ui/treatment/VoiceReviewSheet';
|
||||
import type {
|
||||
VoiceApplySelection,
|
||||
VoiceAvailability,
|
||||
VoiceExtractionResult,
|
||||
} from '@/types/voice';
|
||||
import { treatmentsApi } from '@/lib/api/treatments';
|
||||
import { notificationsApi } from '@/lib/api/notifications';
|
||||
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
|
||||
@@ -464,6 +472,11 @@ export function TreatmentWorkspace({
|
||||
const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false);
|
||||
const [entryStep, setEntryStep] = useState<EntryStep>('treatment');
|
||||
|
||||
|
||||
const [voiceAvailability, setVoiceAvailability] = useState<VoiceAvailability | null>(null);
|
||||
const [voiceResult, setVoiceResult] = useState<VoiceExtractionResult | null>(null);
|
||||
|
||||
|
||||
const isDetailLocked = useCallback(
|
||||
(detail: TreatmentDetailDraft) =>
|
||||
labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientId === detail.clientId),
|
||||
@@ -489,6 +502,21 @@ export function TreatmentWorkspace({
|
||||
[appointments, selectedAppointmentId],
|
||||
);
|
||||
|
||||
|
||||
const voice = useVoiceCapture({
|
||||
// The locale the clinician is actually reading and speaking in. Sent explicitly so
|
||||
// the server's ASR hint, catalog labels and week start match what the microphone was
|
||||
// offered for — req.user.language can drift from the URL locale.
|
||||
locale,
|
||||
maxMs: voiceAvailability?.maxRecordingMs ?? null,
|
||||
onExtracted: setVoiceResult,
|
||||
onError: (error) => showError(getUserFacingError(error, tErrors, t('voiceFailed'))),
|
||||
});
|
||||
|
||||
/** Absence is the unavailable state — the Add button then renders unsplit. */
|
||||
const voiceForEditor =
|
||||
voiceAvailability?.enabled && voiceAvailability.locales.includes(locale) ? voice : undefined;
|
||||
|
||||
const selectedStandalone = useMemo(
|
||||
() => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null,
|
||||
[standaloneTreatments, selectedStandaloneId],
|
||||
@@ -927,12 +955,18 @@ export function TreatmentWorkspace({
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const [orgsResponse, catalogResponse, prosthesisResponse] = await Promise.all([
|
||||
treatmentsApi.listLinkedOrganizations(),
|
||||
treatmentCatalogApi.list(),
|
||||
prosthesisCatalogApi.list(),
|
||||
]);
|
||||
const [orgsResponse, catalogResponse, prosthesisResponse, voiceResponse] =
|
||||
await Promise.all([
|
||||
treatmentsApi.listLinkedOrganizations(),
|
||||
treatmentCatalogApi.list(),
|
||||
prosthesisCatalogApi.list(),
|
||||
// Voice availability comes from the API, not a NEXT_PUBLIC_* var: those are
|
||||
// baked in at build time, so enabling a locale would need a frontend rebuild.
|
||||
// A failure here must not take the whole treatment tab down with it.
|
||||
voiceApi.availability().catch(() => null),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setVoiceAvailability(voiceResponse?.data ?? null);
|
||||
setOrgs(orgsResponse.data);
|
||||
setTreatmentCatalog(catalogResponse.data);
|
||||
setProsthesisCatalog(prosthesisResponse.data);
|
||||
@@ -1935,6 +1969,114 @@ export function TreatmentWorkspace({
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Voice entry.
|
||||
*
|
||||
* Confirm always appends a NEW detail — it never edits an existing one, and never
|
||||
* touches onAddDetail. Nothing is created until this runs, so cancelling or a failed
|
||||
* recording leaves the chip strip untouched.
|
||||
*/
|
||||
const applyVoiceResult = useCallback(
|
||||
(result: VoiceExtractionResult, selection: VoiceApplySelection) => {
|
||||
const detail = newDetail(
|
||||
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
|
||||
);
|
||||
|
||||
// Ticked rows land on top of the seeded defaults, so unticking the type row leaves
|
||||
// the appointment-purpose default rather than a blank.
|
||||
if (selection.treatmentType && result.treatmentType) {
|
||||
detail.treatmentType = result.treatmentType;
|
||||
}
|
||||
if (selection.teeth) {
|
||||
detail.teeth = [...result.teeth];
|
||||
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
|
||||
...group,
|
||||
teeth: [...group.teeth],
|
||||
}));
|
||||
}
|
||||
if (selection.comment && result.comment) {
|
||||
detail.comment = result.comment;
|
||||
}
|
||||
|
||||
const nextDetails = [...detailsRef.current, detail];
|
||||
setDetails(nextDetails);
|
||||
// persistDraft reads detailsRef, and setDetails has not rendered yet.
|
||||
detailsRef.current = nextDetails;
|
||||
setActiveDetailId(detail.clientId);
|
||||
setEntryStep('treatment');
|
||||
|
||||
// Lab-side rows ride on a lab case draft keyed by the detail's *client* id, so a
|
||||
// brand-new unsaved detail can still carry one; it is persisted after the detail is.
|
||||
const wantsLabDraft =
|
||||
(selection.prosthesis && result.prosthesis) ||
|
||||
(selection.lab && result.labId) ||
|
||||
(selection.dueDate && result.dueDate);
|
||||
|
||||
if (wantsLabDraft) {
|
||||
const draft = newLabCaseDraft();
|
||||
draft.detailClientId = detail.clientId;
|
||||
if (selection.lab && result.labId) {
|
||||
draft.destinationOrganizationId = result.labId;
|
||||
}
|
||||
if (selection.dueDate && result.dueDate) {
|
||||
draft.dueDate = result.dueDate;
|
||||
}
|
||||
if (selection.prosthesis && result.prosthesis) {
|
||||
// byTooth keys are plain strings; the group's teeth are FdiToothId.
|
||||
const groupOf = (tooth: string) =>
|
||||
result.toothSelectionGroups.find((group) =>
|
||||
(group.teeth as readonly string[]).includes(tooth),
|
||||
)?.groupId ?? '';
|
||||
// Only teeth that actually landed on the detail. Unticking "teeth" while
|
||||
// leaving "prosthesis" ticked would otherwise attach prosthesis rows for teeth
|
||||
// the treatment does not contain — nothing downstream filters them, and they
|
||||
// would reach task generation as work for teeth nobody is treating.
|
||||
const detailTeeth = new Set<string>(detail.teeth);
|
||||
draft.toothProsthesis = Object.entries(result.prosthesis.byTooth)
|
||||
.filter(([tooth]) => detailTeeth.has(tooth))
|
||||
.map(([tooth, prosthesisTypeCode]) => ({
|
||||
detailClientId: detail.clientId,
|
||||
tooth,
|
||||
prosthesisTypeCode,
|
||||
selectionGroupId: groupOf(tooth),
|
||||
}));
|
||||
}
|
||||
const updatedLabCases = [...labCaseDrafts, draft];
|
||||
setLabCaseDrafts(updatedLabCases);
|
||||
|
||||
// Autosave only watches `details`, so a lab draft left in state alone loses the
|
||||
// lab, the due date and the prosthesis map on reload — silently, because the
|
||||
// detail itself survives.
|
||||
void (async () => {
|
||||
try {
|
||||
const saved = await persistDraft({ force: true });
|
||||
// persistDraft returns a *preview* when the details are not persistable — one
|
||||
// blank detail is enough — and a preview's detail id falls back to the client
|
||||
// id. Check what came back, not the precondition, so this holds for every early
|
||||
// return persistDraft has.
|
||||
const savedDetail = saved.details.find((d) => d.clientId === detail.clientId);
|
||||
if (!savedDetail?.id || savedDetail.id === detail.clientId) return;
|
||||
await persistLabCases(saved, updatedLabCases);
|
||||
} catch (error: unknown) {
|
||||
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
setVoiceResult(null);
|
||||
},
|
||||
[
|
||||
labCaseDrafts,
|
||||
persistDraft,
|
||||
persistLabCases,
|
||||
selectedAppointment?.purpose,
|
||||
showError,
|
||||
t,
|
||||
tErrors,
|
||||
treatmentCatalog,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRemoveDetail = useCallback(
|
||||
(detailClientId: string) => {
|
||||
if (!canEditTreatmentForDay) return;
|
||||
@@ -2478,6 +2620,7 @@ export function TreatmentWorkspace({
|
||||
}}
|
||||
showChrome
|
||||
showFields={entryStep === 'treatment'}
|
||||
voice={voiceForEditor}
|
||||
chartLocked={
|
||||
entryStep === 'treatment' && !activeTypeSelected && !showWholeTreatmentPlan
|
||||
}
|
||||
@@ -2725,6 +2868,16 @@ export function TreatmentWorkspace({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{voiceResult ? (
|
||||
<VoiceReviewSheet
|
||||
result={voiceResult}
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
prosthesisCatalog={prosthesisCatalog}
|
||||
labs={orgs}
|
||||
onApply={(selection, applied) => applyVoiceResult(applied, selection)}
|
||||
onDiscard={() => setVoiceResult(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
90
frontend/src/components/ui/treatment/VoiceRecordingBar.tsx
Normal file
90
frontend/src/components/ui/treatment/VoiceRecordingBar.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
import type { VoiceCaptureState } from '@/lib/voice/useVoiceCapture';
|
||||
|
||||
const METER_BARS = 9;
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const totalSeconds = Math.floor(Math.max(0, ms) / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live recording / processing strip.
|
||||
*
|
||||
* Sits between the header row and the chip strip rather than inside the segmented control:
|
||||
* the header is `sm:justify-between`, so growing the button mid-recording would shift the
|
||||
* whole row.
|
||||
*/
|
||||
export function VoiceRecordingBar({ voice }: { voice: VoiceCaptureState }) {
|
||||
const t = useTranslations('treatment');
|
||||
|
||||
if (voice.phase === 'idle') return null;
|
||||
|
||||
const isRecording = voice.phase === 'recording';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-[var(--radius-md)] border border-border/70 bg-background-secondary/70 px-3 py-2"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{isRecording ? (
|
||||
<>
|
||||
<span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-red-500" aria-hidden />
|
||||
<span className="shrink-0 text-sm tabular-nums text-text-primary">
|
||||
{formatElapsed(voice.elapsedMs)}
|
||||
{voice.maxMs != null ? (
|
||||
<span className="text-text-muted"> / {formatElapsed(voice.maxMs)}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<LevelMeter level={voice.level} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-text-secondary" aria-hidden />
|
||||
<span className="text-sm text-text-secondary">{t('voiceProcessing')}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={voice.onCancel}
|
||||
title={t('voiceCancel')}
|
||||
aria-label={t('voiceCancel')}
|
||||
className="ms-auto inline-flex shrink-0 items-center gap-1 rounded-[var(--radius-md)] px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-red-500/15 hover:text-red-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/40"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden />
|
||||
{t('voiceCancel')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Proves the microphone is actually hearing something — silence looks identical otherwise. */
|
||||
function LevelMeter({ level }: { level: number }) {
|
||||
return (
|
||||
<span className="flex h-4 flex-1 items-end gap-0.5" aria-hidden>
|
||||
{Array.from({ length: METER_BARS }, (_, index) => {
|
||||
// Bars light up left to right as the level rises, with a floor so the meter never
|
||||
// looks dead while a quiet voice is still being captured.
|
||||
const threshold = (index + 1) / METER_BARS;
|
||||
const active = level >= threshold * 0.9;
|
||||
const height = active ? 30 + threshold * 70 : 20;
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className={`w-1 rounded-sm transition-all duration-75 ${
|
||||
active ? 'bg-primary' : 'bg-border'
|
||||
}`}
|
||||
style={{ height: `${height}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
306
frontend/src/components/ui/treatment/VoiceReviewSheet.tsx
Normal file
306
frontend/src/components/ui/treatment/VoiceReviewSheet.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
import {
|
||||
ResponsiveDialogOverlay,
|
||||
ResponsiveDialogPanel,
|
||||
} from '@/components/ui/shared/ResponsiveDialog';
|
||||
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
||||
import {
|
||||
connectedTeethFromResult,
|
||||
countSelected,
|
||||
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 { FdiToothId, LinkedOrganizationOption } from '@/types/treatment';
|
||||
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
|
||||
|
||||
interface VoiceReviewSheetProps {
|
||||
result: VoiceExtractionResult;
|
||||
treatmentCatalog: TreatmentCatalogEntry[];
|
||||
prosthesisCatalog: ProsthesisCatalogEntry[];
|
||||
labs: LinkedOrganizationOption[];
|
||||
/** The result is handed back because the sheet may have added teeth the model missed. */
|
||||
onApply: (selection: VoiceApplySelection, result: VoiceExtractionResult) => void;
|
||||
onDiscard: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation step between the model's output and the form.
|
||||
*
|
||||
* Modal on desktop, bottom sheet on mobile — an overlay and not a route, because navigating
|
||||
* would unmount TreatmentWorkspace and destroy the in-progress draft.
|
||||
*/
|
||||
export function VoiceReviewSheet({
|
||||
result,
|
||||
treatmentCatalog,
|
||||
prosthesisCatalog,
|
||||
labs,
|
||||
onApply,
|
||||
onDiscard,
|
||||
}: VoiceReviewSheetProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const locale = useLocale();
|
||||
const { formatDate } = useAppFormatters();
|
||||
const [selection, setSelection] = useState<VoiceApplySelection>(() =>
|
||||
initialVoiceSelection(result),
|
||||
);
|
||||
const [chosen, setChosen] = useState<FdiToothId[]>([]);
|
||||
|
||||
// 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) => {
|
||||
const nextChosen = chosen.includes(tooth)
|
||||
? chosen.filter((t) => t !== tooth)
|
||||
: [...chosen, tooth];
|
||||
setChosen(nextChosen);
|
||||
setSelection((prev) => ({
|
||||
...prev,
|
||||
// The teeth row starts unticked whenever the recording produced no teeth of its own,
|
||||
// and a picked tooth that is not ticked applies nothing.
|
||||
teeth: true,
|
||||
// A picked tooth has no prosthesis type, so the map is no longer shippable — leaving the
|
||||
// row ticked would apply a map dispatch rejects. Only ever unticks; re-ticking is the
|
||||
// clinician's call.
|
||||
prosthesis:
|
||||
prev.prosthesis &&
|
||||
withChosenTeeth(result, nextChosen).prosthesis?.complete !== false,
|
||||
}));
|
||||
};
|
||||
|
||||
const labelFor = (code: string | null, catalog: { code: string; label: string }[]) =>
|
||||
catalog.find((entry) => entry.code === code)?.label ?? code ?? '';
|
||||
|
||||
const toggle = (key: keyof VoiceApplySelection) => (checked: boolean) =>
|
||||
setSelection((prev) => ({ ...prev, [key]: checked }));
|
||||
|
||||
return (
|
||||
<ResponsiveDialogOverlay onBackdropClick={onDiscard}>
|
||||
<ResponsiveDialogPanel
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="voice-review-title"
|
||||
maxWidthClass="sm:max-w-xl"
|
||||
>
|
||||
<h2 id="voice-review-title" className="text-base font-semibold text-text-primary">
|
||||
{t('voiceReviewTitle')}
|
||||
</h2>
|
||||
|
||||
<p className="mt-2 rounded-[var(--radius-md)] bg-background-card/60 px-3 py-2 text-sm text-text-secondary">
|
||||
{effective.transcript}
|
||||
</p>
|
||||
|
||||
{nothingToApply ? (
|
||||
<p className="mt-4 text-sm text-text-secondary">{t('voiceNothingExtracted')}</p>
|
||||
) : (
|
||||
<div className="mt-4 space-y-3">
|
||||
{available.treatmentType ? (
|
||||
<Row
|
||||
label={t('treatmentType')}
|
||||
checked={selection.treatmentType}
|
||||
onChange={toggle('treatmentType')}
|
||||
>
|
||||
<span className="text-sm text-text-primary">
|
||||
{labelFor(effective.treatmentType, treatmentCatalog)}
|
||||
</span>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{available.teeth ? (
|
||||
<Row label={t('entryStepTeeth')} checked={selection.teeth} onChange={toggle('teeth')}>
|
||||
<div className="mt-1">
|
||||
<FdiToothChart
|
||||
readOnly
|
||||
compact
|
||||
scale={0.55}
|
||||
selected={selectedTeeth}
|
||||
connectedTeeth={connectedTeeth}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{available.comment ? (
|
||||
<Row
|
||||
label={t('comments')}
|
||||
checked={selection.comment}
|
||||
onChange={toggle('comment')}
|
||||
>
|
||||
<span className="text-sm whitespace-pre-wrap text-text-primary">
|
||||
{effective.comment}
|
||||
</span>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{available.prosthesis && effective.prosthesis ? (
|
||||
<Row
|
||||
label={t('prosthesisColType')}
|
||||
checked={selection.prosthesis}
|
||||
onChange={toggle('prosthesis')}
|
||||
warning={
|
||||
effective.prosthesis.complete
|
||||
? undefined
|
||||
: t('voiceProsthesisIncomplete', {
|
||||
teeth: formatToothList(effective.prosthesis.missingTeeth, locale),
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="text-sm text-text-primary">
|
||||
{Object.entries(effective.prosthesis.byTooth)
|
||||
.map(
|
||||
([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`,
|
||||
)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{available.lab ? (
|
||||
<Row
|
||||
label={t('entryStepLab')}
|
||||
checked={selection.lab}
|
||||
onChange={toggle('lab')}
|
||||
warning={effective.labMatchExact ? undefined : t('voiceLabInexact')}
|
||||
>
|
||||
<span className="text-sm text-text-primary">
|
||||
{labs.find((lab) => lab.id === effective.labId)?.name ?? effective.labId}
|
||||
</span>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{available.dueDate && effective.dueDate ? (
|
||||
<Row
|
||||
label={t('dueDateLabel')}
|
||||
checked={selection.dueDate}
|
||||
onChange={toggle('dueDate')}
|
||||
>
|
||||
<span className="text-sm text-text-primary">
|
||||
{formatDate(civilDateToLocalDate(effective.dueDate))}
|
||||
</span>
|
||||
</Row>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
{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>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button type="button" variant="secondary" onClick={onDiscard} fullWidth className="sm:w-auto">
|
||||
{t('voiceDiscard')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={selectedCount === 0}
|
||||
onClick={() => onApply(selection, effective)}
|
||||
fullWidth
|
||||
className="sm:w-auto"
|
||||
>
|
||||
{t('voiceApply', { count: selectedCount })}
|
||||
</Button>
|
||||
</div>
|
||||
</ResponsiveDialogPanel>
|
||||
</ResponsiveDialogOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
warning,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
warning?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-md)] border border-border/70 px-3 py-2">
|
||||
<Checkbox checked={checked} onChange={onChange} label={label} />
|
||||
<div className="mt-1 ps-7 min-w-0">{children}</div>
|
||||
{warning ? (
|
||||
<p className="mt-1 ps-7 flex items-start gap-1 text-xs text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
|
||||
{warning}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `new Date('2025-10-17')` parses a civil date as UTC midnight, which renders as the 16th
|
||||
* west of Greenwich. Build it from its parts so it means the same day everywhere.
|
||||
*/
|
||||
function civilDateToLocalDate(iso: string): Date {
|
||||
const [year, month, day] = iso.split('-').map(Number);
|
||||
return new Date(year, (month ?? 1) - 1, day ?? 1);
|
||||
}
|
||||
|
||||
/** Locale-aware list separator — the Arabic comma is not correct in en or nl. */
|
||||
function formatToothList(teeth: readonly string[], locale: string): string {
|
||||
try {
|
||||
return new Intl.ListFormat(locale, { style: 'short', type: 'unit' }).format([...teeth]);
|
||||
} catch {
|
||||
return teeth.join(', ');
|
||||
}
|
||||
}
|
||||
29
frontend/src/lib/api/voice.ts
Normal file
29
frontend/src/lib/api/voice.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { apiClient } from './client';
|
||||
import type { VoiceAvailability, VoiceExtractionResult } from '@/types/voice';
|
||||
|
||||
export interface ExtractVoicePayload {
|
||||
/** Base64 audio, no data: prefix. */
|
||||
audio: string;
|
||||
format: string;
|
||||
/** IANA zone — the server derives "today" from it for relative deadlines. */
|
||||
timeZone: string;
|
||||
durationMs: number;
|
||||
/** Locale the clinician is speaking; the server uses it rather than the stored one. */
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export const voiceApi = {
|
||||
availability: async (): Promise<{ success: boolean; data: VoiceAvailability }> => {
|
||||
const response = await apiClient.get('/voice/availability');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
extract: async (
|
||||
payload: ExtractVoicePayload,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ success: boolean; data: VoiceExtractionResult }> => {
|
||||
// Forwarded so cancelling closes the connection; the controller turns that into an abort.
|
||||
const response = await apiClient.post('/voice/extract', payload, { signal });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
60
frontend/src/lib/voice/audioFormat.ts
Normal file
60
frontend/src/lib/voice/audioFormat.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/** Containers the backend accepts, in the order we prefer to record them. */
|
||||
const PREFERRED_MIME_TYPES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/aac',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/ogg',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Pick a container this browser can record AND the backend accepts. Chrome and Android give
|
||||
* webm/opus, Safari and iPad mp4/aac; both go to the vendor unmodified, so there is no
|
||||
* transcode step and the list is an intersection, not a preference.
|
||||
*/
|
||||
export function pickRecordingMimeType(): string | null {
|
||||
if (typeof MediaRecorder === 'undefined') return null;
|
||||
if (typeof MediaRecorder.isTypeSupported !== 'function') {
|
||||
// Safari <14.1 shipped MediaRecorder without the feature check; let it choose.
|
||||
return '';
|
||||
}
|
||||
for (const type of PREFERRED_MIME_TYPES) {
|
||||
if (MediaRecorder.isTypeSupported(type)) return type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `audio/webm;codecs=opus` → `webm`, which is what the API's `format` field wants. */
|
||||
export function mimeTypeToFormat(mimeType: string): string {
|
||||
const base = mimeType.split(';')[0]?.trim().toLowerCase() ?? '';
|
||||
const subtype = base.startsWith('audio/') ? base.slice('audio/'.length) : base;
|
||||
// Safari/iOS records `audio/mp4`, but the transcription endpoint's documented container
|
||||
// list names m4a, not mp4. Same container; send the name the vendor documents, so iPad
|
||||
// recordings do not fail while Chrome's webm works.
|
||||
if (subtype === 'x-m4a' || subtype === 'm4a' || subtype === 'mp4') return 'm4a';
|
||||
if (subtype === 'mpeg') return 'mp3';
|
||||
return subtype || 'webm';
|
||||
}
|
||||
|
||||
/** Blob → base64 without the `data:` prefix, which the API does not want. */
|
||||
export async function blobToBase64(blob: Blob): Promise<string> {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buffer);
|
||||
// Chunked to avoid blowing the argument limit on a two-minute recording.
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function isMediaRecorderSupported(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof MediaRecorder !== 'undefined' &&
|
||||
typeof navigator !== 'undefined' &&
|
||||
Boolean(navigator.mediaDevices?.getUserMedia)
|
||||
);
|
||||
}
|
||||
300
frontend/src/lib/voice/useVoiceCapture.ts
Normal file
300
frontend/src/lib/voice/useVoiceCapture.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { voiceApi } from '@/lib/api/voice';
|
||||
import type { ApiError } from '@/types/api';
|
||||
import type { VoiceExtractionResult, VoicePhase } from '@/types/voice';
|
||||
import {
|
||||
blobToBase64,
|
||||
isMediaRecorderSupported,
|
||||
mimeTypeToFormat,
|
||||
pickRecordingMimeType,
|
||||
} from './audioFormat';
|
||||
|
||||
export interface UseVoiceCaptureOptions {
|
||||
/** The locale the clinician is speaking, sent so the server does not have to guess. */
|
||||
locale: string;
|
||||
/** null means uncapped; otherwise the recorder auto-stops here. */
|
||||
maxMs: number | null;
|
||||
onExtracted: (result: VoiceExtractionResult) => void;
|
||||
onError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface VoiceCaptureState {
|
||||
phase: VoicePhase;
|
||||
elapsedMs: number;
|
||||
/** 0..1, for the level meter — proves the microphone is actually hearing something. */
|
||||
level: number;
|
||||
maxMs: number | null;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const LEVEL_POLL_MS = 100;
|
||||
|
||||
/**
|
||||
* Client-side failures must be ApiError-shaped or getUserFacingError cannot resolve them
|
||||
* and every one renders the generic fallback, leaving errors.VOICE_MIC_DENIED dead.
|
||||
*/
|
||||
function clientError(code: string): ApiError {
|
||||
return { statusCode: 0, code };
|
||||
}
|
||||
|
||||
/**
|
||||
* Microphone capture for treatment voice entry.
|
||||
*
|
||||
* Lives in lib/ rather than in the editor: TreatmentDetailsEditor stays presentational
|
||||
* and receives only a `voice` prop, so MediaRecorder and the API call never enter ui/.
|
||||
*/
|
||||
export function useVoiceCapture({
|
||||
locale,
|
||||
maxMs,
|
||||
onExtracted,
|
||||
onError,
|
||||
}: UseVoiceCaptureOptions): VoiceCaptureState {
|
||||
const [phase, setPhase] = useState<VoicePhase>('idle');
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const [level, setLevel] = useState(0);
|
||||
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const startedAtRef = useRef(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
/** Set when the user cancels, so the recorder's stop handler discards instead of sending. */
|
||||
const cancelledRef = useRef(false);
|
||||
/** getUserMedia is async; without this a permission granted after unmount leaks the mic. */
|
||||
const mountedRef = useRef(true);
|
||||
/**
|
||||
* Set synchronously on click: `phase` only becomes 'recording' once getUserMedia resolves,
|
||||
* so a second click during the permission prompt would orphan the first stream.
|
||||
*/
|
||||
const startingRef = useRef(false);
|
||||
|
||||
const teardown = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
void audioContextRef.current?.close().catch(() => undefined);
|
||||
audioContextRef.current = null;
|
||||
recorderRef.current = null;
|
||||
setLevel(0);
|
||||
}, []);
|
||||
|
||||
// Releasing the microphone on unmount matters: the browser shows a recording indicator
|
||||
// for as long as the track is live, and an orphaned one looks like the app is listening.
|
||||
useEffect(() => {
|
||||
// Re-armed on every mount: React StrictMode runs mount → unmount → mount in dev, and
|
||||
// a ref that is only ever set false would leave the hook permanently "unmounted".
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
cancelledRef.current = true;
|
||||
abortRef.current?.abort();
|
||||
try {
|
||||
recorderRef.current?.stop();
|
||||
} catch {
|
||||
// already stopped
|
||||
}
|
||||
teardown();
|
||||
};
|
||||
}, [teardown]);
|
||||
|
||||
const send = useCallback(
|
||||
async (blob: Blob, mimeType: string, durationMs: number) => {
|
||||
setPhase('processing');
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const audio = await blobToBase64(blob);
|
||||
const response = await voiceApi.extract(
|
||||
{
|
||||
audio,
|
||||
format: mimeTypeToFormat(mimeType),
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
durationMs,
|
||||
locale,
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
if (cancelledRef.current) return;
|
||||
onExtracted(response.data);
|
||||
} catch (error) {
|
||||
if (cancelledRef.current || controller.signal.aborted) return;
|
||||
onError(error);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setPhase('idle');
|
||||
setElapsedMs(0);
|
||||
}
|
||||
},
|
||||
[locale, onExtracted, onError],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
// No recorder means nothing will fire `onstop`, so nothing else moves the phase.
|
||||
if (!recorderRef.current) {
|
||||
teardown();
|
||||
setPhase('idle');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
recorderRef.current.stop();
|
||||
} catch {
|
||||
teardown();
|
||||
setPhase('idle');
|
||||
}
|
||||
}, [teardown]);
|
||||
|
||||
const onStart = useCallback(() => {
|
||||
if (phase !== 'idle' || startingRef.current) return;
|
||||
if (!isMediaRecorderSupported()) {
|
||||
// VOICE_UNSUPPORTED_FORMAT, not MIC_DENIED: nothing asked for a permission yet, and
|
||||
// blaming the microphone sends the clinician into site settings for no reason. Same
|
||||
// for the two paths below.
|
||||
onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
|
||||
return;
|
||||
}
|
||||
|
||||
cancelledRef.current = false;
|
||||
startingRef.current = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch {
|
||||
// Permission refused, or no input device. Never a server round-trip.
|
||||
onError(clientError('VOICE_MIC_DENIED'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mountedRef.current) {
|
||||
// Permission resolved after the component went away — release it immediately
|
||||
// rather than leaving the browser's recording indicator lit.
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
|
||||
const mimeType = pickRecordingMimeType();
|
||||
if (mimeType === null) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
|
||||
return;
|
||||
}
|
||||
|
||||
streamRef.current = stream;
|
||||
chunksRef.current = [];
|
||||
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
||||
recorderRef.current = recorder;
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) chunksRef.current.push(event.data);
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
const durationMs = Date.now() - startedAtRef.current;
|
||||
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || mimeType });
|
||||
teardown();
|
||||
if (cancelledRef.current || blob.size === 0) {
|
||||
setPhase('idle');
|
||||
setElapsedMs(0);
|
||||
return;
|
||||
}
|
||||
// Prefer what the recorder actually produced, then the blob's own type. Old
|
||||
// Safari accepts no mimeType hint, and defaulting to webm would mislabel its
|
||||
// mp4/aac clips as something they are not.
|
||||
void send(blob, recorder.mimeType || blob.type || mimeType || 'audio/webm', durationMs);
|
||||
};
|
||||
|
||||
attachLevelMeter(stream, audioContextRef, setLevel);
|
||||
|
||||
startedAtRef.current = Date.now();
|
||||
recorder.start();
|
||||
setPhase('recording');
|
||||
setElapsedMs(0);
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - startedAtRef.current;
|
||||
setElapsedMs(elapsed);
|
||||
// Auto-stop proceeds to processing with what was captured; discarding two
|
||||
// minutes of dictation because a timer expired would be the worst failure.
|
||||
if (maxMs != null && elapsed >= maxMs) stop();
|
||||
}, LEVEL_POLL_MS);
|
||||
} catch {
|
||||
// `new MediaRecorder()` and `recorder.start()` both throw on some browsers, and by
|
||||
// then the stream is live — without this the mic indicator stays lit until unmount.
|
||||
teardown();
|
||||
setPhase('idle');
|
||||
onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
|
||||
} finally {
|
||||
startingRef.current = false;
|
||||
}
|
||||
})();
|
||||
}, [maxMs, onError, phase, send, stop, teardown]);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
cancelledRef.current = true;
|
||||
// Aborting closes the connection, which aborts the vendor call server-side.
|
||||
abortRef.current?.abort();
|
||||
try {
|
||||
recorderRef.current?.stop();
|
||||
} catch {
|
||||
// already stopped
|
||||
}
|
||||
teardown();
|
||||
setPhase('idle');
|
||||
setElapsedMs(0);
|
||||
}, [teardown]);
|
||||
|
||||
return { phase, elapsedMs, level, maxMs, onStart, onStop: stop, onCancel };
|
||||
}
|
||||
|
||||
/** Drives the level meter from the live stream; failure here must not stop recording. */
|
||||
function attachLevelMeter(
|
||||
stream: MediaStream,
|
||||
contextRef: React.MutableRefObject<AudioContext | null>,
|
||||
setLevel: (value: number) => void,
|
||||
) {
|
||||
try {
|
||||
const AudioContextCtor =
|
||||
window.AudioContext ?? (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioContextCtor) return;
|
||||
|
||||
const context = new AudioContextCtor();
|
||||
contextRef.current = context;
|
||||
const source = context.createMediaStreamSource(stream);
|
||||
const analyser = context.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
source.connect(analyser);
|
||||
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
// Sample every frame so a transient is not missed, publish at LEVEL_POLL_MS. The hook
|
||||
// lives in TreatmentWorkspace, so an unthrottled setLevel is ~7,200 whole-tree renders
|
||||
// across a two-minute recording.
|
||||
let peakSinceEmit = 0;
|
||||
let lastEmit = 0;
|
||||
const tick = (now: number) => {
|
||||
if (contextRef.current !== context || context.state === 'closed') return;
|
||||
analyser.getByteTimeDomainData(data);
|
||||
let peak = 0;
|
||||
for (const sample of data) peak = Math.max(peak, Math.abs(sample - 128));
|
||||
peakSinceEmit = Math.max(peakSinceEmit, peak);
|
||||
if (now - lastEmit >= LEVEL_POLL_MS) {
|
||||
lastEmit = now;
|
||||
setLevel(Math.min(1, peakSinceEmit / 128));
|
||||
peakSinceEmit = 0;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
} catch {
|
||||
// A missing or blocked AudioContext costs the meter, not the recording.
|
||||
}
|
||||
}
|
||||
64
frontend/src/types/voice.ts
Normal file
64
frontend/src/types/voice.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { FdiToothId, ToothSelectionGroup } from '@/types/treatment';
|
||||
|
||||
/** Mirrors the backend's ResolvedExtraction — values already resolved, plus what was not. */
|
||||
|
||||
export type VoiceUnresolvedReason =
|
||||
| 'not_permanent_tooth'
|
||||
| 'position_out_of_range'
|
||||
| 'tooth_missing_quadrant'
|
||||
| 'malformed'
|
||||
| 'span_not_same_arch'
|
||||
| 'unknown_catalog_code'
|
||||
| 'tooth_not_selected'
|
||||
| 'invalid_date';
|
||||
|
||||
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 {
|
||||
byTooth: Record<string, string>;
|
||||
/** False means the case cannot ship — every tooth needs a prosthesis type. */
|
||||
complete: boolean;
|
||||
missingTeeth: FdiToothId[];
|
||||
}
|
||||
|
||||
export interface VoiceExtractionResult {
|
||||
transcript: string;
|
||||
treatmentType: string | null;
|
||||
teeth: FdiToothId[];
|
||||
toothSelectionGroups: ToothSelectionGroup[];
|
||||
comment: string | null;
|
||||
prosthesis: VoiceProsthesisResult | null;
|
||||
labId: string | null;
|
||||
/** When false, the lab row must not tick itself — the name only approximately matched. */
|
||||
labMatchExact: boolean;
|
||||
dueDate: string | null;
|
||||
unresolved: VoiceUnresolvedItem[];
|
||||
}
|
||||
|
||||
export interface VoiceAvailability {
|
||||
enabled: boolean;
|
||||
locales: string[];
|
||||
/** null means uncapped. */
|
||||
maxRecordingMs: number | null;
|
||||
}
|
||||
|
||||
/** Which review rows the clinician ticked. */
|
||||
export interface VoiceApplySelection {
|
||||
treatmentType: boolean;
|
||||
teeth: boolean;
|
||||
comment: boolean;
|
||||
prosthesis: boolean;
|
||||
lab: boolean;
|
||||
dueDate: boolean;
|
||||
}
|
||||
|
||||
export type VoicePhase = 'idle' | 'recording' | 'processing';
|
||||
Reference in New Issue
Block a user