improvement: new prosthesis type data structure implemented and finally working!
This commit is contained in:
@@ -4,11 +4,9 @@ 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 { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge';
|
||||
import { isDetailReadyForLabDispatch, isLabCaseCompleted } from '@/components/treatment/treatmentDetailRules';
|
||||
import { isLabCaseCompleted } from '@/components/treatment/treatmentDetailRules';
|
||||
import { AppDateInput } from '@/components/ui/shared/AppDateInput';
|
||||
import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay';
|
||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||
import { LinkedOrganizationSearchCombobox } from '@/components/ui/treatment/LinkedOrganizationSearchCombobox';
|
||||
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
||||
import { DetailLabCaseCommentsSection } from '@/components/ui/treatment/DetailLabCaseCommentsSection';
|
||||
@@ -20,7 +18,12 @@ import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/trea
|
||||
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
|
||||
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
|
||||
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[];
|
||||
@@ -28,6 +31,7 @@ interface LabCasesDispatchPanelProps {
|
||||
labCases: LabCaseDraft[];
|
||||
labDependentCodes: Set<string>;
|
||||
treatmentCatalog: TreatmentCatalogEntry[];
|
||||
prosthesisCatalog?: ProsthesisCatalogEntry[];
|
||||
labCaseSummary?: PatientLabCaseSummary | null;
|
||||
locale: string;
|
||||
onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void;
|
||||
@@ -48,66 +52,27 @@ interface LabCasesDispatchPanelProps {
|
||||
onCommentError?: (message: string) => void;
|
||||
}
|
||||
|
||||
type ProsthesisGroupRow = {
|
||||
groupId: string;
|
||||
kind: 'connected' | 'single';
|
||||
teeth: string[];
|
||||
detailClientId: string;
|
||||
detailNumber: number;
|
||||
};
|
||||
|
||||
function prosthesisGroupRows(
|
||||
labCase: LabCaseDraft,
|
||||
activeDetail: TreatmentDetailDraft,
|
||||
detailNumber: number,
|
||||
): ProsthesisGroupRow[] {
|
||||
if (labCase.detailClientId !== activeDetail.clientId) return [];
|
||||
function prosthesisTeethForDetail(activeDetail: TreatmentDetailDraft): string[] {
|
||||
if (activeDetail.treatmentType !== 'prosthesis') return [];
|
||||
|
||||
const groups =
|
||||
activeDetail.toothSelectionGroups.length > 0
|
||||
? activeDetail.toothSelectionGroups
|
||||
: groupsFromFlatTeeth(activeDetail.teeth);
|
||||
|
||||
return groups.map((g) => ({
|
||||
groupId: g.groupId,
|
||||
kind: g.kind,
|
||||
teeth: g.teeth,
|
||||
detailClientId: activeDetail.clientId,
|
||||
detailNumber,
|
||||
}));
|
||||
if (activeDetail.toothSelectionGroups.length > 0) {
|
||||
return activeDetail.toothSelectionGroups.flatMap((g) => g.teeth);
|
||||
}
|
||||
return activeDetail.teeth;
|
||||
}
|
||||
|
||||
function isProsthesisMapComplete(
|
||||
labCase: LabCaseDraft,
|
||||
rows: ProsthesisGroupRow[],
|
||||
fdiTeeth: string[],
|
||||
detailClientId: string,
|
||||
): boolean {
|
||||
if (rows.length === 0) return true;
|
||||
return rows.every((row) =>
|
||||
row.teeth.every((tooth) =>
|
||||
labCase.toothProsthesis.some(
|
||||
(tp) =>
|
||||
tp.detailClientId === row.detailClientId &&
|
||||
tp.tooth === tooth &&
|
||||
tp.selectionGroupId === row.groupId &&
|
||||
Boolean(tp.prosthesisTypeCode),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function toothProsthesisForRows(
|
||||
rows: ProsthesisGroupRow[],
|
||||
prosthesisTypeCode: string,
|
||||
): LabCaseDraft['toothProsthesis'] {
|
||||
return rows.flatMap((row) =>
|
||||
row.teeth.map((tooth) => ({
|
||||
detailClientId: row.detailClientId,
|
||||
tooth,
|
||||
prosthesisTypeCode,
|
||||
selectionGroupId: row.groupId,
|
||||
})),
|
||||
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({
|
||||
@@ -116,6 +81,7 @@ export function LabCasesDispatchPanel({
|
||||
labCases,
|
||||
labDependentCodes,
|
||||
treatmentCatalog,
|
||||
prosthesisCatalog = [],
|
||||
labCaseSummary,
|
||||
locale,
|
||||
onLabCaseSummaryChange,
|
||||
@@ -137,8 +103,7 @@ export function LabCasesDispatchPanel({
|
||||
}: LabCasesDispatchPanelProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const tErrors = useTranslations('errors');
|
||||
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
|
||||
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
|
||||
const [fetchedCatalog, setFetchedCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
||||
const [pendingComment, setPendingComment] = useState('');
|
||||
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
|
||||
|
||||
@@ -149,7 +114,7 @@ export function LabCasesDispatchPanel({
|
||||
|
||||
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? null;
|
||||
const isLabDependentDetail = Boolean(
|
||||
activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes),
|
||||
activeDetail && labDependentCodes.has(activeDetail.treatmentType),
|
||||
);
|
||||
|
||||
const labCaseForActiveDetail =
|
||||
@@ -167,44 +132,35 @@ export function LabCasesDispatchPanel({
|
||||
null;
|
||||
|
||||
const sent = Boolean(activeLabCase?.sentAt);
|
||||
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
|
||||
|
||||
const activeLabOrgName = activeLabCase?.destinationOrganizationId
|
||||
? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name
|
||||
: null;
|
||||
|
||||
const prosthesisRows = activeLabCase && activeDetail
|
||||
? prosthesisGroupRows(activeLabCase, activeDetail, activeDetailNumber)
|
||||
: [];
|
||||
const fdiTeeth = activeDetail ? prosthesisTeethForDetail(activeDetail) : [];
|
||||
const prosthesisComplete = activeLabCase
|
||||
? isProsthesisMapComplete(activeLabCase, prosthesisRows)
|
||||
: true;
|
||||
const flatToothCount = prosthesisRows.reduce((sum, row) => sum + row.teeth.length, 0);
|
||||
? isProsthesisMapComplete(activeLabCase, fdiTeeth, activeDetailId)
|
||||
: false;
|
||||
const catalog = prosthesisCatalog.length > 0 ? prosthesisCatalog : fetchedCatalog;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLabCase?.destinationOrganizationId) {
|
||||
setProsthesisOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (prosthesisCatalog.length > 0) return;
|
||||
let cancelled = false;
|
||||
void prosthesisCatalogApi
|
||||
.list(activeLabCase.destinationOrganizationId)
|
||||
.list()
|
||||
.then((res) => {
|
||||
if (!cancelled) setProsthesisOptions(res.data);
|
||||
if (!cancelled) setFetchedCatalog(res.data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setProsthesisOptions([]);
|
||||
if (!cancelled) setFetchedCatalog([]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeLabCase?.destinationOrganizationId]);
|
||||
}, [prosthesisCatalog.length]);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingComment('');
|
||||
setApplyAllProsthesis('');
|
||||
}, [activeLabCase?.clientId]);
|
||||
|
||||
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
|
||||
@@ -227,31 +183,6 @@ export function LabCasesDispatchPanel({
|
||||
return null;
|
||||
}
|
||||
|
||||
function setGroupProsthesis(row: ProsthesisGroupRow, prosthesisTypeCode: string) {
|
||||
if (!activeLabCase) return;
|
||||
const toothSet = new Set(row.teeth);
|
||||
const rest = activeLabCase.toothProsthesis.filter(
|
||||
(tp) => !(tp.detailClientId === row.detailClientId && toothSet.has(tp.tooth)),
|
||||
);
|
||||
const next = prosthesisTypeCode
|
||||
? [
|
||||
...rest,
|
||||
...row.teeth.map((tooth) => ({
|
||||
detailClientId: row.detailClientId,
|
||||
tooth,
|
||||
prosthesisTypeCode,
|
||||
selectionGroupId: row.groupId,
|
||||
})),
|
||||
]
|
||||
: rest;
|
||||
updateActiveLabCase({ toothProsthesis: next });
|
||||
}
|
||||
|
||||
function applyProsthesisToAll(code: string) {
|
||||
if (!activeLabCase || !code) return;
|
||||
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, code) });
|
||||
}
|
||||
|
||||
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
|
||||
if (!activeLabCase || sent) return;
|
||||
const set = new Set(activeLabCase.attachmentIds);
|
||||
@@ -263,9 +194,7 @@ export function LabCasesDispatchPanel({
|
||||
function handleSelectOrganization(org: LinkedOrganizationOption) {
|
||||
updateActiveLabCase({
|
||||
destinationOrganizationId: org.id,
|
||||
toothProsthesis: [],
|
||||
});
|
||||
setApplyAllProsthesis('');
|
||||
}
|
||||
|
||||
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
|
||||
@@ -325,7 +254,14 @@ export function LabCasesDispatchPanel({
|
||||
}
|
||||
|
||||
const typeLabel = treatmentTypeLabelFromCatalog(activeDetail.treatmentType, treatmentCatalog);
|
||||
const teethLabel = activeDetail.teeth.length ? [...activeDetail.teeth].sort().join(', ') : t('teethNone');
|
||||
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 (
|
||||
@@ -333,9 +269,7 @@ export function LabCasesDispatchPanel({
|
||||
<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} · {teethLabel}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{typeLabel}</p>
|
||||
</div>
|
||||
{activeLabCase ? renderDueDateField() : null}
|
||||
</div>
|
||||
@@ -363,6 +297,13 @@ export function LabCasesDispatchPanel({
|
||||
orgs={orgs}
|
||||
/>
|
||||
|
||||
{toothJobRows.length > 0 ? (
|
||||
<LabCaseToothJobsList
|
||||
rows={toothJobRows}
|
||||
prosthesisCatalog={catalog}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{hasTrackerSummary && labCaseSummary ? (
|
||||
<LabCaseTrackerCard
|
||||
summary={labCaseSummary}
|
||||
@@ -415,89 +356,20 @@ export function LabCasesDispatchPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{prosthesisRows.length > 0 && activeLabCase.destinationOrganizationId ? (
|
||||
<div className="space-y-3 border-t border-border/60 pt-3">
|
||||
<p className="text-xs font-medium text-text-secondary">
|
||||
{t('prosthesisTypesTitle')}
|
||||
</p>
|
||||
{flatToothCount > 1 ? (
|
||||
<label className="block text-xs text-text-muted space-y-1">
|
||||
{t('prosthesisApplyAll')}
|
||||
<select
|
||||
value={applyAllProsthesis}
|
||||
disabled={disabled || prosthesisOptions.length === 0}
|
||||
onChange={(e) => {
|
||||
const code = e.target.value;
|
||||
setApplyAllProsthesis(code);
|
||||
if (code) applyProsthesisToAll(code);
|
||||
}}
|
||||
className={`${FORM_SELECT_CLASS} w-full mt-1`}
|
||||
>
|
||||
<option value="">{t('prosthesisSelectPlaceholder')}</option>
|
||||
{prosthesisOptions.map((opt) => (
|
||||
<option key={opt.code} value={opt.code}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
{prosthesisRows.map((row) => {
|
||||
const current =
|
||||
activeLabCase.toothProsthesis.find(
|
||||
(tp) =>
|
||||
tp.detailClientId === row.detailClientId &&
|
||||
tp.selectionGroupId === row.groupId &&
|
||||
row.teeth.includes(tp.tooth),
|
||||
)?.prosthesisTypeCode ??
|
||||
activeLabCase.toothProsthesis.find(
|
||||
(tp) =>
|
||||
tp.detailClientId === row.detailClientId &&
|
||||
row.teeth.includes(tp.tooth),
|
||||
)?.prosthesisTypeCode ??
|
||||
'';
|
||||
return (
|
||||
<label
|
||||
key={row.groupId}
|
||||
className="block text-xs text-text-muted rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/40 px-3 py-2 min-w-0"
|
||||
>
|
||||
<span className="grid grid-cols-1 gap-2 md:grid-cols-2 md:items-center md:gap-3">
|
||||
<span className="flex flex-wrap items-center gap-2 text-text-secondary min-w-0 break-words">
|
||||
{row.kind === 'connected' ? <ConnectedSelectionBadge /> : null}
|
||||
<span className="min-w-0">
|
||||
{row.kind === 'connected'
|
||||
? t('prosthesisConnectedLabel')
|
||||
: t('prosthesisColTooth')}
|
||||
{': '}
|
||||
<span className="text-text-primary">{row.teeth.join(', ')}</span>
|
||||
</span>
|
||||
</span>
|
||||
<select
|
||||
value={current}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setGroupProsthesis(row, e.target.value)}
|
||||
className={`${FORM_SELECT_CLASS} w-full min-w-0`}
|
||||
aria-label={
|
||||
row.kind === 'connected'
|
||||
? t('prosthesisConnectedLabel')
|
||||
: t('prosthesisColType')
|
||||
}
|
||||
>
|
||||
<option value="">{t('prosthesisSelectPlaceholder')}</option>
|
||||
{prosthesisOptions.map((opt) => (
|
||||
<option key={opt.code} value={opt.code}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user