improvement: treatment UX fully overhauled.

This commit is contained in:
2026-07-13 01:03:26 +03:30
parent abf0371a5b
commit f28cd06615
27 changed files with 1370 additions and 212 deletions

View File

@@ -0,0 +1,46 @@
'use client';
import { useTranslations } from 'next-intl';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { treatmentsApi } from '@/lib/api/treatments';
import type { TreatmentDetailDraft } from '@/types/treatment';
interface DetailLabCaseCommentsSectionProps {
detail: TreatmentDetailDraft;
canPost: boolean;
onError?: (message: string) => void;
}
export function DetailLabCaseCommentsSection({
detail,
canPost,
onError,
}: DetailLabCaseCommentsSectionProps) {
const t = useTranslations('treatment');
const caseId = detail.labCaseId;
if (!caseId) return null;
return (
<div className="space-y-2 border-t border-border/60 pt-4">
<div>
<p className="text-xs font-medium text-text-secondary">{t('labCaseCommentsTitle')}</p>
<p className="text-[11px] text-text-muted mt-0.5">{t('labCaseCommentsHint')}</p>
</div>
<LabCaseCommentsPanel
caseId={caseId}
canPost={canPost}
canToggleVisibility={false}
loadComments={async () => {
const response = await treatmentsApi.listLabCaseComments(caseId);
return response.data;
}}
onPost={async (body) => {
const response = await treatmentsApi.addLabCaseComment(caseId, { body });
return response.data;
}}
onError={onError}
/>
</div>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { isDetailTypeSelected } from '@/components/treatment/treatmentDetailRules';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
import { labNotSentBadgeClass, labSentBadgeClass } from '@/components/treatment/treatmentStatusStyles';
import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment';
@@ -23,7 +24,7 @@ export function DetailLabSendBadge({
}: DetailLabSendBadgeProps) {
const t = useTranslations('treatment');
if (!labDependentCodes.has(detail.treatmentType)) {
if (!isDetailTypeSelected(detail) || !labDependentCodes.has(detail.treatmentType)) {
return null;
}

View File

@@ -4,9 +4,9 @@ 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 { isDetailReadyForLabDispatch } from '@/components/treatment/treatmentDetailRules';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { LinkedOrganizationSearchCombobox } from '@/components/ui/treatment/LinkedOrganizationSearchCombobox';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
@@ -30,6 +30,8 @@ interface LabCasesDispatchPanelProps {
onOrganizationSearchChange: (value: string) => void;
recentOrganizationIds: string[];
onRecentOrganizationPick: (orgId: string) => void;
canInviteLab?: boolean;
onInviteLab?: () => void;
sendBusyId: string | null;
onAddLabCase: () => void;
onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void;
@@ -89,6 +91,8 @@ export function LabCasesDispatchPanel({
onOrganizationSearchChange,
recentOrganizationIds,
onRecentOrganizationPick,
canInviteLab = false,
onInviteLab,
sendBusyId,
onAddLabCase,
onSendLabCase,
@@ -100,18 +104,13 @@ export function LabCasesDispatchPanel({
const [pendingComment, setPendingComment] = 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 activeDetail = details.find((d) => d.clientId === activeDetailId) ?? null;
const isLabDependentDetail = Boolean(
activeDetail && labDependentCodes.has(activeDetail.treatmentType),
activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes),
);
const labCaseForActiveDetail =
@@ -214,6 +213,14 @@ export function LabCasesDispatchPanel({
updateActiveLabCase({ attachmentIds: [...set] });
}
function handleSelectOrganization(org: LinkedOrganizationOption) {
updateActiveLabCase({
destinationOrganizationId: org.id,
toothProsthesis: [],
});
setApplyAllProsthesis('');
}
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
return (
@@ -340,11 +347,16 @@ export function LabCasesDispatchPanel({
<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')}
<LinkedOrganizationSearchCombobox
search={organizationSearch}
onSearchChange={onOrganizationSearchChange}
organizations={activeLinkedOrganizations}
selectedOrganizationId={activeLabCase.destinationOrganizationId}
onSelectOrganization={handleSelectOrganization}
disabled={disabled}
canInviteLab={canInviteLab}
onInviteLab={onInviteLab}
noPermissionMessage={t('noOrgInvitePermission')}
/>
{recentOrganizations.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
@@ -362,28 +374,6 @@ export function LabCasesDispatchPanel({
))}
</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 ? (
@@ -411,7 +401,7 @@ export function LabCasesDispatchPanel({
))}
</select>
</label>
<div className="overflow-x-auto">
<div className="overflow-x-auto overscroll-x-contain">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted">

View File

@@ -0,0 +1,106 @@
'use client';
import { useTranslations } from 'next-intl';
import { AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import type { LinkedOrganizationOption } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
interface LabDispatchAttentionPanelProps {
items: LabDispatchAttentionItem[];
treatmentCatalog: TreatmentCatalogEntry[];
labDependentCodes: Set<string>;
orgs?: LinkedOrganizationOption[];
onGoToDispatch: (item: LabDispatchAttentionItem) => void;
}
export function LabDispatchAttentionPanel({
items,
treatmentCatalog,
labDependentCodes,
orgs,
onGoToDispatch,
}: LabDispatchAttentionPanelProps) {
const t = useTranslations('treatment');
if (items.length === 0) {
return null;
}
return (
<div className="surface-card p-4 space-y-3 border border-amber-500/35 bg-amber-500/5">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5 icon-flat" />
<div className="min-w-0">
<h3 className="text-sm font-semibold text-text-primary">{t('labAttentionTitle')}</h3>
<p className="text-[11px] text-text-muted mt-0.5">{t('labAttentionSubtitle')}</p>
</div>
</div>
<ul className="space-y-2 max-h-[min(240px,35vh)] overflow-y-auto pr-1">
{items.map((item) => {
const teeth = item.detail.teeth.length
? [...item.detail.teeth].sort().join(', ')
: t('teethNone');
const dateLabel = new Date(item.treatmentAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
return (
<li
key={item.key}
className="flex flex-col gap-2 rounded-[var(--radius-sm)] border border-border/60 bg-background-secondary/40 px-2.5 py-2 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-1.5">
<time className="text-[11px] font-medium text-text-primary tabular-nums">
{dateLabel}
</time>
{item.isCurrentDraft ? (
<span className="text-[10px] uppercase tracking-wide text-primary font-medium">
{t('labAttentionCurrentDraft')}
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-1.5 min-w-0">
<span className="text-[11px] text-text-muted tabular-nums">
{t('detailLabel', { n: item.detailNumber })}
</span>
<TreatmentTypeBadge
type={item.detail.treatmentType}
label={treatmentTypeLabelFromCatalog(
item.detail.treatmentType,
treatmentCatalog,
)}
/>
<DetailLabSendBadge
detail={item.detail}
labDependentCodes={labDependentCodes}
orgs={orgs}
/>
</div>
<p className="text-[11px] text-text-secondary truncate">
{t('teethLabel')} {teeth}
</p>
</div>
<Button
type="button"
variant="primary"
className="shrink-0 w-full sm:w-auto text-xs py-1.5"
onClick={() => onGoToDispatch(item)}
>
{item.isCurrentDraft ? t('labAttentionGoDispatch') : t('labAttentionLoadDispatch')}
</Button>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -0,0 +1,20 @@
'use client';
import { AlertCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
export function LabShipmentBlockedNotice() {
const t = useTranslations('treatment');
return (
<div className="surface-card p-4 border border-amber-500/35 bg-amber-500/5">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5 icon-flat" />
<div className="min-w-0 space-y-1">
<h3 className="text-sm font-semibold text-text-primary">{t('labShipmentBlockedTitle')}</h3>
<p className="text-xs text-text-muted">{t('labShipmentBlockedBody')}</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import { Search } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
import type { LinkedOrganizationOption } from '@/types/treatment';
interface LinkedOrganizationSearchComboboxProps {
search: string;
onSearchChange: (value: string) => void;
organizations: LinkedOrganizationOption[];
selectedOrganizationId?: string | null;
onSelectOrganization: (org: LinkedOrganizationOption) => void;
disabled?: boolean;
canInviteLab?: boolean;
onInviteLab?: () => void;
placeholder?: string;
emptyResultsMessage?: string;
noPermissionMessage?: string;
}
export function LinkedOrganizationSearchCombobox({
search,
onSearchChange,
organizations,
selectedOrganizationId,
onSelectOrganization,
disabled = false,
canInviteLab = false,
onInviteLab,
placeholder,
emptyResultsMessage,
noPermissionMessage,
}: LinkedOrganizationSearchComboboxProps) {
const t = useTranslations('treatment');
const trimmed = search.trim();
const showResults = !disabled && trimmed.length > 0;
const filtered = trimmed
? organizations.filter((o) => o.name.toLowerCase().includes(trimmed.toLowerCase()))
: [];
const selectedOrg = selectedOrganizationId
? organizations.find((o) => o.id === selectedOrganizationId)
: null;
function handleSelect(org: LinkedOrganizationOption) {
onSelectOrganization(org);
onSearchChange('');
}
return (
<div className="space-y-2">
<Input
placeholder={placeholder ?? t('searchOrgsPlaceholder')}
value={search}
onChange={(e) => onSearchChange(e.target.value)}
disabled={disabled}
icon={<Search className="h-4 w-4 icon-flat" />}
/>
{selectedOrg && !trimmed ? (
<p className="text-sm text-text-primary rounded-[var(--radius-md)] border border-border bg-background-secondary/60 px-3 py-2">
{selectedOrg.name}
</p>
) : null}
{showResults ? (
<div className="space-y-2 rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/30 p-2">
{filtered.length === 0 ? (
<div className="space-y-2 px-1 py-1">
<p className="text-sm text-text-muted">
{emptyResultsMessage ?? t('noOrgMatch')}
</p>
{canInviteLab && onInviteLab ? (
<Button type="button" variant="primary" onClick={onInviteLab} fullWidth>
{t('inviteLab')}
</Button>
) : noPermissionMessage ? (
<p className="text-xs text-text-muted">{noPermissionMessage}</p>
) : null}
</div>
) : (
<div className="max-h-48 space-y-1.5 overflow-y-auto overscroll-y-contain pr-1">
{filtered.map((org) => {
const isSelected = selectedOrganizationId === org.id;
return (
<button
key={org.id}
type="button"
onClick={() => handleSelect(org)}
className={`w-full rounded-[var(--radius-sm)] border px-2.5 py-2 text-left text-sm transition-colors ${
isSelected
? 'border-primary/60 bg-primary-soft'
: 'border-transparent hover:bg-background-card/70'
}`}
>
{org.name}
</button>
);
})}
</div>
)}
</div>
) : null}
</div>
);
}

View File

@@ -1,43 +1,127 @@
'use client';
import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { TreatmentHistoryDetailLine } from '@/components/ui/treatment/TreatmentHistoryDetailLine';
import type { PastTreatment } from '@/types/treatment';
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
import { filterTreatmentHistoryItems } from '@/components/treatment/treatmentHistoryFilters';
import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
interface PastTreatmentsPanelProps {
items: PastTreatment[];
currentDraft?: PastTreatment | null;
patientName?: string;
currentAppointmentId?: string | null;
treatmentCatalog: TreatmentCatalogEntry[];
labDependentCodes: Set<string>;
orgs?: LinkedOrganizationOption[];
loading?: boolean;
selectedPreviewId?: string | null;
onSelectTreatment?: (treatment: PastTreatment) => void;
}
function formatHistoryTimestamp(iso: string): string {
const date = new Date(iso);
return date.toLocaleString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
export function PastTreatmentsPanel({
items,
currentDraft = null,
patientName,
currentAppointmentId,
treatmentCatalog,
labDependentCodes,
orgs,
loading,
selectedPreviewId,
onSelectTreatment,
}: PastTreatmentsPanelProps) {
const t = useTranslations('treatment');
const [notShippedOnly, setNotShippedOnly] = useState(false);
const [filterDate, setFilterDate] = useState('');
const hasActiveFilters = notShippedOnly || Boolean(filterDate);
const displayedItems = useMemo(
() =>
filterTreatmentHistoryItems(items, currentDraft, labDependentCodes, {
notShippedOnly,
date: filterDate,
}),
[items, currentDraft, labDependentCodes, notShippedOnly, filterDate],
);
function clearFilters() {
setNotShippedOnly(false);
setFilterDate('');
}
const filterInputClass = `${FORM_SELECT_CLASS} rounded-md px-2 py-1.5 text-xs min-w-[9.5rem]`;
return (
<div className="surface-card p-4 space-y-3">
<div>
<h3 className="text-sm font-semibold text-text-primary">{t('historyTitle')}</h3>
<h3 className="text-sm font-semibold text-text-primary">
{patientName ? t('historyPatientScope', { patientName }) : t('historyTitle')}
</h3>
<p className="text-[11px] text-text-muted mt-0.5">{t('historySubtitle')}</p>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/30 p-2.5">
<Checkbox
checked={notShippedOnly}
onChange={setNotShippedOnly}
label={t('historyFilterNotShipped')}
className="text-xs [&_span:last-child]:text-xs shrink-0"
/>
<label className="flex items-center gap-1.5 shrink-0">
<span className="text-xs font-medium text-text-muted whitespace-nowrap">
{t('historyFilterDate')}
</span>
<input
type="date"
value={filterDate}
onChange={(e) => setFilterDate(e.target.value)}
className={filterInputClass}
/>
</label>
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
disabled={!hasActiveFilters}
className="shrink-0"
>
{t('historyClearFilters')}
</Button>
</div>
{loading && <p className="text-sm text-text-muted">{t('loadingHistory')}</p>}
{!loading && items.length === 0 && (
<p className="text-sm text-text-muted">{t('historyEmpty')}</p>
{!loading && displayedItems.length === 0 && (
<p className="text-sm text-text-muted">
{hasActiveFilters ? t('historyFilterEmpty') : t('historyEmpty')}
</p>
)}
<div className="space-y-1.5 max-h-[min(420px,50vh)] overflow-y-auto pr-1">
{items.map((treatment) => {
<div className="space-y-1.5 max-h-[min(420px,50vh)] overflow-y-auto overscroll-y-contain pr-1">
{displayedItems.map((treatment) => {
const isSelected = selectedPreviewId === treatment.id;
const isCurrentAppointment =
Boolean(currentAppointmentId) && treatment.appointmentId === currentAppointmentId;
const isLiveDraft = treatment.id === 'current-draft';
return (
<article
@@ -61,28 +145,41 @@ export function PastTreatmentsPanel({
}
`}
>
<time
className="text-xs font-medium text-text-primary tabular-nums block"
dateTime={treatment.treatmentAt}
>
{new Date(treatment.treatmentAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
<div className="flex flex-wrap items-center gap-2">
<time
className="text-xs font-medium text-text-primary tabular-nums"
dateTime={treatment.treatmentAt}
>
{formatHistoryTimestamp(treatment.treatmentAt)}
</time>
{isLiveDraft ? (
<span className="text-[10px] font-medium uppercase tracking-wide text-primary">
{t('labAttentionCurrentDraft')}
</span>
) : isCurrentAppointment ? (
<span className="text-[10px] font-medium uppercase tracking-wide text-primary">
{t('historyCurrentAppointment')}
</span>
) : null}
</div>
{treatment.details.length === 0 ? (
<p className="text-[10px] text-text-muted mt-1">{t('noDetails')}</p>
) : (
<div className="mt-1.5 divide-y divide-border/50 border-t border-border/40 pointer-events-none">
{treatment.details.map((detail, idx) => (
<div key={detail.clientId ?? detail.id} className="py-1.5">
<div key={`${detail.clientId ?? detail.id}-${idx}`} className="py-1.5 space-y-1">
<TreatmentHistoryDetailLine
detail={detail}
detailNumber={idx + 1}
treatmentCatalog={treatmentCatalog}
/>
<DetailLabSendBadge
detail={detail}
labDependentCodes={labDependentCodes}
orgs={orgs}
className="text-[10px] px-1.5 py-0"
/>
</div>
))}
</div>

View File

@@ -3,6 +3,7 @@
import { useTranslations } from 'next-intl';
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import { isDetailTypeSelected } from '@/components/treatment/treatmentDetailRules';
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment';
@@ -39,10 +40,16 @@ export function TreatmentDetailSummaryRow({
<span className={`text-text-muted tabular-nums ${compact ? 'text-[11px]' : 'text-xs'}`}>
{t('detailLabel', { n: detailNumber })}
</span>
<TreatmentTypeBadge
type={detail.treatmentType}
label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)}
/>
{isDetailTypeSelected(detail) ? (
<TreatmentTypeBadge
type={detail.treatmentType}
label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)}
/>
) : (
<span className={`text-text-muted italic ${compact ? 'text-[11px]' : 'text-xs'}`}>
{t('treatmentTypeNotSelected')}
</span>
)}
</div>
<DetailLabSendBadge detail={detail} labDependentCodes={labDependentCodes} orgs={orgs} />
</div>

View File

@@ -12,6 +12,14 @@ import {
import type { TreatmentDetailDraft } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
import {
canCommentOnDetailLabCase,
isDetailReadyForLabDispatch,
isDetailTypeSelected,
isLabDependentDetailMissingTeeth,
} from '@/components/treatment/treatmentDetailRules';
import { DetailLabCaseCommentsSection } from '@/components/ui/treatment/DetailLabCaseCommentsSection';
import { labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles';
interface TreatmentDetailsEditorProps {
details: TreatmentDetailDraft[];
@@ -27,6 +35,7 @@ interface TreatmentDetailsEditorProps {
uploadBusy: boolean;
onAddDetail: () => void;
onUploadFiles: (files: FileList | null) => void;
onCommentError?: (message: string) => void;
}
export function TreatmentDetailsEditor({
@@ -43,6 +52,7 @@ export function TreatmentDetailsEditor({
uploadBusy,
onAddDetail,
onUploadFiles,
onCommentError,
}: TreatmentDetailsEditorProps) {
const t = useTranslations('treatment');
const attachmentInputRef = useRef<HTMLInputElement>(null);
@@ -52,12 +62,19 @@ export function TreatmentDetailsEditor({
const locked = isDetailLocked(activeDetail);
const readOnly = disabled || locked;
const treatmentTypeTextColor = treatmentTypeColor(
activeDetail.treatmentType,
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
const treatmentTypeTextColor = isDetailTypeSelected(activeDetail)
? treatmentTypeColor(
activeDetail.treatmentType,
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
)
: undefined;
const showPendingLabHint =
isDetailReadyForLabDispatch(activeDetail, labDependentCodes) && !locked && !readOnly;
const showMissingTeethLabBlock = isLabDependentDetailMissingTeeth(
activeDetail,
labDependentCodes,
);
const isLabDependent = labDependentCodes.has(activeDetail.treatmentType);
const showPendingLabHint = isLabDependent && !locked && !readOnly;
const showLabCaseComments = canCommentOnDetailLabCase(activeDetail);
return (
<div className="surface-card p-3 sm:p-4 space-y-4">
@@ -107,6 +124,9 @@ export function TreatmentDetailsEditor({
{showPendingLabHint && (
<p className={labPendingBannerClass}>{t('detailPendingLabSend')}</p>
)}
{showMissingTeethLabBlock && (
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
)}
<label className="block text-xs font-medium text-text-secondary">
{t('comments')}
@@ -140,6 +160,7 @@ export function TreatmentDetailsEditor({
disabled={readOnly}
style={{ color: treatmentTypeTextColor }}
>
<option value="">{t('treatmentTypePlaceholder')}</option>
{treatmentCatalog.map((entry, index) => (
<option
key={entry.code}
@@ -192,6 +213,14 @@ export function TreatmentDetailsEditor({
</div>
</div>
{showLabCaseComments ? (
<DetailLabCaseCommentsSection
detail={activeDetail}
canPost={canEdit && !disabled}
onError={onCommentError}
/>
) : null}
{canEdit && saveStatus !== 'idle' && (
<p
className={`text-xs pt-2 border-t border-border/60 ${autosaveStatusClass(saveStatus)}`}

View File

@@ -2,6 +2,7 @@
import { useTranslations } from 'next-intl';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import { isDetailTypeSelected } from '@/components/treatment/treatmentDetailRules';
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { PastTreatmentDetail } from '@/types/treatment';
@@ -24,10 +25,14 @@ export function TreatmentHistoryDetailLine({
return (
<div className="flex items-center gap-2 min-w-0 text-[11px] leading-tight">
<span className="text-text-muted tabular-nums shrink-0">{detailNumber}.</span>
<TreatmentTypeBadge
type={detail.treatmentType}
label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)}
/>
{isDetailTypeSelected(detail) ? (
<TreatmentTypeBadge
type={detail.treatmentType}
label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)}
/>
) : (
<span className="text-text-muted italic shrink-0">{t('treatmentTypeNotSelected')}</span>
)}
<span className="text-text-secondary truncate min-w-0">{teeth}</span>
{attachmentCount > 0 && (
<span className="text-text-muted shrink-0 tabular-nums">

View File

@@ -1,58 +1,56 @@
'use client';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow';
import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
interface TreatmentPreviewCardProps {
treatment: PastTreatment | null;
/** e.g. "Current draft" or a formatted date label while browsing. */
heading: string;
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
orgs?: LinkedOrganizationOption[];
openDisabled?: boolean;
onOpen: () => void;
}
export function TreatmentPreviewCard({
treatment,
heading,
labDependentCodes,
treatmentCatalog,
orgs,
openDisabled = false,
onOpen,
}: TreatmentPreviewCardProps) {
const t = useTranslations('treatment');
return (
<div className="surface-card p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-text-primary">{t('previewTitle')}</h3>
<Button type="button" variant="primary" disabled={openDisabled || !treatment} onClick={onOpen}>
{t('openTreatment')}
</Button>
</div>
<h3 className="text-sm font-semibold text-text-primary">{heading}</h3>
{!treatment ? (
<p className="text-sm text-text-muted">{t('selectAppointment')}</p>
) : (
<div className="border border-border/70 rounded-[var(--radius-md)] p-3 bg-background-secondary/40 space-y-2">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-text-primary">{treatment.title}</p>
{treatment.id !== 'current-draft' ? (
<time
className="text-xs text-text-muted tabular-nums shrink-0"
className="text-xs text-text-muted tabular-nums block"
dateTime={treatment.treatmentAt}
>
{new Date(treatment.treatmentAt).toLocaleDateString()}
{new Date(treatment.treatmentAt).toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
</div>
) : null}
<div className="space-y-2 max-h-[min(280px,40vh)] overflow-y-auto pr-1">
{treatment.details.length === 0 ? (
<p className="text-xs text-text-muted">{t('noDetails')}</p>
) : (
treatment.details.map((detail, idx) => (
<TreatmentDetailSummaryRow
key={detail.clientId ?? detail.id}
key={`${detail.clientId ?? detail.id}-${idx}`}
detail={detail}
detailNumber={idx + 1}
labDependentCodes={labDependentCodes}

View File

@@ -3,9 +3,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
import { LabShipmentBlockedNotice } from '@/components/ui/treatment/LabShipmentBlockedNotice';
import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
@@ -20,7 +24,17 @@ import { appointmentsApi } from '@/lib/api/appointments';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentsApi } from '@/lib/api/treatments';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
import { canEditTreatment, canViewTreatment } from '@/components/shared/permissions';
import {
areDetailsPersistable,
defaultTreatmentTypeForAppointment,
isDetailReadyForLabDispatch,
isEmptyDraftDetail,
isLabDependentDetailMissingTeeth,
} from '@/components/treatment/treatmentDetailRules';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
import type { Organization } from '@/types/organization';
@@ -116,24 +130,13 @@ function buildWorkspaceSnapshot(
};
}
function defaultTreatmentTypeForAppointment(
purpose: string | undefined,
catalog: TreatmentCatalogEntry[],
): TreatmentDetailDraft['treatmentType'] {
const treatmentOptions = catalog.filter((entry) => entry.availableInTreatment);
if (purpose && treatmentOptions.some((entry) => entry.code === purpose)) {
return purpose as TreatmentDetailDraft['treatmentType'];
}
return (treatmentOptions[0]?.code ?? 'restoration') as TreatmentDetailDraft['treatmentType'];
}
function newDetail(defaultTreatmentType?: TreatmentDetailDraft['treatmentType']): TreatmentDetailDraft {
function newDetail(defaultTreatmentType?: string): TreatmentDetailDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
treatmentType: defaultTreatmentType ?? 'restoration',
treatmentType: defaultTreatmentType ?? '',
teeth: [],
comment: '',
attachmentMetas: [],
@@ -179,6 +182,7 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
comment: d.notes ?? '',
attachmentMetas: d.attachmentMetas ?? [],
labCaseId: d.labCaseId ?? null,
taskProgress: d.taskProgress ?? null,
sendToOrganizationIds: d.destinationOrganizationId ? [d.destinationOrganizationId] : [],
sends: d.sends ?? [],
sentAt: d.sentAt ?? null,
@@ -220,7 +224,7 @@ function isDetailsDirty(
savedSnapshot: string | null,
): boolean {
if (savedSnapshot === null) {
return details.length !== 1 || details[0].comment !== '' || details[0].teeth.length > 0;
return details.some((d) => !isEmptyDraftDetail(d)) || details.length > 1;
}
return serializeDetails(details) !== savedSnapshot;
}
@@ -242,6 +246,7 @@ function detailsToPreviewTreatment(
notes: d.comment || null,
attachmentMetas: d.attachmentMetas,
labCaseId: d.labCaseId ?? null,
taskProgress: d.taskProgress ?? null,
destinationOrganizationId: d.sendToOrganizationIds[0] ?? null,
sends: d.sends ?? [],
sentAt: d.sentAt ?? null,
@@ -316,6 +321,8 @@ export function TreatmentWorkspace({
labCaseDraftsRef.current = labCaseDrafts;
const skipNextGetDraftRef = useRef(false);
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
const labPanelRef = useRef<HTMLDivElement>(null);
const historyRequestRef = useRef(0);
useEffect(() => {
pendingAppointmentIdRef.current = initialAppointmentId;
@@ -360,18 +367,25 @@ export function TreatmentWorkspace({
!isViewingPastDay &&
workspaceMode === 'live';
const historyPanelItems = useMemo(() => {
return history.filter((item) => {
if (
workspaceMode === 'live' &&
selectedAppointmentId &&
item.appointmentId === selectedAppointmentId
) {
return false;
}
return true;
});
}, [history, selectedAppointmentId, workspaceMode]);
const historyPanelItems = history;
const activeDetail = useMemo(
() => details.find((d) => d.clientId === activeDetailId) ?? details[0] ?? null,
[details, activeDetailId],
);
const showLabDispatchPanel = useMemo(
() => details.some((d) => isDetailReadyForLabDispatch(d, labDependentCodes)),
[details, labDependentCodes],
);
const showLabShipmentBlocked = useMemo(
() =>
Boolean(
activeDetail && isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes),
),
[activeDetail, labDependentCodes],
);
const currentDraftPreview = useMemo<PastTreatment | null>(() => {
if (!selectedAppointment) return null;
@@ -388,17 +402,29 @@ export function TreatmentWorkspace({
const previewTreatment = useMemo(() => {
if (!selectedPreviewId) return currentDraftPreview;
return historyPanelItems.find((item) => item.id === selectedPreviewId) ?? currentDraftPreview;
}, [selectedPreviewId, historyPanelItems, currentDraftPreview]);
return (
history.find((item) => item.id === selectedPreviewId) ??
historyPanelItems.find((item) => item.id === selectedPreviewId) ??
currentDraftPreview
);
}, [selectedPreviewId, history, historyPanelItems, currentDraftPreview]);
const isPreviewAlreadyOpen = useMemo(() => {
if (!previewTreatment?.appointmentId || !selectedAppointmentId) return false;
if (selectedAppointmentId !== previewTreatment.appointmentId) return false;
if (workspaceMode === 'historical') return true;
if (workspaceMode === 'live' && selectedPreviewId === null) return true;
if (workspaceMode === 'live' && selectedPreviewId === previewTreatment.id) return true;
return false;
}, [previewTreatment, selectedAppointmentId, workspaceMode, selectedPreviewId]);
const isBrowsing = selectedPreviewId !== null;
const previewHeading = isBrowsing
? t('previewBrowsingTitle')
: t('previewCurrentDraft');
const labAttentionItems = useMemo(
() =>
collectLabDispatchAttention(
labDependentCodes,
currentDraftPreview,
history,
selectedAppointmentId,
),
[labDependentCodes, currentDraftPreview, history, selectedAppointmentId],
);
const hydrateFromTreatment = useCallback((treatment: PastTreatment) => {
const mapped = treatment.details.map(mapDetailFromApi);
@@ -417,11 +443,6 @@ export function TreatmentWorkspace({
setSaveStatus('idle');
}, []);
const activeDetail = useMemo(
() => details.find((d) => d.clientId === activeDetailId) ?? details[0],
[details, activeDetailId],
);
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const wholePlanTeethSet = useMemo(() => {
@@ -459,10 +480,6 @@ export function TreatmentWorkspace({
setActiveLabCaseId(match?.clientId ?? null);
}, [activeDetailId, labCaseDrafts]);
useEffect(() => {
setSelectionLocked(false);
}, [selectedDay]);
useEffect(() => {
let cancelled = false;
setApptsLoading(true);
@@ -550,28 +567,34 @@ export function TreatmentWorkspace({
setHistoryLoading(false);
return;
}
setHistoryPatientId(selectedAppointment.patientId);
const nextPatientId = selectedAppointment.patientId;
setHistoryPatientId((prev) => {
if (prev !== nextPatientId) {
setHistory([]);
setHistoryLoading(true);
}
return nextPatientId;
});
}, [selectedAppointment?.patientId]);
useEffect(() => {
if (!historyPatientId) return;
let cancelled = false;
const requestId = ++historyRequestRef.current;
setHistoryLoading(true);
void (async () => {
try {
const response = await treatmentsApi.listPatientHistory(historyPatientId);
if (!cancelled) setHistory(response.data);
const response = await treatmentsApi.listPatientHistory(historyPatientId, 50);
if (requestId !== historyRequestRef.current) return;
setHistory(response.data);
} catch (error: unknown) {
if (!cancelled) {
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
}
if (requestId !== historyRequestRef.current) return;
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
} finally {
if (!cancelled) setHistoryLoading(false);
if (requestId === historyRequestRef.current) {
setHistoryLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [historyPatientId, showError, t]);
useEffect(() => {
@@ -652,6 +675,16 @@ export function TreatmentWorkspace({
});
}
if (!areDetailsPersistable(currentDetails)) {
return detailsToPreviewTreatment(currentDetails, {
title: t('treatmentPlanTitle', {
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
}),
patientId: selectedAppointment.patientId,
treatmentAt: selectedAppointment.startAt,
});
}
const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
details: currentDetails.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
clientId,
@@ -674,6 +707,18 @@ export function TreatmentWorkspace({
[selectedAppointment, t],
);
const refreshHistory = useCallback(async (patientId: string) => {
const requestId = ++historyRequestRef.current;
try {
const response = await treatmentsApi.listPatientHistory(patientId, 50);
if (requestId !== historyRequestRef.current) return;
setHistory(response.data);
} catch (error: unknown) {
if (requestId !== historyRequestRef.current) return;
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
}
}, [showError, t]);
const runDraftSave = useCallback(async () => {
if (!selectedAppointment || saveInFlightRef.current) {
if (saveInFlightRef.current) saveQueuedRef.current = true;
@@ -681,7 +726,8 @@ export function TreatmentWorkspace({
}
if (
!isDetailsDirty(detailsRef.current, savedSnapshotRef.current)
!isDetailsDirty(detailsRef.current, savedSnapshotRef.current) ||
!areDetailsPersistable(detailsRef.current)
) {
return;
}
@@ -691,6 +737,9 @@ export function TreatmentWorkspace({
try {
await persistDraft();
setSaveStatus('saved');
if (historyPatientId) {
await refreshHistory(historyPatientId);
}
} catch (error: unknown) {
setSaveStatus('error');
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
@@ -704,16 +753,7 @@ export function TreatmentWorkspace({
}
}
}
}, [selectedAppointment, persistDraft, showError, t]);
const refreshHistory = useCallback(async (patientId: string) => {
try {
const response = await treatmentsApi.listPatientHistory(patientId);
setHistory(response.data);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
}
}, [showError, t]);
}, [selectedAppointment, persistDraft, showError, t, historyPatientId, refreshHistory]);
const flushDraftSave = useCallback(async (): Promise<boolean> => {
if (autosaveTimerRef.current) {
@@ -735,14 +775,11 @@ export function TreatmentWorkspace({
try {
await runDraftSave();
if (historyPatientId) {
await refreshHistory(historyPatientId);
}
return true;
} catch {
return window.confirm(t('confirmDiscard'));
}
}, [selectedAppointment, canEditTreatmentForDay, runDraftSave, historyPatientId, refreshHistory, t]);
}, [selectedAppointment, canEditTreatmentForDay, runDraftSave, t]);
useEffect(() => {
if (draftHydratingRef.current || !canEditTreatmentForDay || !selectedAppointment?.id) {
@@ -787,6 +824,8 @@ export function TreatmentWorkspace({
[flushDraftSave, resetToLiveContext],
);
const canAccessOrganizations = canAccessDashboardRoute(currentOrganization, '/organizations');
const onSelectDay = useCallback(
(day: Date) => {
void (async () => {
@@ -794,7 +833,8 @@ export function TreatmentWorkspace({
if (!ok) return;
const patientIdToRefresh = historyPatientId;
resetToLiveContext();
setSelectedDay(day);
setSelectionLocked(false);
setSelectedDay(startOfLocalDay(day));
if (patientIdToRefresh) {
await refreshHistory(patientIdToRefresh);
}
@@ -807,22 +847,23 @@ export function TreatmentWorkspace({
setSelectedPreviewId(treatment.id);
}, []);
const handleOpenTreatment = useCallback(() => {
void (async () => {
const treatment = previewTreatment;
if (!treatment?.appointmentId) {
const exitBrowse = useCallback(() => {
setSelectedPreviewId(null);
}, []);
const loadTreatmentIntoWorkspace = useCallback(
async (treatment: PastTreatment, focusDetailClientId?: string) => {
if (!treatment.appointmentId) {
showError(t('errorNoAppointmentForTreatment'));
return;
return false;
}
if (isPreviewAlreadyOpen) return;
const ok = workspaceModeRef.current === 'live' ? await flushDraftSave() : true;
if (!ok) return;
if (!ok) return false;
const isHistorical = isTreatmentDayHistorical(treatment.treatmentAt, todayStart);
setWorkspaceMode(isHistorical ? 'historical' : 'live');
setSelectedPreviewId(treatment.id);
setSelectedPreviewId(null);
setSelectedDay(startOfLocalDay(new Date(treatment.treatmentAt)));
setSelectionLocked(true);
setSelectedAppointmentId(treatment.appointmentId);
@@ -831,16 +872,58 @@ export function TreatmentWorkspace({
draftHydratingRef.current = true;
hydrateFromTreatment(treatment);
draftHydratingRef.current = false;
})();
}, [
previewTreatment,
isPreviewAlreadyOpen,
flushDraftSave,
hydrateFromTreatment,
showError,
t,
todayStart,
]);
if (focusDetailClientId) {
setActiveDetailId(focusDetailClientId);
const mappedLabCases = withoutEmptyLabCaseDrafts(
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
);
const linked = mappedLabCases.find(
(lc) => !lc.sentAt && lc.detailClientId === focusDetailClientId,
);
if (linked) {
setActiveLabCaseId(linked.clientId);
}
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
}
return true;
},
[flushDraftSave, hydrateFromTreatment, showError, t, todayStart],
);
const handleLoadIntoWorkspace = useCallback(() => {
if (!previewTreatment) return;
void loadTreatmentIntoWorkspace(previewTreatment);
}, [loadTreatmentIntoWorkspace, previewTreatment]);
const handleGoToLabDispatch = useCallback(
(item: LabDispatchAttentionItem) => {
if (item.isCurrentDraft) {
exitBrowse();
setActiveDetailId(item.detailClientId);
const linked = labCaseDrafts.find(
(lc) => !lc.sentAt && lc.detailClientId === item.detailClientId,
);
if (linked) {
setActiveLabCaseId(linked.clientId);
}
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
return;
}
const treatment =
history.find((entry) => entry.id === item.treatmentId) ??
historyPanelItems.find((entry) => entry.id === item.treatmentId);
if (!treatment) return;
void loadTreatmentIntoWorkspace(treatment, item.detailClientId);
},
[exitBrowse, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace],
);
const uploadForDetail = useCallback(
async (detailClientId: string, files: FileList | File[]) => {
@@ -984,8 +1067,17 @@ export function TreatmentWorkspace({
}
const activeDetail = details.find((d) => d.clientId === activeDetailId);
const shouldIncludeActive =
Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType));
if (
activeDetail &&
isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)
) {
showError(t('labShipmentBlockedBody'));
return;
}
const shouldIncludeActive = Boolean(
activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes),
);
const orphan = cleaned.find((lc) => !lc.sentAt && !lc.detailClientId);
if (orphan && shouldIncludeActive) {
@@ -1068,21 +1160,28 @@ export function TreatmentWorkspace({
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
const sentDetailClientId = labCase.detailClientId;
setDetails((prev) =>
prev.map((detail) => {
if (detail.clientId !== sentDetailClientId) return detail;
return {
...detail,
labCaseId: response.data.id,
sentAt: response.data.sentAt,
sends: response.data.sends,
sendToOrganizationIds: response.data.destinationOrganizationId
? [response.data.destinationOrganizationId]
: detail.sendToOrganizationIds,
};
}),
);
const draftResponse = await treatmentsApi.getDraft(selectedAppointment.id);
if (draftResponse.data?.details?.length) {
const mapped = draftResponse.data.details.map(mapDetailFromApi);
setDetails(mapped);
setSavedSnapshot(serializeDetails(mapped));
} else {
const sentDetailClientId = labCase.detailClientId;
setDetails((prev) =>
prev.map((detail) => {
if (detail.clientId !== sentDetailClientId) return detail;
return {
...detail,
labCaseId: response.data.id,
sentAt: response.data.sentAt,
sends: response.data.sends,
sendToOrganizationIds: response.data.destinationOrganizationId
? [response.data.destinationOrganizationId]
: detail.sendToOrganizationIds,
};
}),
);
}
setLabCaseDrafts((prev) =>
prev.map((lc) =>
@@ -1183,18 +1282,59 @@ export function TreatmentWorkspace({
</div>
)}
<LabDispatchAttentionPanel
items={labAttentionItems}
treatmentCatalog={treatmentCatalog}
labDependentCodes={labDependentCodes}
orgs={orgs}
onGoToDispatch={handleGoToLabDispatch}
/>
{isBrowsing && previewTreatment ? (
<div className="rounded-[var(--radius-md)] border border-primary/40 bg-primary/5 px-3 py-3 space-y-3">
<p className="text-sm text-text-primary">
{t('browseBanner', {
date: new Date(previewTreatment.treatmentAt).toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
}),
})}
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Button type="button" variant="primary" onClick={handleLoadIntoWorkspace}>
{t('loadIntoWorkspace')}
</Button>
<Button type="button" variant="ghost" onClick={exitBrowse}>
{t('backToCurrentDraft')}
</Button>
</div>
</div>
) : null}
<TreatmentPreviewCard
treatment={previewTreatment}
heading={previewHeading}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
orgs={orgs}
openDisabled={isPreviewAlreadyOpen}
onOpen={handleOpenTreatment}
/>
<PastTreatmentsPanel
items={historyPanelItems}
currentDraft={
workspaceMode === 'live' && !isBrowsing ? currentDraftPreview : null
}
patientName={
selectedAppointment
? `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`
: undefined
}
currentAppointmentId={selectedAppointmentId}
treatmentCatalog={treatmentCatalog}
labDependentCodes={labDependentCodes}
orgs={orgs}
loading={historyLoading}
selectedPreviewId={selectedPreviewId}
onSelectTreatment={handleSelectPreviewTreatment}
@@ -1208,15 +1348,12 @@ export function TreatmentWorkspace({
readOnly={showWholeTreatmentPlan}
headerControl={
details.length > 1 ? (
<label className="flex items-center gap-2 text-[11px] text-text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={showWholeTreatmentPlan}
onChange={(e) => setShowWholeTreatmentPlan(e.target.checked)}
className="rounded border-border"
/>
{t('toothChartWholePlan')}
</label>
<Checkbox
checked={showWholeTreatmentPlan}
onChange={setShowWholeTreatmentPlan}
label={t('toothChartWholePlan')}
className="text-[11px] [&_span:last-child]:text-[11px] [&_span:last-child]:text-text-muted"
/>
) : undefined
}
onToggle={(fdi) => {
@@ -1254,8 +1391,12 @@ export function TreatmentWorkspace({
setActiveDetailId(next.clientId);
}}
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
onCommentError={showError}
/>
<div ref={labPanelRef}>
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
{showLabDispatchPanel ? (
<LabCasesDispatchPanel
details={details}
activeDetailId={activeDetailId}
@@ -1288,7 +1429,11 @@ export function TreatmentWorkspace({
onAddLabCase={() => void handleAddLabCase()}
onSendLabCase={(lc, comment) => void handleSendLabCase(lc, comment)}
onCommentError={showError}
canInviteLab={canAccessOrganizations}
onInviteLab={() => router.push('/organizations?action=invite-lab')}
/>
) : null}
</div>
</div>
</div>
</div>