improvement: lab case dispatch UI improved.

This commit is contained in:
2026-06-28 18:48:06 +03:30
parent 7b8ed48fa0
commit 94b97aa0fa
4 changed files with 247 additions and 121 deletions

View File

@@ -467,6 +467,9 @@
"addLabShipment": "Add lab shipment", "addLabShipment": "Add lab shipment",
"labShipmentLabel": "Shipment {n}", "labShipmentLabel": "Shipment {n}",
"includeDetails": "Include treatment details", "includeDetails": "Include treatment details",
"labShipmentIncludedDetails": "Included in this shipment",
"labShipmentNoIncludedDetails": "No details were included in this shipment.",
"labShipmentNoDetailsAvailable": "All lab details are already in other shipments or have been sent.",
"labDetailLine": "Detail {n} · {type} · {teeth}", "labDetailLine": "Detail {n} · {type} · {teeth}",
"noLabDetails": "No lab-dependent treatment details yet. Add a lab type (e.g. endo) in treatment details above.", "noLabDetails": "No lab-dependent treatment details yet. Add a lab type (e.g. endo) in treatment details above.",
"labDispatchEmpty": "Add a lab shipment to group details and send them to a lab.", "labDispatchEmpty": "Add a lab shipment to group details and send them to a lab.",

View File

@@ -467,6 +467,9 @@
"addLabShipment": "افزودن محموله لاب", "addLabShipment": "افزودن محموله لاب",
"labShipmentLabel": "محموله {n}", "labShipmentLabel": "محموله {n}",
"includeDetails": "شامل جزئیات درمان", "includeDetails": "شامل جزئیات درمان",
"labShipmentIncludedDetails": "شامل این محموله",
"labShipmentNoIncludedDetails": "جزئیاتی در این محموله گنجانده نشده است.",
"labShipmentNoDetailsAvailable": "همه جزئیات لاب در محموله‌های دیگر هستند یا ارسال شده‌اند.",
"labDetailLine": "جزئیات {n} · {type} · {teeth}", "labDetailLine": "جزئیات {n} · {type} · {teeth}",
"noLabDetails": "هنوز جزئیات وابسته به لاب وجود ندارد. نوع لاب (مثلاً اندو) در جزئیات درمان بالا اضافه کنید.", "noLabDetails": "هنوز جزئیات وابسته به لاب وجود ندارد. نوع لاب (مثلاً اندو) در جزئیات درمان بالا اضافه کنید.",
"labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروه‌بندی و ارسال کنید.", "labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروه‌بندی و ارسال کنید.",

View File

@@ -467,6 +467,9 @@
"addLabShipment": "Labzending toevoegen", "addLabShipment": "Labzending toevoegen",
"labShipmentLabel": "Zending {n}", "labShipmentLabel": "Zending {n}",
"includeDetails": "Behandeldetails opnemen", "includeDetails": "Behandeldetails opnemen",
"labShipmentIncludedDetails": "Opgenomen in deze zending",
"labShipmentNoIncludedDetails": "Geen details opgenomen in deze zending.",
"labShipmentNoDetailsAvailable": "Alle labdetails zitten al in andere zendingen of zijn verzonden.",
"labDetailLine": "Detail {n} · {type} · {teeth}", "labDetailLine": "Detail {n} · {type} · {teeth}",
"noLabDetails": "Nog geen lab-afhankelijke details. Voeg een labtype (bijv. endo) toe in de behandeldetails hierboven.", "noLabDetails": "Nog geen lab-afhankelijke details. Voeg een labtype (bijv. endo) toe in de behandeldetails hierboven.",
"labDispatchEmpty": "Voeg een labzending toe om details te groeperen en naar een lab te sturen.", "labDispatchEmpty": "Voeg een labzending toe om details te groeperen en naar een lab te sturen.",

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useMemo } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Checkbox } from '@/components/ui/shared/Checkbox';
@@ -29,6 +30,65 @@ interface LabCasesDispatchPanelProps {
onSendLabCase: (labCase: LabCaseDraft) => 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),
);
}
/** Lab-dependent details not yet sent to any lab. */
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));
}
/** Unsent lab details not already assigned to another draft shipment. */
function detailsAvailableForNewShipment(
details: TreatmentDetailDraft[],
labCases: LabCaseDraft[],
labDependentCodes: Set<string>,
): TreatmentDetailDraft[] {
return unsentLabDetails(details, labCases, labDependentCodes).filter(
(d) => !detailInOtherDraftShipment(d.clientId, labCases, ''),
);
}
/** Details the user can pick for the active draft shipment. */
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);
});
}
export function LabCasesDispatchPanel({ export function LabCasesDispatchPanel({
details, details,
labCases, labCases,
@@ -58,19 +118,37 @@ export function LabCasesDispatchPanel({
.map((id) => activeLinkedOrganizations.find((o) => o.id === id)) .map((id) => activeLinkedOrganizations.find((o) => o.id === id))
.filter(Boolean) as LinkedOrganizationOption[]; .filter(Boolean) as LinkedOrganizationOption[];
const labEligibleDetails = details.filter((d) => labDependentCodes.has(d.treatmentType)); 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 = const activeLabCase =
labCases.find((lc) => lc.clientId === activeLabCaseId) ?? labCases[0] ?? null; labCases.find((lc) => lc.clientId === activeLabCaseId) ?? labCases[0] ?? null;
const sent = Boolean(activeLabCase?.sentAt); const sent = Boolean(activeLabCase?.sentAt);
function detailSummary(d: TreatmentDetailDraft, idx: number) { const activeLabOrgName = activeLabCase?.destinationOrganizationId
? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name
: 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 typeKey = treatmentTypeLabelKey(d.treatmentType); const typeKey = treatmentTypeLabelKey(d.treatmentType);
const typeLabel = const typeLabel =
d.treatmentType in TREATMENT_TYPE_KEYS d.treatmentType in TREATMENT_TYPE_KEYS
? t(typeKey as 'typeEndo') ? t(typeKey as 'typeEndo')
: d.treatmentType; : d.treatmentType;
const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone'); const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
return `${t('detailLabel', { n: idx + 1 })} · ${typeLabel} · ${teeth}`; return `${t('detailLabel', { n: detailNumber(d) })} · ${typeLabel} · ${teeth}`;
} }
function updateActiveLabCase(patch: Partial<LabCaseDraft>) { function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
@@ -105,16 +183,6 @@ export function LabCasesDispatchPanel({
); );
} }
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) { if (labEligibleDetails.length === 0) {
return ( return (
<div className="surface-card p-4 space-y-2"> <div className="surface-card p-4 space-y-2">
@@ -124,6 +192,15 @@ export function LabCasesDispatchPanel({
); );
} }
const includedInActiveShipment = activeLabCase
? labEligibleDetails.filter((d) => activeLabCase.detailClientIds.includes(d.clientId))
: [];
const pickableForActiveDraft =
activeLabCase && !sent
? selectableDetailsForDraftShipment(details, labCases, labDependentCodes, activeLabCase)
: [];
return ( return (
<div className="surface-card p-4 space-y-4"> <div className="surface-card p-4 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
@@ -133,6 +210,7 @@ export function LabCasesDispatchPanel({
{t('labDispatchSubtitle')} {t('labDispatchSendHint')} {t('labDispatchSubtitle')} {t('labDispatchSendHint')}
</p> </p>
</div> </div>
{canAddLabShipment && (
<Button <Button
type="button" type="button"
variant="primary" variant="primary"
@@ -141,6 +219,7 @@ export function LabCasesDispatchPanel({
> >
{t('addLabShipment')} {t('addLabShipment')}
</Button> </Button>
)}
</div> </div>
{labCases.length === 0 ? ( {labCases.length === 0 ? (
@@ -182,29 +261,80 @@ export function LabCasesDispatchPanel({
{activeLabCase && ( {activeLabCase && (
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30"> <div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
{sent ? (
<>
<div> <div>
<p className="text-xs font-medium text-text-secondary mb-2">{t('includeDetails')}</p> <p className="text-xs font-medium text-text-secondary mb-2">
<div className="flex flex-col gap-2"> {t('labShipmentIncludedDetails')}
{labEligibleDetails.map((d, idx) => { </p>
const assignedElsewhere = detailAssignedElsewhere(d.clientId); {includedInActiveShipment.length === 0 ? (
const inSentCase = labCases.some( <p className="text-xs text-text-muted">{t('labShipmentNoIncludedDetails')}</p>
(lc) => lc.sentAt && lc.detailClientIds.includes(d.clientId), ) : (
); <ul className="space-y-1.5">
const checked = activeLabCase.detailClientIds.includes(d.clientId); {includedInActiveShipment.map((d) => (
const itemDisabled = <li
disabled || sent || inSentCase || assignedElsewhere; 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 ( return (
<Checkbox <Checkbox
key={d.clientId} key={d.clientId}
checked={checked || inSentCase} checked={checked}
disabled={itemDisabled} disabled={disabled}
onChange={(next) => toggleDetailInActiveLabCase(d.clientId, next)} onChange={(next) => toggleDetailInActiveLabCase(d.clientId, next)}
label={detailSummary(d, idx)} label={detailSummary(d)}
/> />
); );
})} })}
</div> </div>
)}
</div> </div>
<label className="block text-xs font-medium text-text-secondary"> <label className="block text-xs font-medium text-text-secondary">
@@ -214,7 +344,7 @@ export function LabCasesDispatchPanel({
onChange={(e) => updateActiveLabCase({ labComment: e.target.value })} onChange={(e) => updateActiveLabCase({ labComment: e.target.value })}
placeholder={t('labCommentPlaceholder')} placeholder={t('labCommentPlaceholder')}
rows={3} rows={3}
disabled={disabled || sent} 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" 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> </label>
@@ -234,7 +364,7 @@ export function LabCasesDispatchPanel({
<button <button
key={o.id} key={o.id}
type="button" type="button"
disabled={disabled || sent} disabled={disabled}
onClick={() => onRecentOrganizationPick(o.id)} 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" 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"
> >
@@ -250,7 +380,7 @@ export function LabCasesDispatchPanel({
destinationOrganizationId: e.target.value || null, destinationOrganizationId: e.target.value || null,
}) })
} }
disabled={disabled || sent || filteredOrganizations.length === 0} disabled={disabled || filteredOrganizations.length === 0}
> >
<option value="">{t('selectLabPlaceholder')}</option> <option value="">{t('selectLabPlaceholder')}</option>
{filteredOrganizations.map((o) => ( {filteredOrganizations.map((o) => (
@@ -270,7 +400,6 @@ export function LabCasesDispatchPanel({
variant="primary" variant="primary"
disabled={ disabled={
disabled || disabled ||
sent ||
sendBusyId === activeLabCase.clientId || sendBusyId === activeLabCase.clientId ||
!activeLabCase.destinationOrganizationId || !activeLabCase.destinationOrganizationId ||
activeLabCase.detailClientIds.length === 0 activeLabCase.detailClientIds.length === 0
@@ -280,23 +409,11 @@ export function LabCasesDispatchPanel({
> >
{t('sendToLab')} {t('sendToLab')}
</Button> </Button>
{sent && ( </div>
<CaseSentLabel </>
treatmentCase={{
destinationOrganizationId: activeLabCase.destinationOrganizationId,
sendToOrganizationIds: activeLabCase.destinationOrganizationId
? [activeLabCase.destinationOrganizationId]
: [],
sentAt: activeLabCase.sentAt ?? null,
sends: activeLabCase.sends,
}}
orgs={orgs}
/>
)} )}
</div> </div>
</div>
)} )}
</> </>
)} )}
</div> </div>