2026-06-28 17:14:02 +03:30
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
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 { SearchBar } from '@/components/ui/shared/SearchBar';
|
|
|
|
|
import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel';
|
|
|
|
|
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
|
|
|
|
import { TREATMENT_TYPE_KEYS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
|
|
|
|
|
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
|
|
|
|
|
|
|
|
|
|
interface LabCasesDispatchPanelProps {
|
|
|
|
|
details: TreatmentDetailDraft[];
|
|
|
|
|
labCases: LabCaseDraft[];
|
|
|
|
|
labDependentCodes: Set<string>;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function LabCasesDispatchPanel({
|
|
|
|
|
details,
|
|
|
|
|
labCases,
|
|
|
|
|
labDependentCodes,
|
|
|
|
|
activeLabCaseId,
|
|
|
|
|
onActiveLabCaseChange,
|
|
|
|
|
onLabCasesChange,
|
|
|
|
|
disabled,
|
|
|
|
|
canEdit,
|
|
|
|
|
orgs,
|
|
|
|
|
organizationSearch,
|
|
|
|
|
onOrganizationSearchChange,
|
|
|
|
|
recentOrganizationIds,
|
|
|
|
|
onRecentOrganizationPick,
|
|
|
|
|
sendBusyId,
|
|
|
|
|
onAddLabCase,
|
|
|
|
|
onSendLabCase,
|
|
|
|
|
}: LabCasesDispatchPanelProps) {
|
|
|
|
|
const t = useTranslations('treatment');
|
|
|
|
|
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 = details.filter((d) => labDependentCodes.has(d.treatmentType));
|
|
|
|
|
const activeLabCase =
|
|
|
|
|
labCases.find((lc) => lc.clientId === activeLabCaseId) ?? labCases[0] ?? null;
|
|
|
|
|
const sent = Boolean(activeLabCase?.sentAt);
|
|
|
|
|
|
|
|
|
|
function detailSummary(d: TreatmentDetailDraft, idx: number) {
|
|
|
|
|
const typeKey = treatmentTypeLabelKey(d.treatmentType);
|
|
|
|
|
const typeLabel =
|
|
|
|
|
d.treatmentType in TREATMENT_TYPE_KEYS
|
|
|
|
|
? t(typeKey as 'typeEndo')
|
|
|
|
|
: d.treatmentType;
|
|
|
|
|
const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
|
|
|
|
|
return `${t('detailLabel', { n: idx + 1 })} · ${typeLabel} · ${teeth}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
|
|
|
|
|
if (!activeLabCase) return;
|
|
|
|
|
onLabCasesChange(
|
|
|
|
|
labCases.map((lc) => (lc.clientId === activeLabCase.clientId ? { ...lc, ...patch } : lc)),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
return { ...lc, detailClientIds: [...set] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (checked) {
|
|
|
|
|
return {
|
|
|
|
|
...lc,
|
|
|
|
|
detailClientIds: lc.detailClientIds.filter((id) => id !== detailClientId),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return lc;
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function detailAssignedElsewhere(detailClientId: string): boolean {
|
|
|
|
|
if (!activeLabCase) return false;
|
|
|
|
|
return labCases.some(
|
|
|
|
|
(lc) =>
|
|
|
|
|
!lc.sentAt &&
|
|
|
|
|
lc.clientId !== activeLabCase.clientId &&
|
|
|
|
|
lc.detailClientIds.includes(detailClientId),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
<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">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-xs font-medium text-text-secondary mb-2">{t('includeDetails')}</p>
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
{labEligibleDetails.map((d, idx) => {
|
|
|
|
|
const assignedElsewhere = detailAssignedElsewhere(d.clientId);
|
|
|
|
|
const inSentCase = labCases.some(
|
|
|
|
|
(lc) => lc.sentAt && lc.detailClientIds.includes(d.clientId),
|
|
|
|
|
);
|
|
|
|
|
const checked = activeLabCase.detailClientIds.includes(d.clientId);
|
|
|
|
|
const itemDisabled =
|
|
|
|
|
disabled || sent || inSentCase || assignedElsewhere;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Checkbox
|
|
|
|
|
key={d.clientId}
|
|
|
|
|
checked={checked || inSentCase}
|
|
|
|
|
disabled={itemDisabled}
|
|
|
|
|
onChange={(next) => toggleDetailInActiveLabCase(d.clientId, next)}
|
|
|
|
|
label={detailSummary(d, idx)}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</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 || sent}
|
|
|
|
|
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 || sent}
|
|
|
|
|
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) =>
|
|
|
|
|
updateActiveLabCase({
|
|
|
|
|
destinationOrganizationId: e.target.value || null,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
disabled={disabled || sent || 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>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-3 pt-1">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="primary"
|
|
|
|
|
disabled={
|
|
|
|
|
disabled ||
|
|
|
|
|
sent ||
|
|
|
|
|
sendBusyId === activeLabCase.clientId ||
|
|
|
|
|
!activeLabCase.destinationOrganizationId ||
|
|
|
|
|
activeLabCase.detailClientIds.length === 0
|
|
|
|
|
}
|
|
|
|
|
isLoading={sendBusyId === activeLabCase.clientId}
|
|
|
|
|
onClick={() => onSendLabCase(activeLabCase)}
|
|
|
|
|
>
|
|
|
|
|
{t('sendToLab')}
|
|
|
|
|
</Button>
|
|
|
|
|
{sent && (
|
|
|
|
|
<CaseSentLabel
|
|
|
|
|
treatmentCase={{
|
|
|
|
|
destinationOrganizationId: activeLabCase.destinationOrganizationId,
|
|
|
|
|
sendToOrganizationIds: activeLabCase.destinationOrganizationId
|
|
|
|
|
? [activeLabCase.destinationOrganizationId]
|
|
|
|
|
: [],
|
|
|
|
|
sentAt: activeLabCase.sentAt ?? null,
|
|
|
|
|
sends: activeLabCase.sends,
|
|
|
|
|
}}
|
|
|
|
|
orgs={orgs}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|