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

313 lines
12 KiB
TypeScript

'use client';
import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
import { useTranslations } from 'next-intl';
import { Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import { Dropdown } from '@/components/ui/shared/Dropdown';
import { formatDetailChipLabel } from '@/components/treatment/detailChipLabel';
import { autosaveStatusClass, labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles';
import { TreatmentDetailAttachmentsStrip } from '@/components/ui/treatment/TreatmentDetailAttachmentsStrip';
import type { TreatmentDetailDraft } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import { treatmentTypeColor, treatmentTypeOptionStyle } from '@/components/shared/treatmentTypeDisplay';
import {
isDetailTypeSelected,
isLabDependentDetailMissingTeeth,
} from '@/components/treatment/treatmentDetailRules';
interface TreatmentDetailsEditorProps {
details: TreatmentDetailDraft[];
activeDetailId: string;
onActiveDetailChange: (id: string) => void;
onDetailsChange: (details: TreatmentDetailDraft[]) => void;
isDetailLocked: (detail: TreatmentDetailDraft) => boolean;
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
disabled: boolean;
canEdit: boolean;
saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
uploadBusy: boolean;
onAddDetail: () => void;
onRemoveDetail?: (detailClientId: string) => void;
onUploadFiles: (files: File[], onProgress: (percent: number) => void) => Promise<void>;
onRemoveAttachment?: (attachmentId: string) => void;
/** Detail chips + Add detail (default true). */
showChrome?: boolean;
/** Type / notes / attachments fields (default true). */
showFields?: boolean;
/** 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;
/** Dim the chart until a treatment type is chosen. */
chartLocked?: boolean;
chartLockMessage?: string;
}
export function TreatmentDetailsEditor({
details,
activeDetailId,
onActiveDetailChange,
onDetailsChange,
isDetailLocked,
labDependentCodes,
treatmentCatalog,
disabled,
canEdit,
saveStatus,
uploadBusy,
onAddDetail,
onRemoveDetail,
onUploadFiles,
onRemoveAttachment,
showChrome = true,
showFields = true,
chart,
stepper,
footer,
chartLocked = false,
chartLockMessage,
}: TreatmentDetailsEditorProps) {
const t = useTranslations('treatment');
const tCommon = useTranslations('common');
const notesRef = useRef<HTMLTextAreaElement>(null);
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? details[0];
const locked = Boolean(activeDetail && isDetailLocked(activeDetail));
const readOnly = !activeDetail || disabled || locked;
const showMissingTeethLabBlock = Boolean(
activeDetail &&
isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes),
);
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,
),
);
}
return (
<div className="surface-card p-3 sm:p-4 space-y-4">
{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>
<Button
type="button"
variant="primary"
disabled={!canEdit || disabled}
onClick={onAddDetail}
fullWidth
className="sm:w-auto shrink-0"
>
{t('addDetail')}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{details.map((d, idx) => {
const detailLocked = isDetailLocked(d);
const isActive = d.clientId === activeDetailId;
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);
return (
<div
key={d.clientId}
className={`
inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border max-w-full
${
isActive
? 'border-primary bg-primary-soft'
: 'border-border/70 hover:border-border hover:bg-background-card/50'
}
`}
>
<button
type="button"
onClick={() => onActiveDetailChange(d.clientId)}
title={chipLabel}
className={`
inline-flex max-w-[20rem] items-center gap-1.5 px-3 py-1.5 text-sm transition-colors
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'}
`}
>
<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}
</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}
{stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null}
{showFields && activeDetail ? (
<div className="space-y-3">
{showMissingTeethLabBlock && (
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
<Dropdown
label={t('treatmentType')}
value={activeDetail.treatmentType}
onChange={(e) => setActiveType(e.target.value)}
disabled={readOnly}
style={{ color: treatmentTypeTextColor }}
>
<option value="">{t('treatmentTypePlaceholder')}</option>
{treatmentCatalog.map((entry, index) => (
<option key={entry.code} value={entry.code} style={treatmentTypeOptionStyle(entry.code, index)}>
{entry.label}
</option>
))}
</Dropdown>
<TreatmentDetailAttachmentsStrip
attachments={activeDetail.attachmentMetas}
disabled={readOnly}
uploadBusy={uploadBusy}
onUploadFiles={onUploadFiles}
onRemoveAttachment={readOnly ? undefined : onRemoveAttachment}
/>
</div>
{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}
</div>
) : null}
</div>
);
}
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>
);
}