Files
dyolink/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx

259 lines
8.9 KiB
TypeScript
Raw Normal View History

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>
2026-08-20 20:22:42 +03:30
'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(', ');
}
}