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:
2026-08-20 20:22:42 +03:30
parent 8757a8952c
commit 56d413944a
13 changed files with 533 additions and 21 deletions

View File

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