improvement: treatments UI/UX updated again to minimize clicking and scrolling.
This commit is contained in:
@@ -1,21 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
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 {
|
||||
autosaveStatusClass,
|
||||
labPendingBannerClass,
|
||||
labSentBannerClass,
|
||||
labBlockedBannerClass,
|
||||
} from '@/components/treatment/treatmentStatusStyles';
|
||||
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 } from '@/components/shared/treatmentTypeDisplay';
|
||||
import { treatmentTypeColor, treatmentTypeOptionStyle } from '@/components/shared/treatmentTypeDisplay';
|
||||
import {
|
||||
isDetailReadyForLabDispatch,
|
||||
isDetailTypeSelected,
|
||||
isLabDependentDetailMissingTeeth,
|
||||
} from '@/components/treatment/treatmentDetailRules';
|
||||
@@ -34,11 +30,21 @@ interface TreatmentDetailsEditorProps {
|
||||
uploadBusy: boolean;
|
||||
onAddDetail: () => void;
|
||||
onRemoveDetail?: (detailClientId: string) => void;
|
||||
onUploadFiles: (files: FileList | null) => 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({
|
||||
@@ -56,32 +62,44 @@ export function TreatmentDetailsEditor({
|
||||
onAddDetail,
|
||||
onRemoveDetail,
|
||||
onUploadFiles,
|
||||
onRemoveAttachment,
|
||||
showChrome = true,
|
||||
showFields = true,
|
||||
chart,
|
||||
stepper,
|
||||
footer,
|
||||
chartLocked = false,
|
||||
chartLockMessage,
|
||||
}: TreatmentDetailsEditorProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const tCommon = useTranslations('common');
|
||||
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
||||
const notesRef = useRef<HTMLTextAreaElement>(null);
|
||||
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? details[0];
|
||||
|
||||
if (!activeDetail) return null;
|
||||
|
||||
const locked = isDetailLocked(activeDetail);
|
||||
const readOnly = disabled || locked;
|
||||
const treatmentTypeTextColor = isDetailTypeSelected(activeDetail)
|
||||
? treatmentTypeColor(
|
||||
activeDetail.treatmentType,
|
||||
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
|
||||
)
|
||||
: undefined;
|
||||
const showPendingLabHint =
|
||||
isDetailReadyForLabDispatch(activeDetail, labDependentCodes) && !locked && !readOnly;
|
||||
const showMissingTeethLabBlock = isLabDependentDetailMissingTeeth(
|
||||
activeDetail,
|
||||
labDependentCodes,
|
||||
const locked = Boolean(activeDetail && isDetailLocked(activeDetail));
|
||||
const readOnly = !activeDetail || disabled || locked;
|
||||
const showMissingTeethLabBlock = Boolean(
|
||||
activeDetail &&
|
||||
isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes),
|
||||
);
|
||||
|
||||
if (!showChrome && !showFields) return null;
|
||||
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">
|
||||
@@ -90,7 +108,6 @@ export function TreatmentDetailsEditor({
|
||||
<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>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t('detailsSubtitle')}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -108,16 +125,20 @@ export function TreatmentDetailsEditor({
|
||||
{details.map((d, idx) => {
|
||||
const detailLocked = isDetailLocked(d);
|
||||
const isActive = d.clientId === activeDetailId;
|
||||
// Same rules as the former Content-step delete button:
|
||||
// only when more than one detail remains; disabled if no edit, day-locked, sent, or uploading.
|
||||
const showRemoveAction = details.length > 1;
|
||||
const removeDisabled =
|
||||
!canEdit || disabled || detailLocked || uploadBusy;
|
||||
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
|
||||
inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border max-w-full
|
||||
${
|
||||
isActive
|
||||
? 'border-primary bg-primary-soft'
|
||||
@@ -128,14 +149,25 @@ export function TreatmentDetailsEditor({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActiveDetailChange(d.clientId)}
|
||||
title={chipLabel}
|
||||
className={`
|
||||
px-3 py-1.5 text-sm transition-colors
|
||||
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'}
|
||||
`}
|
||||
>
|
||||
{t('detailLabel', { n: idx + 1 })}
|
||||
{detailLocked ? ` · ${t('detailSentBadge')}` : ''}
|
||||
<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
|
||||
@@ -169,114 +201,112 @@ export function TreatmentDetailsEditor({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{showFields ? (
|
||||
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
|
||||
{locked && <p className={labSentBannerClass}>{t('detailLockedInShipment')}</p>}
|
||||
{showPendingLabHint && (
|
||||
<p className={labPendingBannerClass}>{t('detailPendingLabSend')}</p>
|
||||
)}
|
||||
{stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null}
|
||||
|
||||
{showFields && activeDetail ? (
|
||||
<div className="space-y-3">
|
||||
{showMissingTeethLabBlock && (
|
||||
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
|
||||
<Dropdown
|
||||
label={t('treatmentType')}
|
||||
value={activeDetail.treatmentType}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value as TreatmentDetailDraft['treatmentType'];
|
||||
onDetailsChange(
|
||||
details.map((d) =>
|
||||
d.clientId === activeDetailId ? { ...d, treatmentType: nextType } : d,
|
||||
),
|
||||
);
|
||||
}}
|
||||
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={{
|
||||
color: treatmentTypeColor(entry.code, index),
|
||||
backgroundColor: '#14253d',
|
||||
}}
|
||||
>
|
||||
<option key={entry.code} value={entry.code} style={treatmentTypeOptionStyle(entry.code, index)}>
|
||||
{entry.label}
|
||||
</option>
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<label className="block text-xs font-medium text-text-secondary">
|
||||
{t('comments')}
|
||||
<textarea
|
||||
value={activeDetail.comment}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
onDetailsChange(
|
||||
details.map((d) => (d.clientId === activeDetailId ? { ...d, comment: v } : d)),
|
||||
);
|
||||
}}
|
||||
placeholder={t('commentsPlaceholder')}
|
||||
rows={2}
|
||||
<TreatmentDetailAttachmentsStrip
|
||||
attachments={activeDetail.attachmentMetas}
|
||||
disabled={readOnly}
|
||||
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[60px]"
|
||||
uploadBusy={uploadBusy}
|
||||
onUploadFiles={onUploadFiles}
|
||||
onRemoveAttachment={readOnly ? undefined : onRemoveAttachment}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">{t('attachments')}</p>
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
id="treatment-detail-attachments"
|
||||
type="file"
|
||||
multiple
|
||||
disabled={readOnly || uploadBusy}
|
||||
onChange={(e) => {
|
||||
onUploadFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
className="sr-only"
|
||||
aria-label={t('attachFiles')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={readOnly || uploadBusy}
|
||||
isLoading={uploadBusy}
|
||||
onClick={() => attachmentInputRef.current?.click()}
|
||||
aria-controls="treatment-detail-attachments"
|
||||
>
|
||||
{t('chooseFiles')}
|
||||
</Button>
|
||||
{activeDetail.attachmentMetas.length > 0 && (
|
||||
<ul className="mt-2 space-y-1 text-xs text-text-muted">
|
||||
{activeDetail.attachmentMetas.map((f) => (
|
||||
<li key={f.id} className="truncate">
|
||||
{f.fileName} ({(f.sizeBytes / 1024).toFixed(1)} KB)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</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}
|
||||
|
||||
{showFields && canEdit && saveStatus !== 'idle' && (
|
||||
<p
|
||||
className={`text-xs pt-2 border-t border-border/60 ${autosaveStatusClass(saveStatus)}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{saveStatus === 'dirty' && t('unsavedChanges')}
|
||||
{saveStatus === 'saving' && t('saveStatusSaving')}
|
||||
{saveStatus === 'saved' && t('saveStatusSaved')}
|
||||
{saveStatus === 'error' && t('saveStatusError')}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user