Files
dyolink/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx

441 lines
17 KiB
TypeScript
Raw Normal View History

'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { isLabCaseCompleted } from '@/components/treatment/treatmentDetailRules';
import { AppDateInput } from '@/components/ui/shared/AppDateInput';
import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay';
import { LinkedOrganizationSearchCombobox } from '@/components/ui/treatment/LinkedOrganizationSearchCombobox';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
import { DetailLabCaseCommentsSection } from '@/components/ui/treatment/DetailLabCaseCommentsSection';
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 type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import {
isArchSentinel,
toothJobRowsForDetail,
} from '@/components/treatment/prosthesisTree';
import { LabCaseToothJobsList } from '@/components/ui/lab/LabCaseProsthesisGroupsList';
interface LabCasesDispatchPanelProps {
details: TreatmentDetailDraft[];
activeDetailId: string;
labCases: LabCaseDraft[];
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
prosthesisCatalog?: ProsthesisCatalogEntry[];
labCaseSummary?: PatientLabCaseSummary | null;
locale: string;
onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void;
onLabCaseMarkedRead?: (labCaseId: string) => void;
onLabCaseActivityChange?: () => void;
activeLabCaseId: string | null;
onLabCasesChange: (labCases: LabCaseDraft[]) => void;
disabled: boolean;
canEdit: boolean;
orgs: LinkedOrganizationOption[];
organizationSearch: string;
onOrganizationSearchChange: (value: string) => void;
recentOrganizationIds: string[];
canInviteLab?: boolean;
onInviteLab?: () => void;
sendBusyId: string | null;
onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void | Promise<void>;
onCommentError?: (message: string) => void;
}
function prosthesisTeethForDetail(activeDetail: TreatmentDetailDraft): string[] {
if (activeDetail.treatmentType !== 'prosthesis') return [];
if (activeDetail.toothSelectionGroups.length > 0) {
return activeDetail.toothSelectionGroups.flatMap((g) => g.teeth);
}
return activeDetail.teeth;
}
function isProsthesisMapComplete(
labCase: LabCaseDraft,
fdiTeeth: string[],
detailClientId: string,
): boolean {
const assigned = new Set(
labCase.toothProsthesis
.filter((tp) => tp.detailClientId === detailClientId && Boolean(tp.prosthesisTypeCode))
.map((tp) => tp.tooth),
);
const hasArch = [...assigned].some(isArchSentinel);
if (fdiTeeth.length === 0) return hasArch;
return fdiTeeth.every((tooth) => assigned.has(tooth));
}
export function LabCasesDispatchPanel({
details,
activeDetailId,
labCases,
labDependentCodes,
treatmentCatalog,
prosthesisCatalog = [],
labCaseSummary,
locale,
onLabCaseSummaryChange,
onLabCaseMarkedRead,
onLabCaseActivityChange,
activeLabCaseId,
onLabCasesChange,
disabled,
canEdit,
orgs,
organizationSearch,
onOrganizationSearchChange,
recentOrganizationIds,
canInviteLab = false,
onInviteLab,
sendBusyId,
onSendLabCase,
onCommentError,
}: LabCasesDispatchPanelProps) {
const t = useTranslations('treatment');
const tErrors = useTranslations('errors');
const [fetchedCatalog, setFetchedCatalog] = useState<ProsthesisCatalogEntry[]>([]);
const [pendingComment, setPendingComment] = useState('');
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
const activeLinkedOrganizations = orgs.filter((o) => o.active);
const recentOrganizations = recentOrganizationIds
.map((id) => activeLinkedOrganizations.find((o) => o.id === id))
.filter(Boolean) as LinkedOrganizationOption[];
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? null;
const isLabDependentDetail = Boolean(
activeDetail && labDependentCodes.has(activeDetail.treatmentType),
);
const labCaseForActiveDetail =
labCases.find((lc) => lc.detailClientId === activeDetailId) ?? null;
const activeLabCase =
labCaseForActiveDetail ??
(activeLabCaseId
? labCases.find(
(lc) =>
lc.clientId === activeLabCaseId &&
(lc.detailClientId == null || lc.detailClientId === activeDetailId),
)
: null) ??
null;
const sent = Boolean(activeLabCase?.sentAt);
const activeLabOrgName = activeLabCase?.destinationOrganizationId
? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name
: null;
const fdiTeeth = activeDetail ? prosthesisTeethForDetail(activeDetail) : [];
const prosthesisComplete = activeLabCase
? isProsthesisMapComplete(activeLabCase, fdiTeeth, activeDetailId)
: false;
const catalog = prosthesisCatalog.length > 0 ? prosthesisCatalog : fetchedCatalog;
useEffect(() => {
if (prosthesisCatalog.length > 0) return;
let cancelled = false;
void prosthesisCatalogApi
.list()
.then((res) => {
if (!cancelled) setFetchedCatalog(res.data);
})
.catch(() => {
if (!cancelled) setFetchedCatalog([]);
});
return () => {
cancelled = true;
};
}, [prosthesisCatalog.length]);
useEffect(() => {
setPendingComment('');
}, [activeLabCase?.clientId]);
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
if (!activeLabCase) return;
onLabCasesChange(
labCases.map((lc) => (lc.clientId === activeLabCase.clientId ? { ...lc, ...patch } : lc)),
);
}
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]);
if (!activeDetail || !isLabDependentDetail) {
return null;
}
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
if (!activeLabCase || sent) return;
const set = new Set(activeLabCase.attachmentIds);
if (checked) set.add(attachmentId);
else set.delete(attachmentId);
updateActiveLabCase({ attachmentIds: [...set] });
}
function handleSelectOrganization(org: LinkedOrganizationOption) {
updateActiveLabCase({
destinationOrganizationId: org.id,
});
}
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
const canEditDueDate = canEdit && !disabled && (!sent || !caseFullyComplete);
const canPostComments = canEdit && !caseFullyComplete;
const canShowComments = Boolean(activeLabCase?.id);
const commentsDeferSubmit = Boolean(!sent);
async function handleSentDueDateBlur(nextValue: string) {
if (!activeLabCase?.id || !sent || !canEditDueDate) return;
const dueDate = nextValue || null;
if (dueDate === (activeLabCase.dueDate?.slice(0, 10) ?? null)) return;
try {
const response = await treatmentsApi.updateLabCaseDueDate(activeLabCase.id, dueDate);
updateActiveLabCase({
dueDate: response.data.dueDate,
taskProgress: response.data.taskProgress ?? activeLabCase.taskProgress,
});
} catch (error) {
onCommentError?.(getUserFacingError(error, tErrors, t('dueDateUpdateError')));
}
}
function renderDueDateField() {
if (!activeLabCase) return null;
const inputValue = toDateInputValue(activeLabCase.dueDate);
const dueDateInputId = `lab-case-due-date-${activeLabCase.clientId}`;
return (
<div className="w-full min-w-0 sm:w-auto sm:shrink-0 sm:text-end">
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<label
htmlFor={dueDateInputId}
className="text-xs font-medium text-text-secondary shrink-0"
>
{t('dueDateLabel')}{' '}
<span className="font-normal text-text-muted">({t('dueDateOptional')})</span>
</label>
<AppDateInput
id={dueDateInputId}
value={inputValue}
disabled={!canEditDueDate}
onChange={(next) => {
updateActiveLabCase({ dueDate: next || null });
}}
onBlur={(committed) => {
if (sent) void handleSentDueDateBlur(committed);
}}
className={`${FORM_SELECT_CLASS} w-full min-w-0 max-w-full sm:max-w-[11rem] rounded-md py-1.5 text-sm`}
/>
</div>
{sent && caseFullyComplete && activeLabCase.dueDate ? (
<p className="mt-1.5 text-[11px] text-text-muted sm:text-end">{t('dueDateLockedCompleted')}</p>
) : null}
</div>
);
}
const typeLabel = treatmentTypeLabelFromCatalog(activeDetail.treatmentType, treatmentCatalog);
const connectedGroupIds = new Set(
(activeDetail.toothSelectionGroups ?? [])
.filter((group) => group.kind === 'connected')
.map((group) => group.groupId),
);
const toothJobRows = activeLabCase
? toothJobRowsForDetail(activeLabCase.toothProsthesis, activeDetailId, connectedGroupIds)
: [];
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
return (
<div className="surface-card p-3 sm:p-4 space-y-4 min-w-0 overflow-x-hidden">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<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">{typeLabel}</p>
</div>
{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 ? (
<>
{activeLabOrgName ? (
<div>
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
<p className="text-sm text-text-primary mt-1">{activeLabOrgName}</p>
</div>
) : null}
<CaseSentLabel
treatmentCase={{
destinationOrganizationId: activeLabCase.destinationOrganizationId,
sendToOrganizationIds: activeLabCase.destinationOrganizationId
? [activeLabCase.destinationOrganizationId]
: [],
sentAt: activeLabCase.sentAt ?? null,
sends: activeLabCase.sends,
}}
orgs={orgs}
/>
{toothJobRows.length > 0 ? (
<LabCaseToothJobsList
rows={toothJobRows}
prosthesisCatalog={catalog}
/>
) : null}
{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}
</>
) : (
<>
<div className="space-y-2">
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
<LinkedOrganizationSearchCombobox
search={organizationSearch}
onSearchChange={onOrganizationSearchChange}
organizations={activeLinkedOrganizations}
selectedOrganizationId={activeLabCase.destinationOrganizationId}
onSelectOrganization={handleSelectOrganization}
disabled={disabled}
canInviteLab={canInviteLab}
onInviteLab={onInviteLab}
noPermissionMessage={t('noOrgInvitePermission')}
/>
{recentOrganizations.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-text-muted">{t('recent')}</span>
{recentOrganizations.map((o) => (
<button
key={o.id}
type="button"
disabled={disabled}
onClick={() => handleSelectOrganization(o)}
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}
</button>
))}
</div>
)}
</div>
<div className="space-y-2 border-t border-border/60 pt-3">
<p className="text-xs font-medium text-text-secondary">
{t('prosthesisTypesTitle')}
</p>
<p className="text-[11px] text-text-muted">{t('prosthesisEditOnChart')}</p>
{toothJobRows.length === 0 ? (
<p className="text-xs text-amber-700">{t('prosthesisMissingOnChart')}</p>
) : (
<LabCaseToothJobsList
rows={toothJobRows}
prosthesisCatalog={catalog}
/>
)}
</div>
{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"
variant="primary"
className="w-full sm:w-auto"
disabled={
disabled ||
sendBusyId === activeLabCase.clientId ||
!activeLabCase.destinationOrganizationId ||
!activeLabCase.detailClientId ||
!prosthesisComplete
}
isLoading={sendBusyId === activeLabCase.clientId}
onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
>
{t('sendToLab')}
</Button>
</div>
</>
)}
</div>
) : null}
</div>
);
}