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:
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(', ');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user