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

590 lines
23 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState } from 'react';
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';
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
interface LabCasesDispatchPanelProps {
details: TreatmentDetailDraft[];
labCases: LabCaseDraft[];
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
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;
}
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 unsentLabDetails(
details: TreatmentDetailDraft[],
labCases: LabCaseDraft[],
labDependentCodes: Set<string>,
): TreatmentDetailDraft[] {
const sent = sentDetailClientIds(labCases);
return details.filter((d) => labDependentCodes.has(d.treatmentType) && !sent.has(d.clientId));
}
function detailsAvailableForNewShipment(
details: TreatmentDetailDraft[],
labCases: LabCaseDraft[],
labDependentCodes: Set<string>,
): TreatmentDetailDraft[] {
return unsentLabDetails(details, labCases, labDependentCodes).filter(
(d) => !detailInOtherDraftShipment(d.clientId, labCases, ''),
);
}
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[],
): Array<{ detailClientId: string; tooth: string; detailNumber: number }> {
const rows: Array<{ detailClientId: string; tooth: string; detailNumber: number }> = [];
for (const clientId of labCase.detailClientIds) {
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;
}
function isProsthesisMapComplete(labCase: LabCaseDraft, details: TreatmentDetailDraft[]): boolean {
const rows = prosthesisTeethRows(labCase, details);
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),
),
);
}
export function LabCasesDispatchPanel({
details,
labCases,
labDependentCodes,
treatmentCatalog,
activeLabCaseId,
onActiveLabCaseChange,
onLabCasesChange,
disabled,
canEdit,
orgs,
organizationSearch,
onOrganizationSearchChange,
recentOrganizationIds,
onRecentOrganizationPick,
sendBusyId,
onAddLabCase,
onSendLabCase,
}: LabCasesDispatchPanelProps) {
const t = useTranslations('treatment');
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
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[];
const labEligibleDetails = useMemo(
() => details.filter((d) => labDependentCodes.has(d.treatmentType)),
[details, labDependentCodes],
);
const canAddLabShipment = useMemo(
() => detailsAvailableForNewShipment(details, labCases, labDependentCodes).length > 0,
[details, labCases, labDependentCodes],
);
const activeLabCase =
labCases.find((lc) => lc.clientId === activeLabCaseId) ?? labCases[0] ?? null;
const sent = Boolean(activeLabCase?.sentAt);
const activeLabOrgName = activeLabCase?.destinationOrganizationId
? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name
: null;
const prosthesisRows = activeLabCase ? prosthesisTeethRows(activeLabCase, details) : [];
const prosthesisComplete = activeLabCase
? isProsthesisMapComplete(activeLabCase, details)
: 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]);
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}`;
}
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
if (!activeLabCase) return;
onLabCasesChange(
labCases.map((lc) => (lc.clientId === activeLabCase.clientId ? { ...lc, ...patch } : lc)),
);
}
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 });
}
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;
}),
);
}
if (labEligibleDetails.length === 0) {
return (
<div className="surface-card p-4 space-y-2">
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
<p className="text-xs text-text-muted">{t('noLabDetails')}</p>
</div>
);
}
const includedInActiveShipment = activeLabCase
? labEligibleDetails.filter((d) => activeLabCase.detailClientIds.includes(d.clientId))
: [];
const pickableForActiveDraft =
activeLabCase && !sent
? selectableDetailsForDraftShipment(details, labCases, labDependentCodes, activeLabCase)
: [];
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>
<p className="text-xs text-text-muted mt-0.5">
{t('labDispatchSubtitle')} {t('labDispatchSendHint')}
</p>
</div>
{canAddLabShipment && (
<Button
type="button"
variant="primary"
disabled={!canEdit || disabled}
onClick={onAddLabCase}
>
{t('addLabShipment')}
</Button>
)}
</div>
{labCases.length === 0 ? (
<p className="text-xs text-text-muted">{t('labDispatchEmpty')}</p>
) : (
<>
<div className="flex flex-wrap gap-2">
{labCases.map((lc, idx) => {
const sentSummary = formatCaseSentSummary(
lc.sends,
{
organizationIds: lc.destinationOrganizationId ? [lc.destinationOrganizationId] : [],
sentAt: lc.sentAt ?? null,
orgs,
},
t,
);
return (
<button
key={lc.clientId}
type="button"
onClick={() => onActiveLabCaseChange(lc.clientId)}
className={`
rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${
lc.clientId === activeLabCase?.clientId
? 'border-primary bg-primary-soft font-medium text-text-primary'
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
}
`}
>
{t('labShipmentLabel', { n: idx + 1 })}
{sentSummary ? ` · ${sentSummary}` : ''}
</button>
);
})}
</div>
{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>
)}
</div>
{activeLabCase.labComment.trim() ? (
<div>
<p className="text-xs font-medium text-text-secondary">{t('labComment')}</p>
<p className="text-sm text-text-primary mt-1 whitespace-pre-wrap">
{activeLabCase.labComment}
</p>
</div>
) : 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>
<label className="block text-xs font-medium text-text-secondary">
{t('labComment')}
<textarea
value={activeLabCase.labComment}
onChange={(e) => updateActiveLabCase({ labComment: e.target.value })}
placeholder={t('labCommentPlaceholder')}
rows={3}
disabled={disabled}
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y"
/>
</label>
<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>
{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}
<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)}
>
{t('sendToLab')}
</Button>
</div>
</>
)}
</div>
)}
</>
)}
</div>
);
}