Merge branch 'master' into feature/cases

This commit is contained in:
2026-07-10 15:26:36 +03:30
68 changed files with 2497 additions and 604 deletions

View File

@@ -22,7 +22,6 @@ interface LabCasesDispatchPanelProps {
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
activeLabCaseId: string | null;
onActiveLabCaseChange: (id: string) => void;
onLabCasesChange: (labCases: LabCaseDraft[]) => void;
disabled: boolean;
canEdit: boolean;
@@ -40,64 +39,30 @@ interface LabCasesDispatchPanelProps {
function sentDetailClientIds(labCases: LabCaseDraft[]): Set<string> {
const ids = new Set<string>();
for (const lc of labCases) {
if (!lc.sentAt) continue;
for (const id of lc.detailClientIds) ids.add(id);
if (lc.sentAt && lc.detailClientId) ids.add(lc.detailClientId);
}
return ids;
}
function detailInOtherDraftShipment(
detailClientId: string,
labCases: LabCaseDraft[],
activeLabCaseClientId: string,
): boolean {
return labCases.some(
(lc) =>
!lc.sentAt &&
lc.clientId !== activeLabCaseClientId &&
lc.detailClientIds.includes(detailClientId),
);
}
function selectableDetailsForDraftShipment(
details: TreatmentDetailDraft[],
labCases: LabCaseDraft[],
labDependentCodes: Set<string>,
activeLabCase: LabCaseDraft,
): TreatmentDetailDraft[] {
const sent = sentDetailClientIds(labCases);
return details.filter((d) => {
if (!labDependentCodes.has(d.treatmentType)) return false;
if (sent.has(d.clientId)) return false;
if (activeLabCase.detailClientIds.includes(d.clientId)) return true;
return !detailInOtherDraftShipment(d.clientId, labCases, activeLabCase.clientId);
});
}
function prosthesisTeethRows(
labCase: LabCaseDraft,
details: TreatmentDetailDraft[],
scopeDetailClientId?: string,
activeDetail: TreatmentDetailDraft,
detailNumber: number,
): Array<{ detailClientId: string; tooth: string; detailNumber: number }> {
const rows: Array<{ detailClientId: string; tooth: string; detailNumber: number }> = [];
for (const clientId of labCase.detailClientIds) {
if (scopeDetailClientId && clientId !== scopeDetailClientId) continue;
const detail = details.find((d) => d.clientId === clientId);
if (!detail || detail.treatmentType !== 'prosthesis') continue;
const detailNumber = details.findIndex((d) => d.clientId === clientId) + 1;
for (const tooth of detail.teeth) {
rows.push({ detailClientId: clientId, tooth, detailNumber });
}
}
return rows;
if (labCase.detailClientId !== activeDetail.clientId) return [];
if (activeDetail.treatmentType !== 'prosthesis') return [];
return activeDetail.teeth.map((tooth) => ({
detailClientId: activeDetail.clientId,
tooth,
detailNumber,
}));
}
function isProsthesisMapComplete(
labCase: LabCaseDraft,
details: TreatmentDetailDraft[],
scopeDetailClientId?: string,
rows: Array<{ detailClientId: string; tooth: string }>,
): boolean {
const rows = prosthesisTeethRows(labCase, details, scopeDetailClientId);
if (rows.length === 0) return true;
return rows.every((row) =>
labCase.toothProsthesis.some(
@@ -116,7 +81,6 @@ export function LabCasesDispatchPanel({
labDependentCodes,
treatmentCatalog,
activeLabCaseId,
onActiveLabCaseChange,
onLabCasesChange,
disabled,
canEdit,
@@ -151,7 +115,7 @@ export function LabCasesDispatchPanel({
);
const labCaseForActiveDetail =
labCases.find((lc) => lc.detailClientIds.includes(activeDetailId)) ?? null;
labCases.find((lc) => lc.detailClientId === activeDetailId) ?? null;
const activeLabCase =
labCaseForActiveDetail ??
@@ -159,21 +123,20 @@ export function LabCasesDispatchPanel({
const detailAlreadyInShipment = Boolean(labCaseForActiveDetail);
const canAddLabShipment =
!detailAlreadyInShipment &&
!detailInOtherDraftShipment(activeDetailId, labCases, '') &&
!sentDetailClientIds(labCases).has(activeDetailId);
!detailAlreadyInShipment && !sentDetailClientIds(labCases).has(activeDetailId);
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
? prosthesisTeethRows(activeLabCase, details, activeDetailId)
const prosthesisRows = activeLabCase && activeDetail
? prosthesisTeethRows(activeLabCase, activeDetail, activeDetailNumber)
: [];
const prosthesisComplete = activeLabCase
? isProsthesisMapComplete(activeLabCase, details, activeDetailId)
? isProsthesisMapComplete(activeLabCase, prosthesisRows)
: true;
useEffect(() => {
@@ -197,25 +160,18 @@ export function LabCasesDispatchPanel({
};
}, [activeLabCase?.destinationOrganizationId]);
// Reset the pending (unposted) comment when switching to another shipment.
useEffect(() => {
setPendingComment('');
}, [activeLabCase?.clientId]);
// Hide dispatch when the selected treatment detail is not lab-dependent.
if (!activeDetail || !isLabDependentDetail) {
return null;
}
function detailNumber(d: TreatmentDetailDraft) {
const idx = details.findIndex((row) => row.clientId === d.clientId);
return idx >= 0 ? idx + 1 : 0;
}
function detailSummary(d: TreatmentDetailDraft) {
const typeLabel = treatmentTypeLabelFromCatalog(d.treatmentType, treatmentCatalog);
const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
return `${t('detailLabel', { n: detailNumber(d) })} · ${typeLabel} · ${teeth}`;
return `${t('detailLabel', { n: activeDetailNumber })} · ${typeLabel} · ${teeth}`;
}
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
@@ -250,36 +206,6 @@ export function LabCasesDispatchPanel({
updateActiveLabCase({ toothProsthesis: next });
}
function toggleDetailInActiveLabCase(detailClientId: string, checked: boolean) {
if (!activeLabCase || sent) return;
onLabCasesChange(
labCases.map((lc) => {
if (lc.sentAt) return lc;
if (lc.clientId === activeLabCase.clientId) {
const set = new Set(lc.detailClientIds);
if (checked) set.add(detailClientId);
else set.delete(detailClientId);
const keptProsthesis = lc.toothProsthesis.filter((tp) =>
[...set].includes(tp.detailClientId),
);
return { ...lc, detailClientIds: [...set], toothProsthesis: keptProsthesis };
}
if (checked) {
return {
...lc,
detailClientIds: lc.detailClientIds.filter((id) => id !== detailClientId),
};
}
return lc;
}),
);
}
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
if (!activeLabCase || sent) return;
const set = new Set(activeLabCase.attachmentIds);
@@ -288,18 +214,7 @@ export function LabCasesDispatchPanel({
updateActiveLabCase({ attachmentIds: [...set] });
}
const activeDetailAttachments = activeDetail?.attachmentMetas ?? [];
const includedInActiveShipment = activeLabCase
? [activeDetail]
: [];
const pickableForActiveDraft =
activeLabCase && !sent
? selectableDetailsForDraftShipment(details, labCases, labDependentCodes, activeLabCase).filter(
(d) => d.clientId === activeDetailId,
)
: [];
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
return (
<div className="surface-card p-4 space-y-4">
@@ -326,275 +241,249 @@ export function LabCasesDispatchPanel({
<p className="text-xs text-text-muted">{t('labDispatchEmpty')}</p>
) : activeLabCase ? (
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
{sent ? (
<>
<div>
<p className="text-xs font-medium text-text-secondary mb-2">
{t('labShipmentIncludedDetails')}
</p>
{includedInActiveShipment.length === 0 ? (
<p className="text-xs text-text-muted">{t('labShipmentNoIncludedDetails')}</p>
) : (
<ul className="space-y-1.5">
{includedInActiveShipment.map((d) => (
<li
key={d.clientId}
className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2"
>
{detailSummary(d)}
</li>
))}
</ul>
)}
{sent ? (
<>
<div>
<p className="text-xs font-medium text-text-secondary mb-2">
{t('labShipmentIncludedDetails')}
</p>
<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>
</div>
{activeLabCase.id ? (
<LabCaseCommentsPanel
caseId={activeLabCase.id}
canPost={false}
canToggleVisibility={false}
loadComments={async () => {
const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!);
return r.data;
}}
onPost={async () => {
throw new Error('Read-only');
}}
onError={onCommentError}
/>
) : null}
{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}
/>
</>
) : (
<>
<div>
<p className="text-xs font-medium text-text-secondary mb-2">
{t('labShipmentIncludedDetails')}
</p>
<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>
</div>
{!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}
{activeLabCase.id ? (
<LabCaseCommentsPanel
caseId={activeLabCase.id}
canPost={false}
canToggleVisibility={false}
loadComments={async () => {
const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!);
return r.data;
}}
onPost={async () => {
throw new Error('Read-only');
}}
onError={onCommentError}
/>
) : null}
{activeLabCase.id ? (
<LabCaseCommentsPanel
caseId={activeLabCase.id}
canPost={canEdit && !disabled}
canToggleVisibility={false}
deferSubmit
composerValue={pendingComment}
onComposerValueChange={setPendingComment}
loadComments={async () => {
const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!);
return r.data;
}}
onPost={async (body) => {
const r = await treatmentsApi.addLabCaseComment(activeLabCase.id!, { body });
return r.data;
}}
onError={onCommentError}
/>
) : null}
{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}
/>
</>
) : (
<>
<div>
<p className="text-xs font-medium text-text-secondary mb-2">
{t('includeDetails')}
</p>
{pickableForActiveDraft.length === 0 ? (
<p className="text-xs text-text-muted">{t('labShipmentNoDetailsAvailable')}</p>
) : (
<div className="flex flex-col gap-2">
{pickableForActiveDraft.map((d) => {
const checked = activeLabCase.detailClientIds.includes(d.clientId);
return (
<Checkbox
key={d.clientId}
checked={checked}
disabled={disabled}
onChange={(next) => toggleDetailInActiveLabCase(d.clientId, next)}
label={detailSummary(d)}
/>
);
})}
</div>
)}
<div className="space-y-2">
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
<SearchBar
embedded
value={organizationSearch}
onChange={onOrganizationSearchChange}
placeholder={t('searchOrgsPlaceholder')}
/>
{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={() => 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}
</button>
))}
</div>
)}
<Dropdown
value={activeLabCase.destinationOrganizationId ?? ''}
onChange={(e) => {
const nextOrgId = e.target.value || null;
updateActiveLabCase({
destinationOrganizationId: nextOrgId,
toothProsthesis: [],
});
setApplyAllProsthesis('');
}}
disabled={disabled || filteredOrganizations.length === 0}
>
<option value="">{t('selectLabPlaceholder')}</option>
{filteredOrganizations.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</Dropdown>
{filteredOrganizations.length === 0 && (
<p className="text-xs text-text-muted">{t('noOrgMatch')}</p>
)}
</div>
{!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}
{activeLabCase.id ? (
<LabCaseCommentsPanel
caseId={activeLabCase.id}
canPost={canEdit && !disabled}
canToggleVisibility={false}
deferSubmit
composerValue={pendingComment}
onComposerValueChange={setPendingComment}
loadComments={async () => {
const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!);
return r.data;
}}
onPost={async (body) => {
const r = await treatmentsApi.addLabCaseComment(activeLabCase.id!, { body });
return r.data;
}}
onError={onCommentError}
/>
) : null}
<div className="space-y-2">
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
<SearchBar
embedded
value={organizationSearch}
onChange={onOrganizationSearchChange}
placeholder={t('searchOrgsPlaceholder')}
/>
{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={() => 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}
</button>
))}
</div>
)}
<Dropdown
value={activeLabCase.destinationOrganizationId ?? ''}
{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>
<label className="block text-xs text-text-muted space-y-1">
{t('prosthesisApplyAll')}
<select
value={applyAllProsthesis}
disabled={disabled || prosthesisOptions.length === 0}
onChange={(e) => {
const nextOrgId = e.target.value || null;
updateActiveLabCase({
destinationOrganizationId: nextOrgId,
toothProsthesis: [],
});
setApplyAllProsthesis('');
const code = e.target.value;
setApplyAllProsthesis(code);
if (code) applyProsthesisToAll(code);
}}
disabled={disabled || filteredOrganizations.length === 0}
className={`${FORM_SELECT_CLASS} w-full mt-1`}
>
<option value="">{t('selectLabPlaceholder')}</option>
{filteredOrganizations.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
<option value="">{t('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</Dropdown>
{filteredOrganizations.length === 0 && (
<p className="text-xs text-text-muted">{t('noOrgMatch')}</p>
)}
</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>
<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>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted">
<th className="pb-2 pr-3 font-medium">{t('prosthesisColTooth')}</th>
<th className="pb-2 pr-3 font-medium">{t('prosthesisColDetail')}</th>
<th className="pb-2 font-medium">{t('prosthesisColType')}</th>
</select>
</label>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted">
<th className="pb-2 pr-3 font-medium">{t('prosthesisColTooth')}</th>
<th className="pb-2 pr-3 font-medium">{t('prosthesisColDetail')}</th>
<th className="pb-2 font-medium">{t('prosthesisColType')}</th>
</tr>
</thead>
<tbody>
{prosthesisRows.map((row) => {
const current =
activeLabCase.toothProsthesis.find(
(tp) =>
tp.detailClientId === row.detailClientId &&
tp.tooth === row.tooth,
)?.prosthesisTypeCode ?? '';
return (
<tr key={`${row.detailClientId}-${row.tooth}`} className="border-t border-border/40">
<td className="py-2 pr-3 text-text-primary">{row.tooth}</td>
<td className="py-2 pr-3 text-text-secondary">
{t('detailLabel', { n: row.detailNumber })}
</td>
<td className="py-2">
<select
value={current}
disabled={disabled}
onChange={(e) =>
setToothProsthesis(
row.detailClientId,
row.tooth,
e.target.value,
)
}
className={`${FORM_SELECT_CLASS} w-full min-w-[160px]`}
>
<option value="">{t('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</td>
</tr>
</thead>
<tbody>
{prosthesisRows.map((row) => {
const current =
activeLabCase.toothProsthesis.find(
(tp) =>
tp.detailClientId === row.detailClientId &&
tp.tooth === row.tooth,
)?.prosthesisTypeCode ?? '';
return (
<tr key={`${row.detailClientId}-${row.tooth}`} className="border-t border-border/40">
<td className="py-2 pr-3 text-text-primary">{row.tooth}</td>
<td className="py-2 pr-3 text-text-secondary">
{t('detailLabel', { n: row.detailNumber })}
</td>
<td className="py-2">
<select
value={current}
disabled={disabled}
onChange={(e) =>
setToothProsthesis(
row.detailClientId,
row.tooth,
e.target.value,
)
}
className={`${FORM_SELECT_CLASS} w-full min-w-[160px]`}
>
<option value="">{t('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
) : null}
<div className="flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="primary"
disabled={
disabled ||
sendBusyId === activeLabCase.clientId ||
!activeLabCase.destinationOrganizationId ||
activeLabCase.detailClientIds.length === 0 ||
!prosthesisComplete
}
isLoading={sendBusyId === activeLabCase.clientId}
onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
>
{t('sendToLab')}
</Button>
);
})}
</tbody>
</table>
</div>
</>
)}
</div>
</div>
) : null}
<div className="flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="primary"
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>
);

View File

@@ -43,29 +43,36 @@ function isTreatmentDayHistorical(treatmentAt: string, todayStart: Date): boolea
return compareLocalDayStart(new Date(treatmentAt), todayStart) < 0;
}
function withoutEmptyLabCaseDrafts(drafts: LabCaseDraft[]): LabCaseDraft[] {
return drafts.filter((lc) => lc.sentAt || Boolean(lc.detailClientId));
}
function labCaseDraftsToPast(
labCaseDrafts: LabCaseDraft[],
details: TreatmentDetailDraft[],
): PastLabCase[] {
return labCaseDrafts.map((lc) => ({
id: lc.id ?? lc.clientId,
clientId: lc.clientId,
destinationOrganizationId: lc.destinationOrganizationId,
sentAt: lc.sentAt ?? null,
treatmentDetailIds: lc.detailClientIds
.map((cid) => details.find((d) => d.clientId === cid)?.id)
.filter((id): id is string => Boolean(id)),
details: lc.detailClientIds.map((cid) => {
const d = details.find((x) => x.clientId === cid);
return {
id: d?.id ?? cid,
clientId: cid,
treatmentType: d?.treatmentType ?? 'consultation',
teeth: d?.teeth ?? [],
};
}),
sends: lc.sends ?? [],
}));
return labCaseDrafts.map((lc) => {
const linkedDetail = lc.detailClientId
? details.find((d) => d.clientId === lc.detailClientId)
: undefined;
return {
id: lc.id ?? lc.clientId,
clientId: lc.clientId,
destinationOrganizationId: lc.destinationOrganizationId,
sentAt: lc.sentAt ?? null,
treatmentDetailId: linkedDetail?.id ?? null,
detail: linkedDetail
? {
id: linkedDetail.id ?? linkedDetail.clientId,
clientId: linkedDetail.clientId,
treatmentType: linkedDetail.treatmentType,
teeth: linkedDetail.teeth,
}
: null,
sends: lc.sends ?? [],
};
});
}
function enrichDetailsWithLabSendState(
@@ -74,7 +81,7 @@ function enrichDetailsWithLabSendState(
): TreatmentDetailDraft[] {
return details.map((detail) => {
const sentLabCase = labCaseDrafts.find(
(lc) => lc.sentAt && lc.detailClientIds.includes(detail.clientId),
(lc) => lc.sentAt && lc.detailClientId === detail.clientId,
);
if (!sentLabCase) return detail;
return {
@@ -142,7 +149,7 @@ function newLabCaseDraft(): LabCaseDraft {
? crypto.randomUUID()
: `lab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
destinationOrganizationId: null,
detailClientIds: [],
detailClientId: null,
toothProsthesis: [],
attachmentIds: [],
sentAt: null,
@@ -179,16 +186,13 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
}
function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
const detailClientById = new Map(lc.details.map((d) => [d.id, d.clientId]));
return {
clientId: lc.clientId,
id: lc.id,
destinationOrganizationId: lc.destinationOrganizationId,
detailClientIds: lc.details.map((d) => d.clientId),
detailClientId: lc.detail?.clientId ?? null,
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
detailClientId:
detailClientById.get(tp.treatmentDetailId) ?? tp.treatmentDetailId,
detailClientId: lc.detail?.clientId ?? tp.treatmentDetailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
})),
@@ -313,7 +317,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const isDetailLocked = useCallback(
(detail: TreatmentDetailDraft) =>
labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientIds.includes(detail.clientId)),
labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientId === detail.clientId),
[labCaseDrafts],
);
@@ -388,7 +392,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
return stillExists ? prev : mapped[0]?.clientId ?? prev;
});
setSavedSnapshot(serializeDetails(mapped));
const mappedLabCases = (treatment.labCases ?? []).map(mapLabCaseDraftFromApi);
const mappedLabCases = withoutEmptyLabCaseDrafts(
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
);
setLabCaseDrafts(mappedLabCases);
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
@@ -433,7 +439,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
// Sync active lab shipment when the selected treatment detail changes.
useEffect(() => {
const match = labCaseDrafts.find((lc) => lc.detailClientIds.includes(activeDetailId));
const match = labCaseDrafts.find((lc) => lc.detailClientId === activeDetailId);
setActiveLabCaseId(match?.clientId ?? null);
}, [activeDetailId, labCaseDrafts]);
@@ -581,7 +587,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
setSavedSnapshot(serializeDetails([first]));
}
const mappedLabCases = (response.data?.labCases ?? []).map(mapLabCaseDraftFromApi);
const mappedLabCases = withoutEmptyLabCaseDrafts(
(response.data?.labCases ?? []).map(mapLabCaseDraftFromApi),
);
setLabCaseDrafts(mappedLabCases);
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
@@ -848,26 +856,35 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
savedTreatment.details.map((d) => [d.clientId, d.id]),
);
const payload = drafts.map((lc) => ({
clientId: lc.clientId,
id: lc.id,
destinationOrganizationId: lc.destinationOrganizationId ?? undefined,
treatmentDetailIds: lc.detailClientIds
.map((clientId) => detailIdByClientId.get(clientId))
.filter((id): id is string => Boolean(id)),
toothProsthesis: lc.toothProsthesis
.map((tp) => {
const detailId = detailIdByClientId.get(tp.detailClientId);
if (!detailId) return null;
return {
treatmentDetailId: detailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
};
})
.filter((row): row is { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string } => row !== null),
attachmentIds: lc.attachmentIds,
}));
const payload = drafts
.map((lc) => {
if (!lc.detailClientId) return null;
const treatmentDetailId = detailIdByClientId.get(lc.detailClientId);
if (!treatmentDetailId) return null;
return {
clientId: lc.clientId,
id: lc.id,
destinationOrganizationId: lc.destinationOrganizationId ?? undefined,
treatmentDetailId,
toothProsthesis: lc.toothProsthesis
.map((tp) => {
const detailId = detailIdByClientId.get(tp.detailClientId);
if (!detailId) return null;
return {
treatmentDetailId: detailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
};
})
.filter(
(row): row is { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string } =>
row !== null,
),
attachmentIds: lc.attachmentIds,
};
})
.filter((row): row is NonNullable<typeof row> => row !== null);
if (payload.length === 0) {
return savedTreatment;
@@ -876,7 +893,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const response = await treatmentsApi.saveLabCases(selectedAppointment.id, {
labCases: payload,
});
const mapped = response.data.labCases.map(mapLabCaseDraftFromApi);
const mapped = withoutEmptyLabCaseDrafts(response.data.labCases.map(mapLabCaseDraftFromApi));
setLabCaseDrafts(mapped);
setActiveLabCaseId((prev) => {
if (prev && mapped.some((lc) => lc.clientId === prev)) return prev;
@@ -887,18 +904,86 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[labCaseDrafts, selectedAppointment],
);
const handleLabCasesChange = useCallback(
(next: LabCaseDraft[]) => {
const prevCleaned = withoutEmptyLabCaseDrafts(labCaseDrafts);
const cleaned = withoutEmptyLabCaseDrafts(next);
setLabCaseDrafts(cleaned);
if (!cleaned.some((lc) => lc.detailClientId === activeDetailId)) {
setActiveLabCaseId((prev) =>
prev && cleaned.some((lc) => lc.clientId === prev) ? prev : null,
);
}
const removedPersistedDraft = prevCleaned.some(
(lc) => lc.id && !cleaned.some((row) => row.clientId === lc.clientId),
);
if (
removedPersistedDraft &&
selectedAppointment &&
canEditTreatmentForDay
) {
void (async () => {
try {
const saved = await persistDraft({ force: true });
await persistLabCases(saved, cleaned);
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
}
})();
}
},
[
activeDetailId,
canEditTreatmentForDay,
labCaseDrafts,
persistDraft,
persistLabCases,
selectedAppointment,
showError,
t,
],
);
const handleAddLabCase = useCallback(async () => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
const cleaned = withoutEmptyLabCaseDrafts(labCaseDrafts);
const existing = cleaned.find(
(lc) => !lc.sentAt && lc.detailClientId === activeDetailId,
);
if (existing) {
setActiveLabCaseId(existing.clientId);
return;
}
const activeDetail = details.find((d) => d.clientId === activeDetailId);
const shouldIncludeActive =
Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType));
const orphan = cleaned.find((lc) => !lc.sentAt && !lc.detailClientId);
if (orphan && shouldIncludeActive) {
const updatedLabCases = cleaned.map((lc) =>
lc.clientId === orphan.clientId ? { ...lc, detailClientId: activeDetailId } : lc,
);
setLabCaseDrafts(updatedLabCases);
setActiveLabCaseId(orphan.clientId);
try {
const saved = await persistDraft({ force: true });
await persistLabCases(saved, updatedLabCases);
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
}
return;
}
const next: LabCaseDraft = {
...newLabCaseDraft(),
detailClientIds:
activeDetail && labDependentCodes.has(activeDetail.treatmentType)
? [activeDetailId]
: [],
detailClientId: shouldIncludeActive ? activeDetailId : null,
};
const updatedLabCases = [...labCaseDrafts, next];
const updatedLabCases = [...cleaned, next];
setLabCaseDrafts(updatedLabCases);
setActiveLabCaseId(next.clientId);
@@ -928,7 +1013,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
showError(t('errorChooseOrg'));
return;
}
if (labCase.detailClientIds.length === 0) {
if (!labCase.detailClientId) {
showError(t('errorLabCaseNeedsDetails'));
return;
}
@@ -958,10 +1043,10 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
const sentDetailClientIds = new Set(labCase.detailClientIds);
const sentDetailClientId = labCase.detailClientId;
setDetails((prev) =>
prev.map((detail) => {
if (!sentDetailClientIds.has(detail.clientId)) return detail;
if (detail.clientId !== sentDetailClientId) return detail;
return {
...detail,
labCaseId: response.data.id,
@@ -1155,8 +1240,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
activeLabCaseId={activeLabCaseId}
onActiveLabCaseChange={setActiveLabCaseId}
onLabCasesChange={setLabCaseDrafts}
onLabCasesChange={handleLabCasesChange}
disabled={!canEditTreatmentForDay}
canEdit={canEdit}
orgs={orgs}
@@ -1164,14 +1248,18 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
onOrganizationSearchChange={setOrganizationSearch}
recentOrganizationIds={recentOrganizationIds}
onRecentOrganizationPick={(orgId) => {
if (!activeLabCaseId) return;
setLabCaseDrafts((prev) =>
prev.map((lc) =>
lc.clientId === activeLabCaseId && !lc.sentAt
setLabCaseDrafts((prev) => {
const targetId =
activeLabCaseId ??
prev.find((lc) => !lc.sentAt && lc.detailClientId === activeDetailId)
?.clientId;
if (!targetId) return prev;
return prev.map((lc) =>
lc.clientId === targetId && !lc.sentAt
? { ...lc, destinationOrganizationId: orgId }
: lc,
),
);
);
});
}}
sendBusyId={sendBusyId}
onAddLabCase={() => void handleAddLabCase()}