improvement: treatments UI/UX updated again to minimize clicking and scrolling.

This commit is contained in:
2026-08-19 23:59:37 +03:30
parent 29c639f5a1
commit d2f07c0ed3
33 changed files with 1651 additions and 744 deletions

View File

@@ -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>
);
}