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:
@@ -30,6 +30,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';
|
||||
@@ -458,6 +466,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),
|
||||
@@ -483,6 +496,97 @@ export function TreatmentWorkspace({
|
||||
[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(
|
||||
() => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null,
|
||||
[standaloneTreatments, selectedStandaloneId],
|
||||
@@ -898,12 +1002,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);
|
||||
@@ -2381,6 +2491,7 @@ export function TreatmentWorkspace({
|
||||
}}
|
||||
showChrome
|
||||
showFields={entryStep === 'treatment'}
|
||||
voice={voiceForEditor}
|
||||
chartLocked={
|
||||
entryStep === 'treatment' && !activeTypeSelected && !showWholeTreatmentPlan
|
||||
}
|
||||
@@ -2642,6 +2753,16 @@ export function TreatmentWorkspace({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{voiceResult ? (
|
||||
<VoiceReviewSheet
|
||||
result={voiceResult}
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
prosthesisCatalog={prosthesisCatalog}
|
||||
labs={orgs}
|
||||
onApply={(selection) => applyVoiceResult(voiceResult, selection)}
|
||||
onDiscard={() => setVoiceResult(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user