313 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|