improvement: treatments UI/UX updated again to minimize clicking and scrolling.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user