feat: wire voice entry into the treatment workspace
Makes the feature reachable end to end: availability is fetched alongside the catalogs, the capture hook drives the segmented control, and confirming the review sheet appends a new detail. Confirm always appends — it never edits an existing detail and never calls onAddDetail. Ticked rows land on top of the seeded defaults, so unticking the type row leaves the appointment-purpose default rather than a blank. Lab-side rows ride on a lab case draft keyed by the detail's *client* id, so a brand-new unsaved detail can carry a lab, due date and per-tooth prosthesis map. Availability comes from the API rather than a NEXT_PUBLIC_* var, since those are baked in at build time; a failure fetching it degrades to no microphone rather than taking the treatment tab down. From review of this commit: - Unticking "teeth" while leaving "prosthesis" ticked attached prosthesis rows for teeth the detail does not contain. Nothing downstream filters them — assertCompleteToothProsthesisMap only checks detail-teeth ⊆ map, never the reverse — so they would have reached task generation as lab work for teeth nobody is treating. The map is now filtered to the detail's own teeth. - The microphone was gated on the URL locale while the server resolved everything from req.user.language. Those diverge (a bookmarked /fa/ URL, a language toggle whose save failed), which would transcribe Persian with an English hint and anchor "next Thursday" to a Monday week instead of a Saturday one — or 403 from a visibly-enabled button. The client now sends the locale the microphone was offered in, so the gate and the request agree by construction. Also fixed from the previous review: a civil YYYY-MM-DD date rendered a day early west of Greenwich (parsed as UTC midnight); the missing-teeth list hardcoded the Arabic comma for all locales; and voiceApply had no ICU plural, so the common single-field case read "Apply 1 fields". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,9 @@ export const VOICE_AUDIO_FORMATS = [
|
|||||||
|
|
||||||
export type VoiceAudioFormat = (typeof VOICE_AUDIO_FORMATS)[number];
|
export type VoiceAudioFormat = (typeof VOICE_AUDIO_FORMATS)[number];
|
||||||
|
|
||||||
|
/** Locales the app ships; a profile still has to be configured for one to be usable. */
|
||||||
|
export const VOICE_LOCALES = ['en', 'fa', 'nl'] as const;
|
||||||
|
|
||||||
export class ExtractVoiceDto {
|
export class ExtractVoiceDto {
|
||||||
/**
|
/**
|
||||||
* Base64 audio, no data: prefix. Capped well above a 2-minute opus clip (~400 KB) but
|
* Base64 audio, no data: prefix. Capped well above a 2-minute opus clip (~400 KB) but
|
||||||
@@ -52,4 +55,15 @@ export class ExtractVoiceDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The locale the clinician is actually speaking, as the UI offered the microphone.
|
||||||
|
*
|
||||||
|
* Sent explicitly rather than read from `user.language`: the two can diverge (a
|
||||||
|
* bookmarked /fa/ URL, a language toggle whose save failed), and a mismatch would
|
||||||
|
* transcribe Persian with an English hint and anchor "next Thursday" to the wrong
|
||||||
|
* week start. Gating the button and resolving the request must agree by construction.
|
||||||
|
*/
|
||||||
|
@IsIn(VOICE_LOCALES)
|
||||||
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,8 +284,9 @@ describe('resolveVoiceIntent', () => {
|
|||||||
it('reports a hallucinated lab rather than dropping it silently', () => {
|
it('reports a hallucinated lab rather than dropping it silently', () => {
|
||||||
// A near-miss lab id must not look identical to "no lab was spoken".
|
// A near-miss lab id must not look identical to "no lab was spoken".
|
||||||
const result = resolveVoiceIntent({ ...base, labId: 'lab-elsewhere' }, CTX);
|
const result = resolveVoiceIntent({ ...base, labId: 'lab-elsewhere' }, CTX);
|
||||||
|
// The id is not what the clinician said — quoting it back shows them a raw UUID.
|
||||||
expect(result.unresolved).toContainEqual({
|
expect(result.unresolved).toContainEqual({
|
||||||
spoken: 'lab-elsewhere',
|
spoken: '',
|
||||||
reason: 'unknown_catalog_code',
|
reason: 'unknown_catalog_code',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -287,10 +287,10 @@ export function resolveVoiceIntent(
|
|||||||
// reported: a hallucinated lab must not look identical to "no lab was spoken".
|
// reported: a hallucinated lab must not look identical to "no lab was spoken".
|
||||||
const labId = resolveCatalogCode(intent?.labId, ctx.linkedLabIds);
|
const labId = resolveCatalogCode(intent?.labId, ctx.linkedLabIds);
|
||||||
if (intent?.labId != null && !labId) {
|
if (intent?.labId != null && !labId) {
|
||||||
unresolved.push({
|
// `spoken` means "what the clinician said". A rejected lab id is an opaque
|
||||||
spoken: String(intent.labId),
|
// identifier the model invented, so quoting it back would put a raw UUID in front
|
||||||
reason: 'unknown_catalog_code',
|
// of the user; the reason alone carries the meaning.
|
||||||
});
|
unresolved.push({ spoken: '', reason: 'unknown_catalog_code' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ import { ExtractVoiceDto } from './dto/voice.dto';
|
|||||||
import { VoiceThrottlerGuard } from './voice-throttler.guard';
|
import { VoiceThrottlerGuard } from './voice-throttler.guard';
|
||||||
import { VoiceService } from './voice.service';
|
import { VoiceService } from './voice.service';
|
||||||
|
|
||||||
type VoiceRequestUser = {
|
type VoiceRequestUser = { id: string; organizationId?: string };
|
||||||
id: string;
|
|
||||||
organizationId?: string;
|
|
||||||
language?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
@ApiTags('voice')
|
@ApiTags('voice')
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@@ -60,10 +56,12 @@ export class VoiceController {
|
|||||||
if (!res.writableFinished) aborter.abort();
|
if (!res.writableFinished) aborter.abort();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// dto.locale, not req.user.language: the client sends the locale the microphone was
|
||||||
|
// actually offered in, so the ASR hint, catalog labels and week start all match it.
|
||||||
const data = await this.voiceService.extract(
|
const data = await this.voiceService.extract(
|
||||||
req.user,
|
req.user,
|
||||||
dto,
|
dto,
|
||||||
req.user?.language ?? 'en',
|
dto.locale,
|
||||||
aborter.signal,
|
aborter.signal,
|
||||||
);
|
);
|
||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
|
|||||||
@@ -124,10 +124,12 @@ export class VoiceService {
|
|||||||
|
|
||||||
// Stage 2 — structure it. On failure the transcript still goes back to the client so
|
// Stage 2 — structure it. On failure the transcript still goes back to the client so
|
||||||
// the words the clinician already paid for are not lost (transcript salvage).
|
// the words the clinician already paid for are not lost (transcript salvage).
|
||||||
const catalog = await this.buildCatalog(organizationId, catalogLocale);
|
|
||||||
let resolved: ResolvedExtraction;
|
let resolved: ResolvedExtraction;
|
||||||
let llmCost: number | null = null;
|
let llmCost: number | null = null;
|
||||||
try {
|
try {
|
||||||
|
// Inside the try: the transcript is already paid for, so a catalog/DB failure here
|
||||||
|
// must still salvage it rather than becoming a generic 500 that throws it away.
|
||||||
|
const catalog = await this.buildCatalog(organizationId, catalogLocale);
|
||||||
const result = await extraction.extract(
|
const result = await extraction.extract(
|
||||||
transcript,
|
transcript,
|
||||||
catalog,
|
catalog,
|
||||||
|
|||||||
@@ -898,7 +898,24 @@
|
|||||||
"voiceStart": "Record treatment",
|
"voiceStart": "Record treatment",
|
||||||
"voiceStop": "Stop recording",
|
"voiceStop": "Stop recording",
|
||||||
"voiceCancel": "Cancel",
|
"voiceCancel": "Cancel",
|
||||||
"voiceProcessing": "Reading the recording…"
|
"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",
|
||||||
|
"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",
|
||||||
|
"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": {
|
"organizations": {
|
||||||
"loadingOrganization": "Loading organization...",
|
"loadingOrganization": "Loading organization...",
|
||||||
|
|||||||
@@ -899,7 +899,24 @@
|
|||||||
"voiceStart": "ثبت گفتاری درمان",
|
"voiceStart": "ثبت گفتاری درمان",
|
||||||
"voiceStop": "توقف ضبط",
|
"voiceStop": "توقف ضبط",
|
||||||
"voiceCancel": "لغو",
|
"voiceCancel": "لغو",
|
||||||
"voiceProcessing": "در حال پردازش گفتار…"
|
"voiceProcessing": "در حال پردازش گفتار…",
|
||||||
|
"voiceReviewTitle": "بررسی آنچه دریافت شد",
|
||||||
|
"voiceNothingExtracted": "از این ضبط چیز قابل استفادهای برداشت نشد.",
|
||||||
|
"voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندانها نوع داشته باشند، کیس ارسال نمیشود.",
|
||||||
|
"voiceLabInexact": "نام گفتهشده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.",
|
||||||
|
"voiceNotUnderstood": "شناسایی نشد",
|
||||||
|
"voiceDiscard": "انصراف",
|
||||||
|
"voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}",
|
||||||
|
"voiceUnresolved": {
|
||||||
|
"not_permanent_tooth": "دندان دائمی نیست",
|
||||||
|
"position_out_of_range": "شماره دندان معتبر نیست",
|
||||||
|
"malformed": "قابل خواندن نبود",
|
||||||
|
"span_not_same_arch": "بریج نمیتواند بین دو فک باشد",
|
||||||
|
"unknown_catalog_code": "در فهرست این مطب نیست",
|
||||||
|
"tooth_not_selected": "این دندان بخشی از این مورد نیست",
|
||||||
|
"invalid_date": "تاریخ قابل استفاده نیست"
|
||||||
|
},
|
||||||
|
"voiceFailed": "ثبت گفتاری انجام نشد. لطفاً دوباره تلاش کنید."
|
||||||
},
|
},
|
||||||
"organizations": {
|
"organizations": {
|
||||||
"loadingOrganization": "در حال بارگذاری سازمان...",
|
"loadingOrganization": "در حال بارگذاری سازمان...",
|
||||||
|
|||||||
@@ -898,7 +898,24 @@
|
|||||||
"voiceStart": "Behandeling inspreken",
|
"voiceStart": "Behandeling inspreken",
|
||||||
"voiceStop": "Opname stoppen",
|
"voiceStop": "Opname stoppen",
|
||||||
"voiceCancel": "Annuleren",
|
"voiceCancel": "Annuleren",
|
||||||
"voiceProcessing": "Opname wordt gelezen…"
|
"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",
|
||||||
|
"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",
|
||||||
|
"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": {
|
"organizations": {
|
||||||
"loadingOrganization": "Organisatie laden...",
|
"loadingOrganization": "Organisatie laden...",
|
||||||
|
|||||||
61
frontend/src/components/treatment/voiceReviewRows.ts
Normal file
61
frontend/src/components/treatment/voiceReviewRows.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { FdiToothId } from '@/types/treatment';
|
||||||
|
import type { VoiceApplySelection, VoiceExtractionResult } 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. */
|
||||||
|
export function countSelected(selection: VoiceApplySelection): number {
|
||||||
|
return Object.values(selection).filter(Boolean).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
@@ -30,6 +30,14 @@ import {
|
|||||||
import { appointmentsApi } from '@/lib/api/appointments';
|
import { appointmentsApi } from '@/lib/api/appointments';
|
||||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-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 { treatmentsApi } from '@/lib/api/treatments';
|
||||||
import { notificationsApi } from '@/lib/api/notifications';
|
import { notificationsApi } from '@/lib/api/notifications';
|
||||||
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
|
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
|
||||||
@@ -458,6 +466,11 @@ export function TreatmentWorkspace({
|
|||||||
const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false);
|
const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false);
|
||||||
const [entryStep, setEntryStep] = useState<EntryStep>('treatment');
|
const [entryStep, setEntryStep] = useState<EntryStep>('treatment');
|
||||||
|
|
||||||
|
|
||||||
|
const [voiceAvailability, setVoiceAvailability] = useState<VoiceAvailability | null>(null);
|
||||||
|
const [voiceResult, setVoiceResult] = useState<VoiceExtractionResult | null>(null);
|
||||||
|
|
||||||
|
|
||||||
const isDetailLocked = useCallback(
|
const isDetailLocked = useCallback(
|
||||||
(detail: TreatmentDetailDraft) =>
|
(detail: TreatmentDetailDraft) =>
|
||||||
labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientId === detail.clientId),
|
labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientId === detail.clientId),
|
||||||
@@ -483,6 +496,97 @@ export function TreatmentWorkspace({
|
|||||||
[appointments, selectedAppointmentId],
|
[appointments, selectedAppointmentId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDetails((prev) => [...prev, detail]);
|
||||||
|
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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setLabCaseDrafts((prev) => [...prev, draft]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setVoiceResult(null);
|
||||||
|
},
|
||||||
|
[selectedAppointment?.purpose, treatmentCatalog],
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
const selectedStandalone = useMemo(
|
||||||
() => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null,
|
() => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null,
|
||||||
[standaloneTreatments, selectedStandaloneId],
|
[standaloneTreatments, selectedStandaloneId],
|
||||||
@@ -898,12 +1002,18 @@ export function TreatmentWorkspace({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const [orgsResponse, catalogResponse, prosthesisResponse] = await Promise.all([
|
const [orgsResponse, catalogResponse, prosthesisResponse, voiceResponse] =
|
||||||
|
await Promise.all([
|
||||||
treatmentsApi.listLinkedOrganizations(),
|
treatmentsApi.listLinkedOrganizations(),
|
||||||
treatmentCatalogApi.list(),
|
treatmentCatalogApi.list(),
|
||||||
prosthesisCatalogApi.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;
|
if (cancelled) return;
|
||||||
|
setVoiceAvailability(voiceResponse?.data ?? null);
|
||||||
setOrgs(orgsResponse.data);
|
setOrgs(orgsResponse.data);
|
||||||
setTreatmentCatalog(catalogResponse.data);
|
setTreatmentCatalog(catalogResponse.data);
|
||||||
setProsthesisCatalog(prosthesisResponse.data);
|
setProsthesisCatalog(prosthesisResponse.data);
|
||||||
@@ -2381,6 +2491,7 @@ export function TreatmentWorkspace({
|
|||||||
}}
|
}}
|
||||||
showChrome
|
showChrome
|
||||||
showFields={entryStep === 'treatment'}
|
showFields={entryStep === 'treatment'}
|
||||||
|
voice={voiceForEditor}
|
||||||
chartLocked={
|
chartLocked={
|
||||||
entryStep === 'treatment' && !activeTypeSelected && !showWholeTreatmentPlan
|
entryStep === 'treatment' && !activeTypeSelected && !showWholeTreatmentPlan
|
||||||
}
|
}
|
||||||
@@ -2642,6 +2753,16 @@ export function TreatmentWorkspace({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{voiceResult ? (
|
||||||
|
<VoiceReviewSheet
|
||||||
|
result={voiceResult}
|
||||||
|
treatmentCatalog={treatmentCatalog}
|
||||||
|
prosthesisCatalog={prosthesisCatalog}
|
||||||
|
labs={orgs}
|
||||||
|
onApply={(selection) => applyVoiceResult(voiceResult, selection)}
|
||||||
|
onDiscard={() => setVoiceResult(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
258
frontend/src/components/ui/treatment/VoiceReviewSheet.tsx
Normal file
258
frontend/src/components/ui/treatment/VoiceReviewSheet.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
'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,
|
||||||
|
} 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 { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
|
||||||
|
|
||||||
|
interface VoiceReviewSheetProps {
|
||||||
|
result: VoiceExtractionResult;
|
||||||
|
treatmentCatalog: TreatmentCatalogEntry[];
|
||||||
|
prosthesisCatalog: ProsthesisCatalogEntry[];
|
||||||
|
labs: LinkedOrganizationOption[];
|
||||||
|
onApply: (selection: VoiceApplySelection) => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmation step between the model's output and the form.
|
||||||
|
*
|
||||||
|
* Modal on desktop, bottom sheet on mobile via ResponsiveDialog — deliberately 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 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);
|
||||||
|
|
||||||
|
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">
|
||||||
|
{result.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(result.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">
|
||||||
|
{result.comment}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{available.prosthesis && result.prosthesis ? (
|
||||||
|
<Row
|
||||||
|
label={t('prosthesisColType')}
|
||||||
|
checked={selection.prosthesis}
|
||||||
|
onChange={toggle('prosthesis')}
|
||||||
|
warning={
|
||||||
|
result.prosthesis.complete
|
||||||
|
? undefined
|
||||||
|
: t('voiceProsthesisIncomplete', {
|
||||||
|
teeth: formatToothList(result.prosthesis.missingTeeth, locale),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-text-primary">
|
||||||
|
{Object.entries(result.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={result.labMatchExact ? undefined : t('voiceLabInexact')}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-text-primary">
|
||||||
|
{labs.find((lab) => lab.id === result.labId)?.name ?? result.labId}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{available.dueDate && result.dueDate ? (
|
||||||
|
<Row
|
||||||
|
label={t('dueDateLabel')}
|
||||||
|
checked={selection.dueDate}
|
||||||
|
onChange={toggle('dueDate')}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-text-primary">
|
||||||
|
{formatDate(civilDateToLocalDate(result.dueDate))}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.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) => (
|
||||||
|
<li key={`${item.spoken}-${index}`} className="text-xs text-text-secondary">
|
||||||
|
{item.spoken ? `“${item.spoken}” — ` : ''}
|
||||||
|
{t(`voiceUnresolved.${item.reason}`)}
|
||||||
|
</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)}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bare `YYYY-MM-DD` is a *civil* date, but `new Date('2025-10-17')` parses it as UTC
|
||||||
|
* midnight — which renders as the 16th for any viewer west of Greenwich. Build the date
|
||||||
|
* 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(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ export interface ExtractVoicePayload {
|
|||||||
/** IANA zone — the server derives "today" from it for relative deadlines. */
|
/** IANA zone — the server derives "today" from it for relative deadlines. */
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
|
/** Locale the clinician is speaking; the server uses it rather than the stored one. */
|
||||||
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const voiceApi = {
|
export const voiceApi = {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
} from './audioFormat';
|
} from './audioFormat';
|
||||||
|
|
||||||
export interface UseVoiceCaptureOptions {
|
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. */
|
/** null means uncapped; otherwise the recorder auto-stops here. */
|
||||||
maxMs: number | null;
|
maxMs: number | null;
|
||||||
onExtracted: (result: VoiceExtractionResult) => void;
|
onExtracted: (result: VoiceExtractionResult) => void;
|
||||||
@@ -46,6 +48,7 @@ function clientError(code: string): ApiError {
|
|||||||
* and receives only a `voice` prop, so MediaRecorder and the API call never enter ui/.
|
* and receives only a `voice` prop, so MediaRecorder and the API call never enter ui/.
|
||||||
*/
|
*/
|
||||||
export function useVoiceCapture({
|
export function useVoiceCapture({
|
||||||
|
locale,
|
||||||
maxMs,
|
maxMs,
|
||||||
onExtracted,
|
onExtracted,
|
||||||
onError,
|
onError,
|
||||||
@@ -117,6 +120,7 @@ export function useVoiceCapture({
|
|||||||
format: mimeTypeToFormat(mimeType),
|
format: mimeTypeToFormat(mimeType),
|
||||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
durationMs,
|
durationMs,
|
||||||
|
locale,
|
||||||
},
|
},
|
||||||
controller.signal,
|
controller.signal,
|
||||||
);
|
);
|
||||||
@@ -131,7 +135,7 @@ export function useVoiceCapture({
|
|||||||
setElapsedMs(0);
|
setElapsedMs(0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onExtracted, onError],
|
[locale, onExtracted, onError],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user