2026-06-28 17:14:02 +03:30
|
|
|
'use client';
|
|
|
|
|
|
2026-08-19 23:59:37 +03:30
|
|
|
import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
|
2026-06-28 17:14:02 +03:30
|
|
|
import { useTranslations } from 'next-intl';
|
feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.
The two halves share a wrapper and nothing else. Add keeps its exact behaviour.
The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.
Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.
Fixes from review of this commit:
- mountedRef was set false on unmount and never re-armed, so under StrictMode
the hook was permanently "unmounted" in dev and recording silently never
started.
- onStart guarded only on `phase`, which does not change until getUserMedia
resolves; a second click during the permission prompt orphaned the first
MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
a bare weekday carries no qualifier, and rejecting it discarded a real
deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
Safari's mp4/aac clips are not mislabelled.
Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:47:10 +03:30
|
|
|
import { Mic, Square, Trash2 } from 'lucide-react';
|
2026-06-28 17:14:02 +03:30
|
|
|
import { Button } from '@/components/ui/shared/Button';
|
|
|
|
|
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
2026-08-19 23:59:37 +03:30
|
|
|
import { formatDetailChipLabel } from '@/components/treatment/detailChipLabel';
|
|
|
|
|
import { autosaveStatusClass, labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles';
|
|
|
|
|
import { TreatmentDetailAttachmentsStrip } from '@/components/ui/treatment/TreatmentDetailAttachmentsStrip';
|
feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.
The two halves share a wrapper and nothing else. Add keeps its exact behaviour.
The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.
Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.
Fixes from review of this commit:
- mountedRef was set false on unmount and never re-armed, so under StrictMode
the hook was permanently "unmounted" in dev and recording silently never
started.
- onStart guarded only on `phase`, which does not change until getUserMedia
resolves; a second click during the permission prompt orphaned the first
MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
a bare weekday carries no qualifier, and rejecting it discarded a real
deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
Safari's mp4/aac clips are not mislabelled.
Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:47:10 +03:30
|
|
|
import { VoiceRecordingBar } from '@/components/ui/treatment/VoiceRecordingBar';
|
|
|
|
|
import type { VoiceCaptureState } from '@/lib/voice/useVoiceCapture';
|
2026-06-28 17:14:02 +03:30
|
|
|
import type { TreatmentDetailDraft } from '@/types/treatment';
|
2026-07-06 20:40:19 +03:30
|
|
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
2026-08-19 23:59:37 +03:30
|
|
|
import { treatmentTypeColor, treatmentTypeOptionStyle } from '@/components/shared/treatmentTypeDisplay';
|
2026-07-13 01:03:26 +03:30
|
|
|
import {
|
|
|
|
|
isDetailTypeSelected,
|
|
|
|
|
isLabDependentDetailMissingTeeth,
|
|
|
|
|
} from '@/components/treatment/treatmentDetailRules';
|
2026-06-28 17:14:02 +03:30
|
|
|
|
|
|
|
|
interface TreatmentDetailsEditorProps {
|
|
|
|
|
details: TreatmentDetailDraft[];
|
|
|
|
|
activeDetailId: string;
|
|
|
|
|
onActiveDetailChange: (id: string) => void;
|
|
|
|
|
onDetailsChange: (details: TreatmentDetailDraft[]) => void;
|
|
|
|
|
isDetailLocked: (detail: TreatmentDetailDraft) => boolean;
|
2026-06-28 21:45:33 +03:30
|
|
|
labDependentCodes: Set<string>;
|
2026-07-06 20:40:19 +03:30
|
|
|
treatmentCatalog: TreatmentCatalogEntry[];
|
2026-06-28 17:14:02 +03:30
|
|
|
disabled: boolean;
|
|
|
|
|
canEdit: boolean;
|
2026-06-28 18:08:29 +03:30
|
|
|
saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
|
2026-06-28 17:14:02 +03:30
|
|
|
uploadBusy: boolean;
|
2026-08-22 19:36:37 +03:30
|
|
|
onAddDetail: () => void;
|
2026-07-17 00:39:32 +03:30
|
|
|
onRemoveDetail?: (detailClientId: string) => void;
|
2026-08-19 23:59:37 +03:30
|
|
|
onUploadFiles: (files: File[], onProgress: (percent: number) => void) => Promise<void>;
|
|
|
|
|
onRemoveAttachment?: (attachmentId: string) => void;
|
2026-07-17 00:39:32 +03:30
|
|
|
/** Detail chips + Add detail (default true). */
|
|
|
|
|
showChrome?: boolean;
|
|
|
|
|
/** Type / notes / attachments fields (default true). */
|
|
|
|
|
showFields?: boolean;
|
2026-08-19 23:59:37 +03:30
|
|
|
/** FDI chart (or other) rendered beside notes on wide screens. */
|
|
|
|
|
chart?: ReactNode;
|
|
|
|
|
/** Shown below chrome (e.g. prosthesis wizard). */
|
|
|
|
|
stepper?: ReactNode;
|
|
|
|
|
/** Shown below type + chart + notes (e.g. Continue to lab). */
|
|
|
|
|
footer?: ReactNode;
|
feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.
The two halves share a wrapper and nothing else. Add keeps its exact behaviour.
The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.
Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.
Fixes from review of this commit:
- mountedRef was set false on unmount and never re-armed, so under StrictMode
the hook was permanently "unmounted" in dev and recording silently never
started.
- onStart guarded only on `phase`, which does not change until getUserMedia
resolves; a second click during the permission prompt orphaned the first
MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
a bare weekday carries no qualifier, and rejecting it discarded a real
deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
Safari's mp4/aac clips are not mislabelled.
Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:47:10 +03:30
|
|
|
/**
|
|
|
|
|
* Voice entry. Omit when unavailable — the Add button then renders unsplit, exactly as
|
|
|
|
|
* before this feature existed. Presence *is* the availability flag, so the two cannot
|
|
|
|
|
* disagree.
|
|
|
|
|
*/
|
|
|
|
|
voice?: VoiceCaptureState;
|
2026-08-19 23:59:37 +03:30
|
|
|
/** Dim the chart until a treatment type is chosen. */
|
|
|
|
|
chartLocked?: boolean;
|
|
|
|
|
chartLockMessage?: string;
|
2026-06-28 17:14:02 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function TreatmentDetailsEditor({
|
|
|
|
|
details,
|
|
|
|
|
activeDetailId,
|
|
|
|
|
onActiveDetailChange,
|
|
|
|
|
onDetailsChange,
|
|
|
|
|
isDetailLocked,
|
2026-06-28 21:45:33 +03:30
|
|
|
labDependentCodes,
|
2026-07-06 20:40:19 +03:30
|
|
|
treatmentCatalog,
|
2026-06-28 17:14:02 +03:30
|
|
|
disabled,
|
|
|
|
|
canEdit,
|
2026-06-28 18:08:29 +03:30
|
|
|
saveStatus,
|
2026-06-28 17:14:02 +03:30
|
|
|
uploadBusy,
|
|
|
|
|
onAddDetail,
|
2026-07-16 22:45:37 +03:30
|
|
|
onRemoveDetail,
|
2026-06-28 17:14:02 +03:30
|
|
|
onUploadFiles,
|
2026-08-19 23:59:37 +03:30
|
|
|
onRemoveAttachment,
|
2026-07-17 00:39:32 +03:30
|
|
|
showChrome = true,
|
|
|
|
|
showFields = true,
|
feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.
The two halves share a wrapper and nothing else. Add keeps its exact behaviour.
The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.
Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.
Fixes from review of this commit:
- mountedRef was set false on unmount and never re-armed, so under StrictMode
the hook was permanently "unmounted" in dev and recording silently never
started.
- onStart guarded only on `phase`, which does not change until getUserMedia
resolves; a second click during the permission prompt orphaned the first
MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
a bare weekday carries no qualifier, and rejecting it discarded a real
deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
Safari's mp4/aac clips are not mislabelled.
Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:47:10 +03:30
|
|
|
voice,
|
2026-08-19 23:59:37 +03:30
|
|
|
chart,
|
|
|
|
|
stepper,
|
|
|
|
|
footer,
|
|
|
|
|
chartLocked = false,
|
|
|
|
|
chartLockMessage,
|
2026-06-28 17:14:02 +03:30
|
|
|
}: TreatmentDetailsEditorProps) {
|
|
|
|
|
const t = useTranslations('treatment');
|
2026-07-16 22:45:37 +03:30
|
|
|
const tCommon = useTranslations('common');
|
2026-08-19 23:59:37 +03:30
|
|
|
const notesRef = useRef<HTMLTextAreaElement>(null);
|
2026-06-28 17:14:02 +03:30
|
|
|
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? details[0];
|
|
|
|
|
|
2026-08-19 23:59:37 +03:30
|
|
|
const locked = Boolean(activeDetail && isDetailLocked(activeDetail));
|
|
|
|
|
const readOnly = !activeDetail || disabled || locked;
|
|
|
|
|
const showMissingTeethLabBlock = Boolean(
|
|
|
|
|
activeDetail &&
|
|
|
|
|
isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes),
|
2026-07-06 20:40:19 +03:30
|
|
|
);
|
2026-06-28 17:14:02 +03:30
|
|
|
|
2026-08-19 23:59:37 +03:30
|
|
|
if (!showChrome && !showFields && !stepper) return null;
|
|
|
|
|
|
|
|
|
|
const treatmentTypeTextColor =
|
|
|
|
|
activeDetail && isDetailTypeSelected(activeDetail)
|
|
|
|
|
? treatmentTypeColor(
|
|
|
|
|
activeDetail.treatmentType,
|
|
|
|
|
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
|
|
|
|
|
)
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
function setActiveType(nextType: string) {
|
|
|
|
|
onDetailsChange(
|
|
|
|
|
details.map((d) =>
|
|
|
|
|
d.clientId === activeDetailId ? { ...d, treatmentType: nextType } : d,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-07-17 00:39:32 +03:30
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
return (
|
2026-07-11 17:10:02 +03:30
|
|
|
<div className="surface-card p-3 sm:p-4 space-y-4">
|
2026-07-17 00:39:32 +03:30
|
|
|
{showChrome ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold text-text-primary">{t('detailsTitle')}</h3>
|
|
|
|
|
</div>
|
feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.
The two halves share a wrapper and nothing else. Add keeps its exact behaviour.
The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.
Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.
Fixes from review of this commit:
- mountedRef was set false on unmount and never re-armed, so under StrictMode
the hook was permanently "unmounted" in dev and recording silently never
started.
- onStart guarded only on `phase`, which does not change until getUserMedia
resolves; a second click during the permission prompt orphaned the first
MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
a bare weekday carries no qualifier, and rejecting it discarded a real
deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
Safari's mp4/aac clips are not mislabelled.
Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:47:10 +03:30
|
|
|
{voice ? (
|
|
|
|
|
<AddDetailWithVoice
|
|
|
|
|
addLabel={t('addDetail')}
|
|
|
|
|
startLabel={t('voiceStart')}
|
|
|
|
|
stopLabel={t('voiceStop')}
|
|
|
|
|
disabled={!canEdit || disabled}
|
|
|
|
|
onAddDetail={onAddDetail}
|
|
|
|
|
voice={voice}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="primary"
|
|
|
|
|
disabled={!canEdit || disabled}
|
|
|
|
|
onClick={onAddDetail}
|
|
|
|
|
fullWidth
|
|
|
|
|
className="sm:w-auto shrink-0"
|
|
|
|
|
>
|
|
|
|
|
{t('addDetail')}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-07-16 22:45:37 +03:30
|
|
|
</div>
|
2026-06-28 17:14:02 +03:30
|
|
|
|
feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.
The two halves share a wrapper and nothing else. Add keeps its exact behaviour.
The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.
Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.
Fixes from review of this commit:
- mountedRef was set false on unmount and never re-armed, so under StrictMode
the hook was permanently "unmounted" in dev and recording silently never
started.
- onStart guarded only on `phase`, which does not change until getUserMedia
resolves; a second click during the permission prompt orphaned the first
MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
a bare weekday carries no qualifier, and rejecting it discarded a real
deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
Safari's mp4/aac clips are not mislabelled.
Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:47:10 +03:30
|
|
|
{voice ? <VoiceRecordingBar voice={voice} /> : null}
|
|
|
|
|
|
2026-07-17 00:39:32 +03:30
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
{details.map((d, idx) => {
|
|
|
|
|
const detailLocked = isDetailLocked(d);
|
|
|
|
|
const isActive = d.clientId === activeDetailId;
|
2026-08-19 23:59:37 +03:30
|
|
|
const showRemoveAction = Boolean(onRemoveDetail);
|
|
|
|
|
const removeDisabled = !canEdit || disabled || detailLocked || uploadBusy;
|
|
|
|
|
const chipLabel = formatDetailChipLabel(
|
|
|
|
|
d,
|
|
|
|
|
treatmentCatalog,
|
|
|
|
|
t('detailLabel', { n: idx + 1 }),
|
|
|
|
|
);
|
|
|
|
|
const showLabStatus =
|
|
|
|
|
isDetailTypeSelected(d) && labDependentCodes.has(d.treatmentType);
|
2026-07-17 00:39:32 +03:30
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={d.clientId}
|
|
|
|
|
className={`
|
2026-08-19 23:59:37 +03:30
|
|
|
inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border max-w-full
|
2026-07-17 00:39:32 +03:30
|
|
|
${
|
|
|
|
|
isActive
|
|
|
|
|
? 'border-primary bg-primary-soft'
|
|
|
|
|
: 'border-border/70 hover:border-border hover:bg-background-card/50'
|
|
|
|
|
}
|
|
|
|
|
`}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onActiveDetailChange(d.clientId)}
|
2026-08-19 23:59:37 +03:30
|
|
|
title={chipLabel}
|
2026-07-17 00:39:32 +03:30
|
|
|
className={`
|
2026-08-19 23:59:37 +03:30
|
|
|
inline-flex max-w-[20rem] items-center gap-1.5 px-3 py-1.5 text-sm transition-colors
|
2026-07-17 00:39:32 +03:30
|
|
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/45
|
|
|
|
|
${isActive ? 'font-medium text-text-primary' : 'text-text-secondary'}
|
|
|
|
|
`}
|
|
|
|
|
>
|
2026-08-19 23:59:37 +03:30
|
|
|
<span className="truncate">{chipLabel}</span>
|
|
|
|
|
{showLabStatus ? (
|
|
|
|
|
<span
|
|
|
|
|
className={`shrink-0 ${
|
|
|
|
|
detailLocked
|
|
|
|
|
? 'text-emerald-600 dark:text-emerald-400'
|
|
|
|
|
: 'text-amber-600 dark:text-amber-400'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
· {detailLocked ? t('detailSentBadge') : t('detailUnsentBadge')}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
2026-07-17 00:39:32 +03:30
|
|
|
</button>
|
|
|
|
|
{showRemoveAction ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
if (removeDisabled) return;
|
|
|
|
|
onRemoveDetail?.(d.clientId);
|
|
|
|
|
}}
|
|
|
|
|
disabled={removeDisabled}
|
|
|
|
|
title={tCommon('delete')}
|
|
|
|
|
aria-label={t('removeDetailAria', { n: idx + 1 })}
|
|
|
|
|
className={`
|
|
|
|
|
inline-flex items-center justify-center border-s px-1.5 transition-colors
|
|
|
|
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500/40
|
|
|
|
|
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-inherit
|
|
|
|
|
${
|
|
|
|
|
isActive
|
|
|
|
|
? 'border-primary/30 text-text-muted hover:bg-red-500/15 hover:text-red-600'
|
|
|
|
|
: 'border-border/60 text-text-muted hover:bg-red-500/15 hover:text-red-600'
|
|
|
|
|
}
|
|
|
|
|
`}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-08-19 23:59:37 +03:30
|
|
|
{stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null}
|
|
|
|
|
|
2026-08-22 18:51:57 +03:30
|
|
|
{activeDetail && showMissingTeethLabBlock ? (
|
|
|
|
|
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-08-19 23:59:37 +03:30
|
|
|
{showFields && activeDetail ? (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
|
2026-07-17 00:39:32 +03:30
|
|
|
<Dropdown
|
|
|
|
|
label={t('treatmentType')}
|
|
|
|
|
value={activeDetail.treatmentType}
|
2026-08-19 23:59:37 +03:30
|
|
|
onChange={(e) => setActiveType(e.target.value)}
|
2026-07-17 00:39:32 +03:30
|
|
|
disabled={readOnly}
|
|
|
|
|
style={{ color: treatmentTypeTextColor }}
|
|
|
|
|
>
|
|
|
|
|
<option value="">{t('treatmentTypePlaceholder')}</option>
|
|
|
|
|
{treatmentCatalog.map((entry, index) => (
|
2026-08-19 23:59:37 +03:30
|
|
|
<option key={entry.code} value={entry.code} style={treatmentTypeOptionStyle(entry.code, index)}>
|
2026-07-17 00:39:32 +03:30
|
|
|
{entry.label}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</Dropdown>
|
2026-08-19 23:59:37 +03:30
|
|
|
<TreatmentDetailAttachmentsStrip
|
|
|
|
|
attachments={activeDetail.attachmentMetas}
|
2026-07-17 00:39:32 +03:30
|
|
|
disabled={readOnly}
|
2026-08-19 23:59:37 +03:30
|
|
|
uploadBusy={uploadBusy}
|
|
|
|
|
onUploadFiles={onUploadFiles}
|
|
|
|
|
onRemoveAttachment={readOnly ? undefined : onRemoveAttachment}
|
2026-07-17 00:39:32 +03:30
|
|
|
/>
|
|
|
|
|
</div>
|
2026-08-19 23:59:37 +03:30
|
|
|
|
|
|
|
|
{chart ? (
|
|
|
|
|
<div className="relative min-w-0 w-full">
|
|
|
|
|
{chartLocked ? (
|
|
|
|
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-[var(--radius-md)] bg-background-card/70 px-4">
|
|
|
|
|
<p className="text-sm text-text-secondary text-center">{chartLockMessage}</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
<div className={chartLocked ? 'pointer-events-none opacity-40' : undefined}>{chart}</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<NotesField
|
|
|
|
|
textareaRef={notesRef}
|
|
|
|
|
value={activeDetail.comment}
|
|
|
|
|
disabled={readOnly}
|
|
|
|
|
label={t('comments')}
|
|
|
|
|
placeholder={t('commentsPlaceholder')}
|
|
|
|
|
onChange={(v) => {
|
|
|
|
|
onDetailsChange(
|
|
|
|
|
details.map((d) => (d.clientId === activeDetailId ? { ...d, comment: v } : d)),
|
|
|
|
|
);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{canEdit && saveStatus !== 'idle' && (
|
|
|
|
|
<p className={`text-xs ${autosaveStatusClass(saveStatus)}`} role="status" aria-live="polite">
|
|
|
|
|
{saveStatus === 'dirty' && t('unsavedChanges')}
|
|
|
|
|
{saveStatus === 'saving' && t('saveStatusSaving')}
|
|
|
|
|
{saveStatus === 'saved' && t('saveStatusSaved')}
|
|
|
|
|
{saveStatus === 'error' && t('saveStatusError')}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{footer ? <div className="pt-1">{footer}</div> : null}
|
2026-07-17 00:39:32 +03:30
|
|
|
</div>
|
2026-08-22 18:51:57 +03:30
|
|
|
) : showFields && !activeDetail ? (
|
2026-08-22 19:36:37 +03:30
|
|
|
<p className="text-sm text-text-muted">{t('noDetails')}</p>
|
2026-07-17 00:39:32 +03:30
|
|
|
) : null}
|
2026-06-28 17:14:02 +03:30
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-08-19 23:59:37 +03:30
|
|
|
|
|
|
|
|
function NotesField({
|
|
|
|
|
textareaRef,
|
|
|
|
|
value,
|
|
|
|
|
disabled,
|
|
|
|
|
label,
|
|
|
|
|
placeholder,
|
|
|
|
|
onChange,
|
|
|
|
|
}: {
|
|
|
|
|
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
|
|
|
|
value: string;
|
|
|
|
|
disabled: boolean;
|
|
|
|
|
label: string;
|
|
|
|
|
placeholder: string;
|
|
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
}) {
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const el = textareaRef.current;
|
|
|
|
|
if (!el) return;
|
|
|
|
|
el.style.height = '0px';
|
|
|
|
|
el.style.height = `${el.scrollHeight}px`;
|
|
|
|
|
}, [textareaRef, value]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<label className="block text-xs font-medium text-text-secondary min-w-0">
|
|
|
|
|
{label}
|
|
|
|
|
<textarea
|
|
|
|
|
ref={textareaRef}
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(e) => onChange(e.target.value)}
|
|
|
|
|
placeholder={placeholder}
|
|
|
|
|
rows={1}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
className="mt-1.5 w-full min-h-[2.875rem] sm:min-h-9 resize-none overflow-hidden rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-3 py-2.5 sm:py-2 text-base sm:text-sm leading-tight text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
);
|
|
|
|
|
}
|
feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.
The two halves share a wrapper and nothing else. Add keeps its exact behaviour.
The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.
Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.
Fixes from review of this commit:
- mountedRef was set false on unmount and never re-armed, so under StrictMode
the hook was permanently "unmounted" in dev and recording silently never
started.
- onStart guarded only on `phase`, which does not change until getUserMedia
resolves; a second click during the permission prompt orphaned the first
MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
a bare weekday carries no qualifier, and rejecting it discarded a real
deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
Safari's mp4/aac clips are not mislabelled.
Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:47:10 +03:30
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* "Add detail", split into two segments with the microphone at the logical end.
|
|
|
|
|
*
|
|
|
|
|
* Built like the detail chip's trash affordance in this same file — an
|
|
|
|
|
* `inline-flex items-stretch overflow-hidden rounded` wrapper holding two raw `<button>`s
|
|
|
|
|
* divided by `border-s` — rather than two shared `Button`s, which each hardcode their own
|
|
|
|
|
* rounding and would fight a segmented control.
|
|
|
|
|
*
|
|
|
|
|
* `border-s` puts the microphone at the *logical* end: visually right in en/nl, visually
|
|
|
|
|
* left in fa, on the same side as the chip's trash in both directions.
|
|
|
|
|
*
|
|
|
|
|
* The two halves share a wrapper and nothing else. Add keeps its exact existing
|
|
|
|
|
* behaviour; the microphone is an independent action that creates nothing until the
|
|
|
|
|
* clinician confirms.
|
|
|
|
|
*/
|
|
|
|
|
function AddDetailWithVoice({
|
|
|
|
|
addLabel,
|
|
|
|
|
startLabel,
|
|
|
|
|
stopLabel,
|
|
|
|
|
disabled,
|
|
|
|
|
onAddDetail,
|
|
|
|
|
voice,
|
|
|
|
|
}: {
|
|
|
|
|
addLabel: string;
|
|
|
|
|
startLabel: string;
|
|
|
|
|
stopLabel: string;
|
|
|
|
|
disabled: boolean;
|
|
|
|
|
onAddDetail: () => void;
|
|
|
|
|
voice: VoiceCaptureState;
|
|
|
|
|
}) {
|
|
|
|
|
const isRecording = voice.phase === 'recording';
|
|
|
|
|
const isBusy = voice.phase !== 'idle';
|
|
|
|
|
const micLabel = isRecording ? stopLabel : startLabel;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className={`
|
|
|
|
|
inline-flex w-full items-stretch overflow-hidden rounded-[var(--radius-md)]
|
|
|
|
|
bg-primary text-white shrink-0 sm:w-auto
|
|
|
|
|
${disabled ? 'opacity-60' : ''}
|
|
|
|
|
`}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onAddDetail}
|
|
|
|
|
disabled={disabled || isBusy}
|
|
|
|
|
className="
|
|
|
|
|
flex-1 px-4 py-2 text-sm font-medium transition-all duration-200
|
|
|
|
|
hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset
|
|
|
|
|
focus-visible:ring-white/60
|
|
|
|
|
disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:opacity-60
|
|
|
|
|
"
|
|
|
|
|
>
|
|
|
|
|
{addLabel}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={isRecording ? voice.onStop : voice.onStart}
|
|
|
|
|
disabled={disabled || voice.phase === 'processing'}
|
|
|
|
|
title={micLabel}
|
|
|
|
|
aria-label={micLabel}
|
|
|
|
|
className={`
|
|
|
|
|
inline-flex items-center justify-center border-s border-white/25 px-3
|
|
|
|
|
transition-all duration-200 focus:outline-none focus-visible:ring-2
|
|
|
|
|
focus-visible:ring-inset focus-visible:ring-white/60
|
|
|
|
|
disabled:cursor-not-allowed disabled:opacity-60
|
|
|
|
|
${isRecording ? 'bg-red-600 hover:bg-red-700' : 'hover:opacity-90'}
|
|
|
|
|
`}
|
|
|
|
|
>
|
|
|
|
|
{isRecording ? (
|
|
|
|
|
<Square className="h-4 w-4 fill-current" aria-hidden />
|
|
|
|
|
) : (
|
|
|
|
|
<Mic className="h-4 w-4" aria-hidden />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|