improvement: users can now comment on a case and it's details and have an option to make it visible for clinics too.
This commit is contained in:
@@ -1,21 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, 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 { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||
import { treatmentsApi } from '@/lib/api/treatments';
|
||||
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[];
|
||||
activeDetailId: string;
|
||||
labCases: LabCaseDraft[];
|
||||
labDependentCodes: Set<string>;
|
||||
treatmentCatalog: TreatmentCatalogEntry[];
|
||||
@@ -32,6 +34,7 @@ interface LabCasesDispatchPanelProps {
|
||||
sendBusyId: string | null;
|
||||
onAddLabCase: () => void;
|
||||
onSendLabCase: (labCase: LabCaseDraft) => void;
|
||||
onCommentError?: (message: string) => void;
|
||||
}
|
||||
|
||||
function sentDetailClientIds(labCases: LabCaseDraft[]): Set<string> {
|
||||
@@ -56,25 +59,6 @@ function detailInOtherDraftShipment(
|
||||
);
|
||||
}
|
||||
|
||||
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[],
|
||||
@@ -93,9 +77,11 @@ function selectableDetailsForDraftShipment(
|
||||
function prosthesisTeethRows(
|
||||
labCase: LabCaseDraft,
|
||||
details: TreatmentDetailDraft[],
|
||||
scopeDetailClientId?: string,
|
||||
): 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;
|
||||
@@ -106,8 +92,12 @@ function prosthesisTeethRows(
|
||||
return rows;
|
||||
}
|
||||
|
||||
function isProsthesisMapComplete(labCase: LabCaseDraft, details: TreatmentDetailDraft[]): boolean {
|
||||
const rows = prosthesisTeethRows(labCase, details);
|
||||
function isProsthesisMapComplete(
|
||||
labCase: LabCaseDraft,
|
||||
details: TreatmentDetailDraft[],
|
||||
scopeDetailClientId?: string,
|
||||
): boolean {
|
||||
const rows = prosthesisTeethRows(labCase, details, scopeDetailClientId);
|
||||
if (rows.length === 0) return true;
|
||||
return rows.every((row) =>
|
||||
labCase.toothProsthesis.some(
|
||||
@@ -121,6 +111,7 @@ function isProsthesisMapComplete(labCase: LabCaseDraft, details: TreatmentDetail
|
||||
|
||||
export function LabCasesDispatchPanel({
|
||||
details,
|
||||
activeDetailId,
|
||||
labCases,
|
||||
labDependentCodes,
|
||||
treatmentCatalog,
|
||||
@@ -137,6 +128,7 @@ export function LabCasesDispatchPanel({
|
||||
sendBusyId,
|
||||
onAddLabCase,
|
||||
onSendLabCase,
|
||||
onCommentError,
|
||||
}: LabCasesDispatchPanelProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
|
||||
@@ -152,27 +144,35 @@ export function LabCasesDispatchPanel({
|
||||
.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 activeDetail = details.find((d) => d.clientId === activeDetailId) ?? null;
|
||||
const isLabDependentDetail = Boolean(
|
||||
activeDetail && labDependentCodes.has(activeDetail.treatmentType),
|
||||
);
|
||||
|
||||
const canAddLabShipment = useMemo(
|
||||
() => detailsAvailableForNewShipment(details, labCases, labDependentCodes).length > 0,
|
||||
[details, labCases, labDependentCodes],
|
||||
);
|
||||
const labCaseForActiveDetail =
|
||||
labCases.find((lc) => lc.detailClientIds.includes(activeDetailId)) ?? null;
|
||||
|
||||
const activeLabCase =
|
||||
labCases.find((lc) => lc.clientId === activeLabCaseId) ?? labCases[0] ?? null;
|
||||
labCaseForActiveDetail ??
|
||||
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
|
||||
|
||||
const detailAlreadyInShipment = Boolean(labCaseForActiveDetail);
|
||||
const canAddLabShipment =
|
||||
!detailAlreadyInShipment &&
|
||||
!detailInOtherDraftShipment(activeDetailId, labCases, '') &&
|
||||
!sentDetailClientIds(labCases).has(activeDetailId);
|
||||
|
||||
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 prosthesisRows = activeLabCase
|
||||
? prosthesisTeethRows(activeLabCase, details, activeDetailId)
|
||||
: [];
|
||||
const prosthesisComplete = activeLabCase
|
||||
? isProsthesisMapComplete(activeLabCase, details)
|
||||
? isProsthesisMapComplete(activeLabCase, details, activeDetailId)
|
||||
: true;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -196,6 +196,11 @@ export function LabCasesDispatchPanel({
|
||||
};
|
||||
}, [activeLabCase?.destinationOrganizationId]);
|
||||
|
||||
// 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;
|
||||
@@ -269,22 +274,15 @@ export function LabCasesDispatchPanel({
|
||||
);
|
||||
}
|
||||
|
||||
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))
|
||||
? [activeDetail]
|
||||
: [];
|
||||
|
||||
const pickableForActiveDraft =
|
||||
activeLabCase && !sent
|
||||
? selectableDetailsForDraftShipment(details, labCases, labDependentCodes, activeLabCase)
|
||||
? selectableDetailsForDraftShipment(details, labCases, labDependentCodes, activeLabCase).filter(
|
||||
(d) => d.clientId === activeDetailId,
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
@@ -308,45 +306,10 @@ export function LabCasesDispatchPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{labCases.length === 0 ? (
|
||||
{!detailAlreadyInShipment ? (
|
||||
<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">
|
||||
) : activeLabCase ? (
|
||||
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
|
||||
{sent ? (
|
||||
<>
|
||||
<div>
|
||||
@@ -369,13 +332,20 @@ export function LabCasesDispatchPanel({
|
||||
)}
|
||||
</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>
|
||||
{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 ? (
|
||||
@@ -423,17 +393,22 @@ export function LabCasesDispatchPanel({
|
||||
)}
|
||||
</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"
|
||||
{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}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
|
||||
@@ -581,9 +556,7 @@ export function LabCasesDispatchPanel({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ function labCaseDraftsToPast(
|
||||
id: lc.id ?? lc.clientId,
|
||||
clientId: lc.clientId,
|
||||
destinationOrganizationId: lc.destinationOrganizationId,
|
||||
labComment: lc.labComment || null,
|
||||
sentAt: lc.sentAt ?? null,
|
||||
treatmentDetailIds: lc.detailClientIds
|
||||
.map((cid) => details.find((d) => d.clientId === cid)?.id)
|
||||
@@ -132,7 +131,6 @@ function newLabCaseDraft(): LabCaseDraft {
|
||||
? crypto.randomUUID()
|
||||
: `lab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
destinationOrganizationId: null,
|
||||
labComment: '',
|
||||
detailClientIds: [],
|
||||
toothProsthesis: [],
|
||||
sentAt: null,
|
||||
@@ -175,7 +173,6 @@ function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
|
||||
clientId: lc.clientId,
|
||||
id: lc.id,
|
||||
destinationOrganizationId: lc.destinationOrganizationId,
|
||||
labComment: lc.labComment ?? '',
|
||||
detailClientIds: lc.details.map((d) => d.clientId),
|
||||
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
|
||||
detailClientId:
|
||||
@@ -391,6 +388,12 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
|
||||
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
|
||||
|
||||
// Sync active lab shipment when the selected treatment detail changes.
|
||||
useEffect(() => {
|
||||
const match = labCaseDrafts.find((lc) => lc.detailClientIds.includes(activeDetailId));
|
||||
setActiveLabCaseId(match?.clientId ?? null);
|
||||
}, [activeDetailId, labCaseDrafts]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectionLocked(false);
|
||||
}, [selectedDay]);
|
||||
@@ -788,18 +791,18 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
);
|
||||
|
||||
const persistLabCases = useCallback(
|
||||
async (savedTreatment: PastTreatment) => {
|
||||
async (savedTreatment: PastTreatment, draftsOverride?: LabCaseDraft[]) => {
|
||||
if (!selectedAppointment) throw new Error('No appointment selected');
|
||||
|
||||
const drafts = draftsOverride ?? labCaseDrafts;
|
||||
const detailIdByClientId = new Map(
|
||||
savedTreatment.details.map((d) => [d.clientId, d.id]),
|
||||
);
|
||||
|
||||
const payload = labCaseDrafts.map((lc) => ({
|
||||
const payload = drafts.map((lc) => ({
|
||||
clientId: lc.clientId,
|
||||
id: lc.id,
|
||||
destinationOrganizationId: lc.destinationOrganizationId ?? undefined,
|
||||
labComment: lc.labComment.trim() || undefined,
|
||||
treatmentDetailIds: lc.detailClientIds
|
||||
.map((clientId) => detailIdByClientId.get(clientId))
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
@@ -834,6 +837,40 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
[labCaseDrafts, selectedAppointment],
|
||||
);
|
||||
|
||||
const handleAddLabCase = useCallback(async () => {
|
||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||
|
||||
const activeDetail = details.find((d) => d.clientId === activeDetailId);
|
||||
const next: LabCaseDraft = {
|
||||
...newLabCaseDraft(),
|
||||
detailClientIds:
|
||||
activeDetail && labDependentCodes.has(activeDetail.treatmentType)
|
||||
? [activeDetailId]
|
||||
: [],
|
||||
};
|
||||
const updatedLabCases = [...labCaseDrafts, next];
|
||||
setLabCaseDrafts(updatedLabCases);
|
||||
setActiveLabCaseId(next.clientId);
|
||||
|
||||
try {
|
||||
const saved = await persistDraft({ force: true });
|
||||
await persistLabCases(saved, updatedLabCases);
|
||||
} catch (error: unknown) {
|
||||
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
|
||||
}
|
||||
}, [
|
||||
activeDetailId,
|
||||
canEditTreatmentForDay,
|
||||
details,
|
||||
labCaseDrafts,
|
||||
labDependentCodes,
|
||||
persistDraft,
|
||||
persistLabCases,
|
||||
selectedAppointment,
|
||||
showError,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleSendLabCase = useCallback(
|
||||
async (labCase: LabCaseDraft) => {
|
||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||
@@ -1041,6 +1078,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
|
||||
<LabCasesDispatchPanel
|
||||
details={details}
|
||||
activeDetailId={activeDetailId}
|
||||
labCases={labCaseDrafts}
|
||||
labDependentCodes={labDependentCodes}
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
@@ -1064,12 +1102,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
);
|
||||
}}
|
||||
sendBusyId={sendBusyId}
|
||||
onAddLabCase={() => {
|
||||
const next = newLabCaseDraft();
|
||||
setLabCaseDrafts((prev) => [...prev, next]);
|
||||
setActiveLabCaseId(next.clientId);
|
||||
}}
|
||||
onAddLabCase={() => void handleAddLabCase()}
|
||||
onSendLabCase={(lc) => void handleSendLabCase(lc)}
|
||||
onCommentError={showError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user