An under-specified tooth was a dead end: the sheet said what was missing and the clinician had to leave and hunt for it on the chart. The readings are enumerable, so the review sheet now renders them as chips — the one interactive part of an otherwise read-only confirmation step. A pick is folded into the result by withChosenTeeth() rather than tracked alongside it, so the rows, the mini chart, the prosthesis warning and applyVoiceResult all keep reading a single VoiceExtractionResult and none of them has to know the chips exist. It unions rather than toggles: a candidate can coincidentally be a tooth the recording already produced, and tapping it must not deselect that one. Two things that would otherwise make the chips look functional while applying nothing: the teeth row is ticked on the first pick (it starts unticked when the recording produced no teeth of its own), and the apply count is now intersected with row availability so it cannot promise to apply a row with nothing in it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
'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,
|
|
withChosenTeeth,
|
|
} 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 { FdiToothId, LinkedOrganizationOption } from '@/types/treatment';
|
|
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
|
|
|
|
interface VoiceReviewSheetProps {
|
|
result: VoiceExtractionResult;
|
|
treatmentCatalog: TreatmentCatalogEntry[];
|
|
prosthesisCatalog: ProsthesisCatalogEntry[];
|
|
labs: LinkedOrganizationOption[];
|
|
/** The result is handed back because the sheet may have added teeth the model missed. */
|
|
onApply: (selection: VoiceApplySelection, result: VoiceExtractionResult) => 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 [chosen, setChosen] = useState<FdiToothId[]>([]);
|
|
|
|
// Everything below renders from `effective`, never from `result` — a tooth picked from
|
|
// the candidate chips has to reach the rows, the chart and the apply count alike.
|
|
const effective = useMemo(() => withChosenTeeth(result, chosen), [result, chosen]);
|
|
|
|
const available = useMemo(() => voiceRowAvailability(effective), [effective]);
|
|
const connectedTeeth = useMemo(() => connectedTeethFromResult(effective), [effective]);
|
|
const selectedTeeth = useMemo(() => new Set(effective.teeth), [effective.teeth]);
|
|
const nothingToApply = !hasAnythingToApply(effective);
|
|
const selectedCount = countSelected(selection, available);
|
|
|
|
const pickCandidate = (tooth: FdiToothId) => {
|
|
setChosen((prev) =>
|
|
prev.includes(tooth) ? prev.filter((t) => t !== tooth) : [...prev, tooth],
|
|
);
|
|
// The teeth row starts unticked whenever the recording produced no teeth of its own,
|
|
// and a picked tooth that is not ticked applies nothing.
|
|
setSelection((prev) => (prev.teeth ? prev : { ...prev, teeth: true }));
|
|
};
|
|
|
|
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">
|
|
{effective.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(effective.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">
|
|
{effective.comment}
|
|
</span>
|
|
</Row>
|
|
) : null}
|
|
|
|
{available.prosthesis && effective.prosthesis ? (
|
|
<Row
|
|
label={t('prosthesisColType')}
|
|
checked={selection.prosthesis}
|
|
onChange={toggle('prosthesis')}
|
|
warning={
|
|
effective.prosthesis.complete
|
|
? undefined
|
|
: t('voiceProsthesisIncomplete', {
|
|
teeth: formatToothList(effective.prosthesis.missingTeeth, locale),
|
|
})
|
|
}
|
|
>
|
|
<span className="text-sm text-text-primary">
|
|
{Object.entries(effective.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={effective.labMatchExact ? undefined : t('voiceLabInexact')}
|
|
>
|
|
<span className="text-sm text-text-primary">
|
|
{labs.find((lab) => lab.id === effective.labId)?.name ?? effective.labId}
|
|
</span>
|
|
</Row>
|
|
) : null}
|
|
|
|
{available.dueDate && effective.dueDate ? (
|
|
<Row
|
|
label={t('dueDateLabel')}
|
|
checked={selection.dueDate}
|
|
onChange={toggle('dueDate')}
|
|
>
|
|
<span className="text-sm text-text-primary">
|
|
{formatDate(civilDateToLocalDate(effective.dueDate))}
|
|
</span>
|
|
</Row>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{effective.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">
|
|
{effective.unresolved.map((item, index) => (
|
|
<li key={`${item.spoken}-${index}`} className="text-xs text-text-secondary">
|
|
{item.spoken ? `“${item.spoken}” — ` : ''}
|
|
{t(`voiceUnresolved.${item.reason}`)}
|
|
{item.candidates && item.candidates.length > 0 ? (
|
|
<span className="mt-1 flex flex-wrap items-center gap-1">
|
|
<span className="text-text-muted">{t('voicePickTooth')}</span>
|
|
{item.candidates.map((tooth) => {
|
|
const picked = chosen.includes(tooth as FdiToothId);
|
|
return (
|
|
<button
|
|
key={tooth}
|
|
type="button"
|
|
aria-pressed={picked}
|
|
aria-label={t('toothAria', { fdi: tooth })}
|
|
onClick={() => pickCandidate(tooth as FdiToothId)}
|
|
className={`rounded-full border px-2 py-0.5 text-xs transition-colors ${
|
|
picked
|
|
? 'border-transparent bg-primary text-white'
|
|
: 'border-border text-text-primary hover:border-border-strong'
|
|
}`}
|
|
>
|
|
{tooth}
|
|
</button>
|
|
);
|
|
})}
|
|
</span>
|
|
) : null}
|
|
</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, effective)}
|
|
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(', ');
|
|
}
|
|
}
|