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:
2026-07-07 17:14:31 +03:30
parent cb63ced4e3
commit b2d40b3e97
21 changed files with 467 additions and 269 deletions

View File

@@ -3,13 +3,17 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { MessageSquare } from 'lucide-react';
import { ToastStack } from '@/components/ui/shared/Toast';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { canEditCases } from '@/components/shared/permissions';
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { canEditCases, canEditTasks } from '@/components/shared/permissions';
import { Badge } from '@/components/ui/shared/Badge';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
import { casesApi } from '@/lib/api/cases';
import { tasksApi } from '@/lib/api/tasks';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { Button } from '@/components/ui/shared/Button';
@@ -30,17 +34,6 @@ import {
const PAGE_SIZE = 20;
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
switch (status) {
case 'COMPLETED':
return 'success';
case 'IN_PROGRESS':
return 'default';
default:
return 'warning';
}
}
function formatPatientName(patient: { firstName: string; lastName: string }) {
return `${patient.firstName} ${patient.lastName}`.trim();
}
@@ -105,8 +98,10 @@ export default function CasesPage() {
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
const [commentCount, setCommentCount] = useState(0);
const canEdit = canEditCases(currentOrganization);
const canEditComments = canEditTasks(currentOrganization);
const locale = user?.language ?? 'en';
const treatmentLabel = useCallback(
@@ -200,12 +195,21 @@ export default function CasesPage() {
useEffect(() => {
if (selectedCaseId) {
void loadDetail(selectedCaseId);
void tasksApi
.listComments(selectedCaseId)
.then((r) => setCommentCount(r.data.length))
.catch(() => setCommentCount(0));
} else {
setSelectedCase(null);
setCommentCount(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes
}, [selectedCaseId]);
function scrollToComments() {
document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' });
}
function clearFilters() {
setSearch('');
setClinicId('');
@@ -408,9 +412,17 @@ export default function CasesPage() {
) : (
<div className="space-y-4">
<header className="space-y-1 border-b border-border pb-3">
<h2 className="text-lg font-semibold text-text-primary">
{formatPatientName(selectedCase.patient)}
</h2>
<div className="flex flex-wrap items-start justify-between gap-2">
<h2 className="text-lg font-semibold text-text-primary">
{formatPatientName(selectedCase.patient)}
</h2>
<Button type="button" variant="outline" size="sm" onClick={scrollToComments}>
<MessageSquare className="h-4 w-4 me-1.5" />
{commentCount > 0
? t('commentsCount', { count: commentCount })
: t('showComments')}
</Button>
</div>
<p className="text-sm text-text-muted">
{t('patientMobile')}: {selectedCase.patient.mobile}
</p>
@@ -432,12 +444,6 @@ export default function CasesPage() {
total={selectedCase.taskProgress.total}
/>
</div>
{selectedCase.labComment ? (
<p className="text-sm text-text-muted pt-1">
<span className="font-medium text-text-primary">{t('labComment')}:</span>{' '}
{selectedCase.labComment}
</p>
) : null}
</header>
{selectedCase.details.length > 0 && (
@@ -493,7 +499,7 @@ export default function CasesPage() {
<span className="min-w-0 flex-1">
{task.stepOrder}. {task.stepLabel}
</span>
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
</Badge>
@@ -530,6 +536,34 @@ export default function CasesPage() {
))
)}
</div>
{selectedCaseId ? (
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
<LabCaseCommentsPanel
caseId={selectedCaseId}
canPost={canEditComments}
canToggleVisibility={canEditComments}
loadComments={async () => {
const r = await tasksApi.listComments(selectedCaseId);
setCommentCount(r.data.length);
return r.data;
}}
onPost={async (body, visibleToClinic) => {
const r = await tasksApi.addComment(selectedCaseId, {
body,
visibleToClinic,
});
setCommentCount((n) => n + 1);
return r.data;
}}
onToggleVisibility={async (commentId, visible) => {
const r = await tasksApi.setCommentVisibility(commentId, visible);
return r.data;
}}
onError={toast.showError}
/>
</section>
) : null}
</div>
)}
</section>

View File

@@ -4,12 +4,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { MessageSquare } from 'lucide-react';
import { ToastStack } from '@/components/ui/shared/Toast';
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { Badge } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import {
labTaskStatusSelectClass,
labTaskStatusVariant,
} from '@/components/ui/lab/labTaskStatusDisplay';
import {
formatToothList,
prosthesisTypeBadgeStyle,
@@ -19,8 +22,6 @@ import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { tasksApi } from '@/lib/api/tasks';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import type {
LabTaskListItem,
LabTaskStatus,
@@ -28,14 +29,9 @@ import type {
PaginatedLabTasks,
TaskSortField,
} from '@/types/cases';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const PAGE_SIZE = 50;
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
return status === 'COMPLETED' ? 'success' : 'default';
}
function formatPatientName(patient: { firstName: string; lastName: string }) {
return `${patient.firstName} ${patient.lastName}`.trim();
}
@@ -55,7 +51,6 @@ export default function TasksPage() {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const [expandedCommentsCaseId, setExpandedCommentsCaseId] = useState<string | null>(null);
const [search, setSearch] = useState('');
@@ -127,10 +122,6 @@ export default function TasksPage() {
}
}, [listParams, showError, setError]);
useEffect(() => {
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
}, []);
useEffect(() => {
if (!canView) return;
const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0);
@@ -191,7 +182,7 @@ export default function TasksPage() {
}}
placeholder={t('searchPlaceholder')}
/>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<label className="space-y-1">
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
<select
@@ -230,28 +221,28 @@ export default function TasksPage() {
</label>
<label className="space-y-1">
<span className="text-xs text-text-muted">{t('sortBy')}</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as TaskSortField)}
className={filterSelectClass}
>
<option value="date">{t('sortDate')}</option>
<option value="status">{t('sortStatus')}</option>
<option value="clinic">{t('sortClinic')}</option>
<option value="patient">{t('sortPatient')}</option>
<option value="important">{t('sortImportant')}</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-text-muted"> </span>
<select
value={sortDir}
onChange={(e) => setSortDir(e.target.value as 'asc' | 'desc')}
className={filterSelectClass}
>
<option value="desc"></option>
<option value="asc"></option>
</select>
<div className="flex gap-1.5">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as TaskSortField)}
className={`${filterSelectClass} min-w-0 flex-1`}
>
<option value="date">{t('sortDate')}</option>
<option value="status">{t('sortStatus')}</option>
<option value="clinic">{t('sortClinic')}</option>
<option value="patient">{t('sortPatient')}</option>
<option value="important">{t('sortImportant')}</option>
</select>
<select
value={sortDir}
onChange={(e) => setSortDir(e.target.value as 'asc' | 'desc')}
className={`${FORM_SELECT_CLASS} w-14 shrink-0 rounded-md px-2 py-1.5 text-sm`}
aria-label={t('sortDirection')}
>
<option value="desc"></option>
<option value="asc"></option>
</select>
</div>
</label>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm">
@@ -303,12 +294,6 @@ export default function TasksPage() {
{t('importantBadge')}
</Badge>
) : null}
<span
className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border"
style={prosthesisTypeBadgeStyle(task.prosthesisTypeCode, index)}
>
{task.prosthesisTypeLabel}
</span>
</div>
<p className="text-[11px] text-text-secondary truncate">
{t('fromClinic', { name: task.clinic.name })} ·{' '}
@@ -336,7 +321,7 @@ export default function TasksPage() {
onChange={(e) =>
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
}
className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`}
className={`${FORM_SELECT_CLASS} w-full max-w-[132px] ${labTaskStatusSelectClass(task.status)}`}
>
{statusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -345,7 +330,7 @@ export default function TasksPage() {
))}
</select>
) : (
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
</Badge>
@@ -369,10 +354,12 @@ export default function TasksPage() {
<MessageSquare className="h-4 w-4" />
</button>
) : null}
<TreatmentTypeBadge
type={task.treatmentType}
label={treatmentTypeLabelFromCatalog(task.treatmentType, treatmentCatalog)}
/>
<span
className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium border"
style={prosthesisTypeBadgeStyle(task.prosthesisTypeCode, index)}
>
{task.prosthesisTypeLabel}
</span>
</div>
</div>

View File

@@ -97,11 +97,13 @@ export function LabCaseCommentsPanel({
{comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')}
{comment.authorName ? ` · ${comment.authorName}` : ''}
</span>
{comment.visibleToClinic ? (
<span className="text-primary">{t('clinicCanSee')}</span>
) : (
<span>{t('hiddenFromClinic')}</span>
)}
{comment.showVisibilityStatus !== false ? (
comment.visibleToClinic ? (
<span className="text-primary">{t('clinicCanSee')}</span>
) : (
<span>{t('hiddenFromClinic')}</span>
)
) : null}
</div>
<p className="mt-1 text-text-primary whitespace-pre-wrap">{comment.body}</p>
</div>

View File

@@ -0,0 +1,17 @@
import type { BadgeVariant } from '@/components/ui/shared/Badge';
import type { LabTaskStatus } from '@/types/cases';
export function labTaskStatusVariant(status: LabTaskStatus): BadgeVariant {
return status === 'COMPLETED' ? 'success' : 'default';
}
export function labTaskStatusSelectClass(status: LabTaskStatus): string {
switch (status) {
case 'COMPLETED':
return 'border-success/60 text-success';
case 'IN_PROGRESS':
return 'border-primary/60 text-primary';
default:
return '';
}
}

View File

@@ -2,17 +2,19 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { MessageSquare } from 'lucide-react';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { organizationApi } from '@/lib/api/organization';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { Badge } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { ToastStack } from '@/components/ui/shared/Toast';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
import {
formatToothList,
prosthesisTypeBadgeStyle,
@@ -23,17 +25,6 @@ import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const PAGE_SIZE = 20;
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
switch (status) {
case 'COMPLETED':
return 'success';
case 'IN_PROGRESS':
return 'default';
default:
return 'warning';
}
}
function formatPatientName(patient: { firstName: string; lastName: string }) {
return `${patient.firstName} ${patient.lastName}`.trim();
}
@@ -96,6 +87,7 @@ export function ConnectionCaseHistoryContent({
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
const [commentCount, setCommentCount] = useState(0);
const locale = user?.language ?? 'en';
const isClinic = currentOrganization?.type === 'CLINIC';
@@ -154,11 +146,21 @@ export function ConnectionCaseHistoryContent({
useEffect(() => {
if (!selectedCaseId) {
setSelectedCase(null);
setCommentCount(0);
return;
}
let cancelled = false;
void organizationApi
.listConnectionCaseComments(connection.id, selectedCaseId)
.then((r) => {
if (!cancelled) setCommentCount(r.data.length);
})
.catch(() => {
if (!cancelled) setCommentCount(0);
});
void (async () => {
setLoadingDetail(true);
setError('');
@@ -180,6 +182,10 @@ export function ConnectionCaseHistoryContent({
};
}, [selectedCaseId, connection.id, showError, setError]);
function scrollToComments() {
document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' });
}
return (
<div className="space-y-6">
<div>
@@ -300,9 +306,19 @@ export function ConnectionCaseHistoryContent({
) : (
<div className="space-y-4">
<header className="space-y-1 border-b border-border pb-3">
<h2 className="text-lg font-semibold text-text-primary">
{formatPatientName(selectedCase.patient)}
</h2>
<div className="flex flex-wrap items-start justify-between gap-2">
<h2 className="text-lg font-semibold text-text-primary">
{formatPatientName(selectedCase.patient)}
</h2>
{isClinic ? (
<Button type="button" variant="outline" size="sm" onClick={scrollToComments}>
<MessageSquare className="h-4 w-4 me-1.5" />
{commentCount > 0
? tCases('commentsCount', { count: commentCount })
: tCases('showComments')}
</Button>
) : null}
</div>
<p className="text-sm text-text-muted">
{tCases('patientMobile')}: {selectedCase.patient.mobile}
</p>
@@ -330,12 +346,6 @@ export function ConnectionCaseHistoryContent({
total={selectedCase.taskProgress.total}
/>
</div>
{selectedCase.labComment ? (
<p className="text-sm text-text-muted pt-1">
<span className="font-medium text-text-primary">{tCases('labComment')}:</span>{' '}
{selectedCase.labComment}
</p>
) : null}
</header>
{selectedCase.details.length > 0 && (
@@ -395,7 +405,7 @@ export function ConnectionCaseHistoryContent({
<span className="min-w-0 flex-1">
{task.stepOrder}. {task.stepLabel}
</span>
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
</Badge>
@@ -413,27 +423,31 @@ export function ConnectionCaseHistoryContent({
</div>
{isClinic && selectedCaseId ? (
<LabCaseCommentsPanel
caseId={selectedCaseId}
canPost
canToggleVisibility={false}
loadComments={async () => {
const r = await organizationApi.listConnectionCaseComments(
connection.id,
selectedCaseId,
);
return r.data;
}}
onPost={async (body) => {
const r = await organizationApi.addConnectionCaseComment(
connection.id,
selectedCaseId,
body,
);
return r.data;
}}
onError={showError}
/>
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
<LabCaseCommentsPanel
caseId={selectedCaseId}
canPost
canToggleVisibility={false}
loadComments={async () => {
const r = await organizationApi.listConnectionCaseComments(
connection.id,
selectedCaseId,
);
setCommentCount(r.data.length);
return r.data;
}}
onPost={async (body) => {
const r = await organizationApi.addConnectionCaseComment(
connection.id,
selectedCaseId,
body,
);
setCommentCount((n) => n + 1);
return r.data;
}}
onError={showError}
/>
</section>
) : null}
</div>
)}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -1,4 +1,5 @@
import { apiClient } from './client';
import type { LabCaseComment } from '@/types/cases';
import type {
LabCaseResponse,
LinkedOrganizationOption,
@@ -82,6 +83,21 @@ export const treatmentsApi = {
return response.data;
},
listLabCaseComments: async (
labCaseId: string,
): Promise<{ success: boolean; data: LabCaseComment[] }> => {
const response = await apiClient.get(`/treatments/lab-cases/${labCaseId}/comments`);
return response.data;
},
addLabCaseComment: async (
labCaseId: string,
payload: { body: string },
): Promise<{ success: boolean; data: LabCaseComment }> => {
const response = await apiClient.post(`/treatments/lab-cases/${labCaseId}/comments`, payload);
return response.data;
},
getAttachmentFileBlob: async (attachmentId: string): Promise<Blob> => {
const response = await apiClient.get(`/treatments/attachments/${attachmentId}/file`, {
responseType: 'blob',

View File

@@ -63,12 +63,12 @@ export interface LabCaseComment {
visibleToClinic: boolean;
createdAt: string;
canToggleVisibility: boolean;
showVisibilityStatus?: boolean;
}
export interface LabCaseDetail {
id: string;
sentAt: string | null;
labComment: string | null;
clinic: { id: string; name: string };
patient: {
id: string;

View File

@@ -89,7 +89,6 @@ export interface PastLabCase {
id: string;
clientId: string;
destinationOrganizationId: string | null;
labComment?: string | null;
sentAt?: string | null;
treatmentDetailIds: string[];
details: Array<{
@@ -143,7 +142,6 @@ export interface LabCaseDraft {
clientId: string;
id?: string;
destinationOrganizationId: string | null;
labComment: string;
detailClientIds: string[];
toothProsthesis: LabCaseToothProsthesisDraft[];
sentAt?: string | null;
@@ -166,7 +164,6 @@ export interface SaveLabCasePayload {
clientId: string;
id?: string;
destinationOrganizationId?: string;
labComment?: string;
treatmentDetailIds: string[];
toothProsthesis?: Array<{
treatmentDetailId: string;
@@ -185,7 +182,6 @@ export interface LabCaseResponse {
id: string;
clientId: string;
destinationOrganizationId: string | null;
labComment: string | null;
sentAt: string | null;
treatmentDetailIds: string[];
details: Array<{