2026-06-28 17:14:02 +03:30
|
|
|
'use client';
|
|
|
|
|
|
2026-07-07 17:14:31 +03:30
|
|
|
import { useEffect, useState } from 'react';
|
2026-06-28 17:14:02 +03:30
|
|
|
import { useTranslations } from 'next-intl';
|
|
|
|
|
import { Button } from '@/components/ui/shared/Button';
|
|
|
|
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
|
|
|
|
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
2026-07-06 20:40:19 +03:30
|
|
|
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
|
2026-06-28 17:14:02 +03:30
|
|
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
|
|
|
|
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
2026-07-07 17:14:31 +03:30
|
|
|
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
2026-07-06 20:40:19 +03:30
|
|
|
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
2026-07-07 17:14:31 +03:30
|
|
|
import { treatmentsApi } from '@/lib/api/treatments';
|
2026-07-06 20:40:19 +03:30
|
|
|
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
|
|
|
|
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
2026-06-28 17:14:02 +03:30
|
|
|
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
|
|
|
|
|
|
|
|
|
|
interface LabCasesDispatchPanelProps {
|
|
|
|
|
details: TreatmentDetailDraft[];
|
2026-07-07 17:14:31 +03:30
|
|
|
activeDetailId: string;
|
2026-06-28 17:14:02 +03:30
|
|
|
labCases: LabCaseDraft[];
|
|
|
|
|
labDependentCodes: Set<string>;
|
2026-07-06 20:40:19 +03:30
|
|
|
treatmentCatalog: TreatmentCatalogEntry[];
|
2026-06-28 17:14:02 +03:30
|
|
|
activeLabCaseId: string | null;
|
|
|
|
|
onActiveLabCaseChange: (id: string) => void;
|
|
|
|
|
onLabCasesChange: (labCases: LabCaseDraft[]) => void;
|
|
|
|
|
disabled: boolean;
|
|
|
|
|
canEdit: boolean;
|
|
|
|
|
orgs: LinkedOrganizationOption[];
|
|
|
|
|
organizationSearch: string;
|
|
|
|
|
onOrganizationSearchChange: (value: string) => void;
|
|
|
|
|
recentOrganizationIds: string[];
|
|
|
|
|
onRecentOrganizationPick: (orgId: string) => void;
|
|
|
|
|
sendBusyId: string | null;
|
|
|
|
|
onAddLabCase: () => void;
|
|
|
|
|
onSendLabCase: (labCase: LabCaseDraft) => void;
|
2026-07-07 17:14:31 +03:30
|
|
|
onCommentError?: (message: string) => void;
|
2026-06-28 17:14:02 +03:30
|
|
|
}
|
|
|
|
|
|
2026-06-28 18:48:06 +03:30
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
function prosthesisTeethRows(
|
|
|
|
|
labCase: LabCaseDraft,
|
|
|
|
|
details: TreatmentDetailDraft[],
|
2026-07-07 17:14:31 +03:30
|
|
|
scopeDetailClientId?: string,
|
2026-07-06 20:40:19 +03:30
|
|
|
): Array<{ detailClientId: string; tooth: string; detailNumber: number }> {
|
|
|
|
|
const rows: Array<{ detailClientId: string; tooth: string; detailNumber: number }> = [];
|
|
|
|
|
for (const clientId of labCase.detailClientIds) {
|
2026-07-07 17:14:31 +03:30
|
|
|
if (scopeDetailClientId && clientId !== scopeDetailClientId) continue;
|
2026-07-06 20:40:19 +03:30
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 17:14:31 +03:30
|
|
|
function isProsthesisMapComplete(
|
|
|
|
|
labCase: LabCaseDraft,
|
|
|
|
|
details: TreatmentDetailDraft[],
|
|
|
|
|
scopeDetailClientId?: string,
|
|
|
|
|
): boolean {
|
|
|
|
|
const rows = prosthesisTeethRows(labCase, details, scopeDetailClientId);
|
2026-07-06 20:40:19 +03:30
|
|
|
if (rows.length === 0) return true;
|
|
|
|
|
return rows.every((row) =>
|
|
|
|
|
labCase.toothProsthesis.some(
|
|
|
|
|
(tp) =>
|
|
|
|
|
tp.detailClientId === row.detailClientId &&
|
|
|
|
|
tp.tooth === row.tooth &&
|
|
|
|
|
Boolean(tp.prosthesisTypeCode),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
export function LabCasesDispatchPanel({
|
|
|
|
|
details,
|
2026-07-07 17:14:31 +03:30
|
|
|
activeDetailId,
|
2026-06-28 17:14:02 +03:30
|
|
|
labCases,
|
|
|
|
|
labDependentCodes,
|
2026-07-06 20:40:19 +03:30
|
|
|
treatmentCatalog,
|
2026-06-28 17:14:02 +03:30
|
|
|
activeLabCaseId,
|
|
|
|
|
onActiveLabCaseChange,
|
|
|
|
|
onLabCasesChange,
|
|
|
|
|
disabled,
|
|
|
|
|
canEdit,
|
|
|
|
|
orgs,
|
|
|
|
|
organizationSearch,
|
|
|
|
|
onOrganizationSearchChange,
|
|
|
|
|
recentOrganizationIds,
|
|
|
|
|
onRecentOrganizationPick,
|
|
|
|
|
sendBusyId,
|
|
|
|
|
onAddLabCase,
|
|
|
|
|
onSendLabCase,
|
2026-07-07 17:14:31 +03:30
|
|
|
onCommentError,
|
2026-06-28 17:14:02 +03:30
|
|
|
}: LabCasesDispatchPanelProps) {
|
|
|
|
|
const t = useTranslations('treatment');
|
2026-07-06 20:40:19 +03:30
|
|
|
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
|
|
|
|
|
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
|
|
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
|
|
|
|
const filteredOrganizations = (() => {
|
|
|
|
|
const q = organizationSearch.trim().toLowerCase();
|
|
|
|
|
if (!q) return activeLinkedOrganizations;
|
|
|
|
|
return activeLinkedOrganizations.filter((o) => o.name.toLowerCase().includes(q));
|
|
|
|
|
})();
|
|
|
|
|
const recentOrganizations = recentOrganizationIds
|
|
|
|
|
.map((id) => activeLinkedOrganizations.find((o) => o.id === id))
|
|
|
|
|
.filter(Boolean) as LinkedOrganizationOption[];
|
|
|
|
|
|
2026-07-07 17:14:31 +03:30
|
|
|
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? null;
|
|
|
|
|
const isLabDependentDetail = Boolean(
|
|
|
|
|
activeDetail && labDependentCodes.has(activeDetail.treatmentType),
|
2026-06-28 18:48:06 +03:30
|
|
|
);
|
|
|
|
|
|
2026-07-07 17:14:31 +03:30
|
|
|
const labCaseForActiveDetail =
|
|
|
|
|
labCases.find((lc) => lc.detailClientIds.includes(activeDetailId)) ?? null;
|
2026-06-28 18:48:06 +03:30
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
const activeLabCase =
|
2026-07-07 17:14:31 +03:30
|
|
|
labCaseForActiveDetail ??
|
|
|
|
|
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
|
|
|
|
|
|
|
|
|
|
const detailAlreadyInShipment = Boolean(labCaseForActiveDetail);
|
|
|
|
|
const canAddLabShipment =
|
|
|
|
|
!detailAlreadyInShipment &&
|
|
|
|
|
!detailInOtherDraftShipment(activeDetailId, labCases, '') &&
|
|
|
|
|
!sentDetailClientIds(labCases).has(activeDetailId);
|
|
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
const sent = Boolean(activeLabCase?.sentAt);
|
|
|
|
|
|
2026-06-28 18:48:06 +03:30
|
|
|
const activeLabOrgName = activeLabCase?.destinationOrganizationId
|
|
|
|
|
? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name
|
|
|
|
|
: null;
|
|
|
|
|
|
2026-07-07 17:14:31 +03:30
|
|
|
const prosthesisRows = activeLabCase
|
|
|
|
|
? prosthesisTeethRows(activeLabCase, details, activeDetailId)
|
|
|
|
|
: [];
|
2026-07-06 20:40:19 +03:30
|
|
|
const prosthesisComplete = activeLabCase
|
2026-07-07 17:14:31 +03:30
|
|
|
? isProsthesisMapComplete(activeLabCase, details, activeDetailId)
|
2026-07-06 20:40:19 +03:30
|
|
|
: true;
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!activeLabCase?.destinationOrganizationId) {
|
|
|
|
|
setProsthesisOptions([]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
void prosthesisCatalogApi
|
|
|
|
|
.list(activeLabCase.destinationOrganizationId)
|
|
|
|
|
.then((res) => {
|
|
|
|
|
if (!cancelled) setProsthesisOptions(res.data);
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
if (!cancelled) setProsthesisOptions([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [activeLabCase?.destinationOrganizationId]);
|
|
|
|
|
|
2026-07-07 17:14:31 +03:30
|
|
|
// Hide dispatch when the selected treatment detail is not lab-dependent.
|
|
|
|
|
if (!activeDetail || !isLabDependentDetail) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 18:48:06 +03:30
|
|
|
function detailNumber(d: TreatmentDetailDraft) {
|
|
|
|
|
const idx = details.findIndex((row) => row.clientId === d.clientId);
|
|
|
|
|
return idx >= 0 ? idx + 1 : 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function detailSummary(d: TreatmentDetailDraft) {
|
2026-07-06 20:40:19 +03:30
|
|
|
const typeLabel = treatmentTypeLabelFromCatalog(d.treatmentType, treatmentCatalog);
|
2026-06-28 17:14:02 +03:30
|
|
|
const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
|
2026-06-28 18:48:06 +03:30
|
|
|
return `${t('detailLabel', { n: detailNumber(d) })} · ${typeLabel} · ${teeth}`;
|
2026-06-28 17:14:02 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
|
|
|
|
|
if (!activeLabCase) return;
|
|
|
|
|
onLabCasesChange(
|
|
|
|
|
labCases.map((lc) => (lc.clientId === activeLabCase.clientId ? { ...lc, ...patch } : lc)),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
function setToothProsthesis(
|
|
|
|
|
detailClientId: string,
|
|
|
|
|
tooth: string,
|
|
|
|
|
prosthesisTypeCode: string,
|
|
|
|
|
) {
|
|
|
|
|
if (!activeLabCase) return;
|
|
|
|
|
const rest = activeLabCase.toothProsthesis.filter(
|
|
|
|
|
(tp) => !(tp.detailClientId === detailClientId && tp.tooth === tooth),
|
|
|
|
|
);
|
|
|
|
|
const next = prosthesisTypeCode
|
|
|
|
|
? [...rest, { detailClientId, tooth, prosthesisTypeCode }]
|
|
|
|
|
: rest;
|
|
|
|
|
updateActiveLabCase({ toothProsthesis: next });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyProsthesisToAll(code: string) {
|
|
|
|
|
if (!activeLabCase || !code) return;
|
|
|
|
|
const next = prosthesisRows.map((row) => ({
|
|
|
|
|
detailClientId: row.detailClientId,
|
|
|
|
|
tooth: row.tooth,
|
|
|
|
|
prosthesisTypeCode: code,
|
|
|
|
|
}));
|
|
|
|
|
updateActiveLabCase({ toothProsthesis: next });
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
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);
|
2026-07-06 20:40:19 +03:30
|
|
|
|
|
|
|
|
const keptProsthesis = lc.toothProsthesis.filter((tp) =>
|
|
|
|
|
[...set].includes(tp.detailClientId),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return { ...lc, detailClientIds: [...set], toothProsthesis: keptProsthesis };
|
2026-06-28 17:14:02 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (checked) {
|
|
|
|
|
return {
|
|
|
|
|
...lc,
|
|
|
|
|
detailClientIds: lc.detailClientIds.filter((id) => id !== detailClientId),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return lc;
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 18:43:10 +03:30
|
|
|
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] });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const activeDetailAttachments = activeDetail?.attachmentMetas ?? [];
|
|
|
|
|
|
2026-06-28 18:48:06 +03:30
|
|
|
const includedInActiveShipment = activeLabCase
|
2026-07-07 17:14:31 +03:30
|
|
|
? [activeDetail]
|
2026-06-28 18:48:06 +03:30
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
const pickableForActiveDraft =
|
|
|
|
|
activeLabCase && !sent
|
2026-07-07 17:14:31 +03:30
|
|
|
? selectableDetailsForDraftShipment(details, labCases, labDependentCodes, activeLabCase).filter(
|
|
|
|
|
(d) => d.clientId === activeDetailId,
|
|
|
|
|
)
|
2026-06-28 18:48:06 +03:30
|
|
|
: [];
|
|
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
return (
|
|
|
|
|
<div className="surface-card p-4 space-y-4">
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
|
2026-06-28 18:08:29 +03:30
|
|
|
<p className="text-xs text-text-muted mt-0.5">
|
|
|
|
|
{t('labDispatchSubtitle')} {t('labDispatchSendHint')}
|
|
|
|
|
</p>
|
2026-06-28 17:14:02 +03:30
|
|
|
</div>
|
2026-06-28 18:48:06 +03:30
|
|
|
{canAddLabShipment && (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="primary"
|
|
|
|
|
disabled={!canEdit || disabled}
|
|
|
|
|
onClick={onAddLabCase}
|
|
|
|
|
>
|
|
|
|
|
{t('addLabShipment')}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-06-28 17:14:02 +03:30
|
|
|
</div>
|
|
|
|
|
|
2026-07-07 17:14:31 +03:30
|
|
|
{!detailAlreadyInShipment ? (
|
2026-06-28 17:14:02 +03:30
|
|
|
<p className="text-xs text-text-muted">{t('labDispatchEmpty')}</p>
|
2026-07-07 17:14:31 +03:30
|
|
|
) : activeLabCase ? (
|
|
|
|
|
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
|
2026-06-28 18:48:06 +03: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>
|
|
|
|
|
)}
|
2026-06-28 17:14:02 +03:30
|
|
|
</div>
|
|
|
|
|
|
2026-07-07 17:14:31 +03:30
|
|
|
{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}
|
|
|
|
|
/>
|
2026-06-28 18:48:06 +03:30
|
|
|
) : 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}
|
|
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
<CaseSentLabel
|
|
|
|
|
treatmentCase={{
|
|
|
|
|
destinationOrganizationId: activeLabCase.destinationOrganizationId,
|
|
|
|
|
sendToOrganizationIds: activeLabCase.destinationOrganizationId
|
|
|
|
|
? [activeLabCase.destinationOrganizationId]
|
|
|
|
|
: [],
|
|
|
|
|
sentAt: activeLabCase.sentAt ?? null,
|
|
|
|
|
sends: activeLabCase.sends,
|
|
|
|
|
}}
|
|
|
|
|
orgs={orgs}
|
|
|
|
|
/>
|
2026-06-28 18:48:06 +03:30
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<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>
|
|
|
|
|
|
2026-07-07 18:43:10 +03:30
|
|
|
{!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}
|
|
|
|
|
|
2026-07-07 17:14:31 +03:30
|
|
|
{activeLabCase.id ? (
|
|
|
|
|
<LabCaseCommentsPanel
|
|
|
|
|
caseId={activeLabCase.id}
|
|
|
|
|
canPost={canEdit && !disabled}
|
|
|
|
|
canToggleVisibility={false}
|
|
|
|
|
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}
|
2026-06-28 18:48:06 +03:30
|
|
|
/>
|
2026-07-07 17:14:31 +03:30
|
|
|
) : null}
|
2026-06-28 18:48:06 +03:30
|
|
|
|
|
|
|
|
<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 ?? ''}
|
2026-07-06 20:40:19 +03:30
|
|
|
onChange={(e) => {
|
|
|
|
|
const nextOrgId = e.target.value || null;
|
2026-06-28 18:48:06 +03:30
|
|
|
updateActiveLabCase({
|
2026-07-06 20:40:19 +03:30
|
|
|
destinationOrganizationId: nextOrgId,
|
|
|
|
|
toothProsthesis: [],
|
|
|
|
|
});
|
|
|
|
|
setApplyAllProsthesis('');
|
|
|
|
|
}}
|
2026-06-28 18:48:06 +03:30
|
|
|
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>
|
|
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
{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>
|
|
|
|
|
</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}
|
|
|
|
|
|
2026-06-28 18:48:06 +03:30
|
|
|
<div className="flex flex-wrap items-center gap-3 pt-1">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="primary"
|
|
|
|
|
disabled={
|
|
|
|
|
disabled ||
|
|
|
|
|
sendBusyId === activeLabCase.clientId ||
|
|
|
|
|
!activeLabCase.destinationOrganizationId ||
|
2026-07-06 20:40:19 +03:30
|
|
|
activeLabCase.detailClientIds.length === 0 ||
|
|
|
|
|
!prosthesisComplete
|
2026-06-28 18:48:06 +03:30
|
|
|
}
|
|
|
|
|
isLoading={sendBusyId === activeLabCase.clientId}
|
|
|
|
|
onClick={() => onSendLabCase(activeLabCase)}
|
|
|
|
|
>
|
|
|
|
|
{t('sendToLab')}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-06-28 17:14:02 +03:30
|
|
|
</div>
|
2026-07-07 17:14:31 +03:30
|
|
|
) : null}
|
2026-06-28 17:14:02 +03:30
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|