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,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
@@ -16,7 +16,12 @@ import { LabCaseTrackerCard } from '@/components/ui/treatment/LabCaseTrackerCard
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import { treatmentsApi } from '@/lib/api/treatments';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
import {
lastProsthesisTypeForLab,
loadLabDispatchDefaults,
rememberLastLab,
rememberLastProsthesisType,
} from '@/components/treatment/labDispatchDefaults';
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
@@ -29,6 +34,7 @@ interface LabCasesDispatchPanelProps {
labCases: LabCaseDraft[];
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
clinicOrganizationId?: string | null;
labCaseSummary?: PatientLabCaseSummary | null;
locale: string;
onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void;
@@ -98,12 +104,27 @@ function isProsthesisMapComplete(
);
}
function toothProsthesisForRows(
rows: ProsthesisGroupRow[],
prosthesisTypeCode: string,
): LabCaseDraft['toothProsthesis'] {
return rows.flatMap((row) =>
row.teeth.map((tooth) => ({
detailClientId: row.detailClientId,
tooth,
prosthesisTypeCode,
selectionGroupId: row.groupId,
})),
);
}
export function LabCasesDispatchPanel({
details,
activeDetailId,
labCases,
labDependentCodes,
treatmentCatalog,
clinicOrganizationId,
labCaseSummary,
locale,
onLabCaseSummaryChange,
@@ -129,6 +150,7 @@ export function LabCasesDispatchPanel({
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const [pendingComment, setPendingComment] = useState('');
const autoFilledCaseRef = useRef<string | null>(null);
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
const activeLinkedOrganizations = orgs.filter((o) => o.active);
@@ -148,8 +170,6 @@ export function LabCasesDispatchPanel({
labCaseForActiveDetail ??
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
const detailAlreadyInShipment = Boolean(labCaseForActiveDetail);
const sent = Boolean(activeLabCase?.sentAt);
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
@@ -190,16 +210,6 @@ export function LabCasesDispatchPanel({
setPendingComment('');
}, [activeLabCase?.clientId]);
if (!activeDetail || !isLabDependentDetail) {
return null;
}
function detailSummary(d: TreatmentDetailDraft) {
const typeLabel = treatmentTypeLabelFromCatalog(d.treatmentType, treatmentCatalog);
const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
return `${t('detailLabel', { n: activeDetailNumber })} · ${typeLabel} · ${teeth}`;
}
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
if (!activeLabCase) return;
onLabCasesChange(
@@ -207,6 +217,58 @@ export function LabCasesDispatchPanel({
);
}
useEffect(() => {
if (!activeLabCase || sent || !activeDetail) return;
const ids = activeDetail.attachmentMetas.map((a) => a.id);
const missing = ids.filter((id) => !activeLabCase.attachmentIds.includes(id));
if (missing.length === 0) return;
updateActiveLabCase({ attachmentIds: [...activeLabCase.attachmentIds, ...missing] });
// eslint-disable-next-line react-hooks/exhaustive-deps -- only sync newly uploaded files
}, [activeDetail?.attachmentMetas, activeLabCase?.clientId, sent]);
useEffect(() => {
if (!activeLabCase || sent || activeLabCase.destinationOrganizationId) return;
const lastLabId = loadLabDispatchDefaults(clinicOrganizationId).lastLabId;
const lastLab = lastLabId
? activeLinkedOrganizations.find((o) => o.id === lastLabId)
: undefined;
if (!lastLab) return;
updateActiveLabCase({ destinationOrganizationId: lastLab.id, toothProsthesis: [] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeLabCase?.clientId, clinicOrganizationId, sent]);
useEffect(() => {
if (!activeLabCase || sent) return;
if (!activeLabCase.destinationOrganizationId) return;
if (prosthesisOptions.length === 0 || prosthesisRows.length === 0) return;
const fillKey = `${activeLabCase.clientId}:${prosthesisRows.length}`;
if (autoFilledCaseRef.current === fillKey) return;
if (isProsthesisMapComplete(activeLabCase, prosthesisRows)) {
autoFilledCaseRef.current = fillKey;
return;
}
const lastCode = lastProsthesisTypeForLab(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
);
if (!lastCode || !prosthesisOptions.some((opt) => opt.code === lastCode)) return;
autoFilledCaseRef.current = fillKey;
setApplyAllProsthesis(lastCode);
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, lastCode) });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeLabCase?.clientId,
activeLabCase?.destinationOrganizationId,
clinicOrganizationId,
prosthesisOptions,
prosthesisRows.length,
sent,
]);
if (!activeDetail || !isLabDependentDetail) {
return null;
}
function setGroupProsthesis(row: ProsthesisGroupRow, prosthesisTypeCode: string) {
if (!activeLabCase) return;
const toothSet = new Set(row.teeth);
@@ -225,19 +287,25 @@ export function LabCasesDispatchPanel({
]
: rest;
updateActiveLabCase({ toothProsthesis: next });
if (prosthesisTypeCode && activeLabCase.destinationOrganizationId) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
prosthesisTypeCode,
);
}
}
function applyProsthesisToAll(code: string) {
if (!activeLabCase || !code) return;
const next = prosthesisRows.flatMap((row) =>
row.teeth.map((tooth) => ({
detailClientId: row.detailClientId,
tooth,
prosthesisTypeCode: code,
selectionGroupId: row.groupId,
})),
);
updateActiveLabCase({ toothProsthesis: next });
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, code) });
if (activeLabCase.destinationOrganizationId) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
code,
);
}
}
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
@@ -249,17 +317,17 @@ export function LabCasesDispatchPanel({
}
function handleSelectOrganization(org: LinkedOrganizationOption) {
autoFilledCaseRef.current = null;
updateActiveLabCase({
destinationOrganizationId: org.id,
toothProsthesis: [],
});
setApplyAllProsthesis('');
rememberLastLab(clinicOrganizationId, org.id);
}
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
const canEditDueDate = canEdit && !disabled && (!sent || !caseFullyComplete);
// Comments/progress belong to the shipment context, even when the treatment is opened from history.
// Do not block commenting just because the treatment editor is read-only.
const canPostComments = canEdit && !caseFullyComplete;
const canShowComments = Boolean(activeLabCase?.id);
const commentsDeferSubmit = Boolean(!sent);
@@ -314,67 +382,8 @@ export function LabCasesDispatchPanel({
);
}
function renderIncludedDetailSummary() {
if (!activeDetail) return null;
if (activeDetail.treatmentType !== 'prosthesis' || !activeLabCase) {
return (
<p className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2">
{detailSummary(activeDetail)}
</p>
);
}
const selectionGroups =
activeDetail.toothSelectionGroups.length > 0
? activeDetail.toothSelectionGroups
: groupsFromFlatTeeth(activeDetail.teeth);
const rows = selectionGroups.map((group) => {
const codes = new Set(
activeLabCase.toothProsthesis
.filter(
(tp) =>
tp.detailClientId === activeDetail.clientId &&
group.teeth.includes(tp.tooth as never) &&
tp.prosthesisTypeCode,
)
.map((tp) => tp.prosthesisTypeCode),
);
const code = codes.size === 1 ? [...codes][0] : '';
return {
groupId: group.groupId,
kind: group.kind,
teeth: group.teeth,
code,
label: code
? prosthesisOptions.find((p) => p.code === code)?.label ?? code
: t('prosthesisUnassigned'),
};
});
return (
<div className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2 space-y-1">
{rows.map((g) => (
<p
key={g.groupId}
className="text-[13px]"
style={{
color: g.code
? prosthesisTypeColorFromCatalog(g.code, prosthesisOptions)
: undefined,
}}
>
{g.kind === 'connected' ? (
<ConnectedSelectionBadge className="me-1 align-middle" />
) : null}
{g.label}: <span className="text-text-primary">{g.teeth.join(', ')}</span>
</p>
))}
</div>
);
}
const typeLabel = treatmentTypeLabelFromCatalog(activeDetail.treatmentType, treatmentCatalog);
const teethLabel = activeDetail.teeth.length ? [...activeDetail.teeth].sort().join(', ') : t('teethNone');
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
return (
@@ -383,36 +392,16 @@ export function LabCasesDispatchPanel({
<div className="min-w-0">
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
<p className="text-xs text-text-muted mt-0.5">
{t('labDispatchSubtitle')} {t('labDispatchSendHint')}
{typeLabel} · {teethLabel}
</p>
</div>
{detailAlreadyInShipment ? renderDueDateField() : null}
{activeLabCase ? renderDueDateField() : null}
</div>
{activeLabCase ? (
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-3 sm:p-4 bg-background-secondary/30 min-w-0">
{sent ? (
<>
{renderIncludedDetailSummary()}
{hasTrackerSummary && labCaseSummary ? (
<LabCaseTrackerCard
summary={labCaseSummary}
locale={locale}
onSummaryChange={onLabCaseSummaryChange}
onMarkedRead={onLabCaseMarkedRead}
/>
) : null}
{canShowComments && activeLabCase?.id ? (
<DetailLabCaseCommentsSection
labCaseId={activeLabCase.id}
canPost={canPostComments}
onError={onCommentError}
onMarkRead={onLabCaseMarkedRead}
onActivityChange={onLabCaseActivityChange}
/>
) : null}
{activeLabOrgName ? (
<div>
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
@@ -431,30 +420,6 @@ export function LabCasesDispatchPanel({
}}
orgs={orgs}
/>
</>
) : (
<>
{renderIncludedDetailSummary()}
{!sent && activeDetailAttachments.length > 0 ? (
<div>
<p className="text-xs font-medium text-text-secondary mb-2">
{t('labShipmentAttachments')}
</p>
<p className="text-[11px] text-text-muted mb-2">{t('labShipmentAttachmentsHint')}</p>
<div className="flex flex-col gap-2">
{activeDetailAttachments.map((att) => (
<Checkbox
key={att.id}
checked={activeLabCase.attachmentIds.includes(att.id)}
disabled={disabled}
onChange={(next) => toggleAttachmentInActiveLabCase(att.id, next)}
label={`${att.fileName} (${(att.sizeBytes / 1024).toFixed(1)} KB)`}
/>
))}
</div>
</div>
) : null}
{hasTrackerSummary && labCaseSummary ? (
<LabCaseTrackerCard
@@ -469,15 +434,14 @@ export function LabCasesDispatchPanel({
<DetailLabCaseCommentsSection
labCaseId={activeLabCase.id}
canPost={canPostComments}
deferSubmit={commentsDeferSubmit}
composerValue={pendingComment}
onComposerValueChange={setPendingComment}
onError={onCommentError}
onMarkRead={onLabCaseMarkedRead}
onActivityChange={onLabCaseActivityChange}
/>
) : null}
</>
) : (
<>
<div className="space-y-2">
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
<LinkedOrganizationSearchCombobox
@@ -499,7 +463,10 @@ export function LabCasesDispatchPanel({
key={o.id}
type="button"
disabled={disabled}
onClick={() => onRecentOrganizationPick(o.id)}
onClick={() => {
handleSelectOrganization(o);
onRecentOrganizationPick(o.id);
}}
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
>
{o.name}
@@ -514,7 +481,7 @@ export function LabCasesDispatchPanel({
<p className="text-xs font-medium text-text-secondary">
{t('prosthesisTypesTitle')}
</p>
{flatToothCount > 1 && prosthesisRows.every((r) => r.kind === 'single') ? (
{flatToothCount > 1 ? (
<label className="block text-xs text-text-muted space-y-1">
{t('prosthesisApplyAll')}
<select
@@ -593,6 +560,48 @@ export function LabCasesDispatchPanel({
</div>
) : null}
{activeDetailAttachments.length > 0 ? (
<div>
<p className="text-xs font-medium text-text-secondary mb-1">
{t('labShipmentAttachments')}
</p>
<p className="text-[11px] text-text-muted mb-2">{t('labShipmentAttachmentsHint')}</p>
<div className="flex flex-col gap-2">
{activeDetailAttachments.map((att) => (
<Checkbox
key={att.id}
checked={activeLabCase.attachmentIds.includes(att.id)}
disabled={disabled}
onChange={(next) => toggleAttachmentInActiveLabCase(att.id, next)}
label={`${att.fileName} (${(att.sizeBytes / 1024).toFixed(1)} KB)`}
/>
))}
</div>
</div>
) : null}
{hasTrackerSummary && labCaseSummary ? (
<LabCaseTrackerCard
summary={labCaseSummary}
locale={locale}
onSummaryChange={onLabCaseSummaryChange}
onMarkedRead={onLabCaseMarkedRead}
/>
) : null}
{canShowComments && activeLabCase?.id ? (
<DetailLabCaseCommentsSection
labCaseId={activeLabCase.id}
canPost={canPostComments}
deferSubmit={commentsDeferSubmit}
composerValue={pendingComment}
onComposerValueChange={setPendingComment}
onError={onCommentError}
onMarkRead={onLabCaseMarkedRead}
onActivityChange={onLabCaseActivityChange}
/>
) : null}
<div className="flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
@@ -606,7 +615,26 @@ export function LabCasesDispatchPanel({
!prosthesisComplete
}
isLoading={sendBusyId === activeLabCase.clientId}
onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
onClick={() => {
if (activeLabCase.destinationOrganizationId) {
rememberLastLab(clinicOrganizationId, activeLabCase.destinationOrganizationId);
const codes = [
...new Set(
activeLabCase.toothProsthesis
.map((tp) => tp.prosthesisTypeCode)
.filter(Boolean),
),
];
if (codes.length === 1) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
codes[0],
);
}
}
return onSendLabCase(activeLabCase, pendingComment.trim());
}}
>
{t('sendToLab')}
</Button>