259 lines
8.9 KiB
TypeScript
259 lines
8.9 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,
|
||
|
|
} 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(', ');
|
||
|
|
}
|
||
|
|
}
|