2026-06-28 17:14:02 +03:30
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useRef } from 'react';
|
|
|
|
|
import { useTranslations } from 'next-intl';
|
|
|
|
|
import { Button } from '@/components/ui/shared/Button';
|
|
|
|
|
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
2026-06-28 21:45:33 +03:30
|
|
|
import {
|
|
|
|
|
autosaveStatusClass,
|
|
|
|
|
labPendingBannerClass,
|
|
|
|
|
labSentBannerClass,
|
2026-07-12 21:08:29 +03:30
|
|
|
} from '@/components/treatment/treatmentStatusStyles';
|
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-07-12 21:08:29 +03:30
|
|
|
import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
|
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;
|
|
|
|
|
onAddDetail: () => void;
|
|
|
|
|
onUploadFiles: (files: FileList | null) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
onUploadFiles,
|
|
|
|
|
}: TreatmentDetailsEditorProps) {
|
|
|
|
|
const t = useTranslations('treatment');
|
|
|
|
|
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? details[0];
|
|
|
|
|
|
|
|
|
|
if (!activeDetail) return null;
|
|
|
|
|
|
|
|
|
|
const locked = isDetailLocked(activeDetail);
|
|
|
|
|
const readOnly = disabled || locked;
|
2026-07-06 20:40:19 +03:30
|
|
|
const treatmentTypeTextColor = treatmentTypeColor(
|
|
|
|
|
activeDetail.treatmentType,
|
|
|
|
|
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
|
|
|
|
|
);
|
2026-06-28 21:45:33 +03:30
|
|
|
const isLabDependent = labDependentCodes.has(activeDetail.treatmentType);
|
|
|
|
|
const showPendingLabHint = isLabDependent && !locked && !readOnly;
|
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">
|
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
2026-06-28 17:14:02 +03:30
|
|
|
<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>
|
2026-07-11 17:10:02 +03:30
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="primary"
|
|
|
|
|
disabled={!canEdit || disabled}
|
|
|
|
|
onClick={onAddDetail}
|
|
|
|
|
fullWidth
|
|
|
|
|
className="sm:w-auto shrink-0"
|
|
|
|
|
>
|
2026-06-28 21:45:33 +03:30
|
|
|
{t('addDetail')}
|
|
|
|
|
</Button>
|
2026-06-28 17:14:02 +03:30
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
{details.map((d, idx) => (
|
|
|
|
|
<button
|
|
|
|
|
key={d.clientId}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onActiveDetailChange(d.clientId)}
|
|
|
|
|
className={`
|
|
|
|
|
rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
|
|
|
|
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
|
|
|
|
|
${
|
|
|
|
|
d.clientId === activeDetailId
|
|
|
|
|
? 'border-primary bg-primary-soft font-medium text-text-primary'
|
|
|
|
|
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
|
|
|
|
|
}
|
|
|
|
|
`}
|
|
|
|
|
>
|
|
|
|
|
{t('detailLabel', { n: idx + 1 })}
|
|
|
|
|
{isDetailLocked(d) ? ` · ${t('detailSentBadge')}` : ''}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
|
|
|
|
|
{locked && (
|
2026-06-28 21:45:33 +03:30
|
|
|
<p className={labSentBannerClass}>{t('detailLockedInShipment')}</p>
|
|
|
|
|
)}
|
|
|
|
|
{showPendingLabHint && (
|
|
|
|
|
<p className={labPendingBannerClass}>{t('detailPendingLabSend')}</p>
|
2026-06-28 17:14:02 +03:30
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<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={5}
|
|
|
|
|
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-[120px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<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,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}}
|
|
|
|
|
disabled={readOnly}
|
|
|
|
|
style={{ color: treatmentTypeTextColor }}
|
|
|
|
|
>
|
2026-07-06 20:40:19 +03:30
|
|
|
{treatmentCatalog.map((entry, index) => (
|
|
|
|
|
<option
|
|
|
|
|
key={entry.code}
|
|
|
|
|
value={entry.code}
|
|
|
|
|
style={{
|
|
|
|
|
color: treatmentTypeColor(entry.code, index),
|
|
|
|
|
backgroundColor: '#14253d',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{entry.label}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
2026-06-28 17:14:02 +03:30
|
|
|
</Dropdown>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-28 18:08:29 +03:30
|
|
|
{canEdit && saveStatus !== 'idle' && (
|
|
|
|
|
<p
|
2026-06-28 21:45:33 +03:30
|
|
|
className={`text-xs pt-2 border-t border-border/60 ${autosaveStatusClass(saveStatus)}`}
|
2026-06-28 18:08:29 +03:30
|
|
|
role="status"
|
|
|
|
|
aria-live="polite"
|
|
|
|
|
>
|
|
|
|
|
{saveStatus === 'dirty' && t('unsavedChanges')}
|
|
|
|
|
{saveStatus === 'saving' && t('saveStatusSaving')}
|
|
|
|
|
{saveStatus === 'saved' && t('saveStatusSaved')}
|
|
|
|
|
{saveStatus === 'error' && t('saveStatusError')}
|
|
|
|
|
</p>
|
2026-06-28 17:14:02 +03:30
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|