improvement: lab/clinic commiunication flow completely overhauled. no more shit.

This commit is contained in:
2026-07-13 22:53:23 +03:30
parent 2ad572f4c8
commit 27eae25f61
30 changed files with 1805 additions and 696 deletions

View File

@@ -9,12 +9,15 @@ import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { LinkedOrganizationSearchCombobox } from '@/components/ui/treatment/LinkedOrganizationSearchCombobox';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { DetailLabCaseCommentsSection } from '@/components/ui/treatment/DetailLabCaseCommentsSection';
import { LabCaseTrackerCard } from '@/components/ui/treatment/LabCaseTrackerCard';
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import { treatmentsApi } from '@/lib/api/treatments';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
interface LabCasesDispatchPanelProps {
details: TreatmentDetailDraft[];
@@ -22,6 +25,11 @@ interface LabCasesDispatchPanelProps {
labCases: LabCaseDraft[];
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
labCaseSummary?: PatientLabCaseSummary | null;
locale: string;
onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void;
onLabCaseMarkedRead?: (labCaseId: string) => void;
onLabCaseActivityChange?: () => void;
activeLabCaseId: string | null;
onLabCasesChange: (labCases: LabCaseDraft[]) => void;
disabled: boolean;
@@ -83,6 +91,11 @@ export function LabCasesDispatchPanel({
labCases,
labDependentCodes,
treatmentCatalog,
labCaseSummary,
locale,
onLabCaseSummaryChange,
onLabCaseMarkedRead,
onLabCaseActivityChange,
activeLabCaseId,
onLabCasesChange,
disabled,
@@ -103,6 +116,7 @@ export function LabCasesDispatchPanel({
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const [pendingComment, setPendingComment] = useState('');
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
const activeLinkedOrganizations = orgs.filter((o) => o.active);
const recentOrganizations = recentOrganizationIds
@@ -224,6 +238,11 @@ export function LabCasesDispatchPanel({
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
const canEditDueDate = canEdit && !disabled && (!sent || !caseFullyComplete);
// Comments/progress belong to the shipment context, even when the treatment is opened from history.
// Do not block commenting just because the treatment editor is read-only.
const canPostComments = canEdit && !caseFullyComplete;
const canShowComments = Boolean(activeLabCase?.id);
const commentsDeferSubmit = Boolean(!sent);
async function handleSentDueDateBlur(nextValue: string) {
if (!activeLabCase?.id || !sent || !canEditDueDate) return;
@@ -252,40 +271,97 @@ export function LabCasesDispatchPanel({
function renderDueDateField() {
if (!activeLabCase) return null;
const inputValue = toDateInputValue(activeLabCase.dueDate);
const dueDateInputId = `lab-case-due-date-${activeLabCase.clientId}`;
return (
<label className="block shrink-0 sm:max-w-[11rem] sm:text-end">
<span className="block text-xs font-medium text-text-secondary sm:text-end">
{t('dueDateLabel')}{' '}
<span className="font-normal text-text-muted">({t('dueDateOptional')})</span>
</span>
<input
type="date"
value={inputValue}
disabled={!canEditDueDate}
onChange={(e) => {
if (!sent) {
updateActiveLabCase({ dueDate: e.target.value || null });
}
}}
onBlur={(e) => {
if (sent) void handleSentDueDateBlur(e.target.value);
}}
className={`${FORM_SELECT_CLASS} mt-2 w-full rounded-md px-2 py-1.5 text-sm`}
/>
<div className="shrink-0 sm:text-end">
<div className="flex items-center gap-2 sm:justify-end">
<label
htmlFor={dueDateInputId}
className="text-xs font-medium text-text-secondary shrink-0"
>
{t('dueDateLabel')}{' '}
<span className="font-normal text-text-muted">({t('dueDateOptional')})</span>
</label>
<input
id={dueDateInputId}
type="date"
value={inputValue}
disabled={!canEditDueDate}
onChange={(e) => {
if (!sent) {
updateActiveLabCase({ dueDate: e.target.value || null });
}
}}
onBlur={(e) => {
if (sent) void handleSentDueDateBlur(e.target.value);
}}
className={`${FORM_SELECT_CLASS} w-full max-w-[11rem] rounded-md px-2 py-1.5 text-sm`}
/>
</div>
{sent && caseFullyComplete && activeLabCase.dueDate ? (
<p className="mt-1.5 text-[11px] text-text-muted sm:text-end">{t('dueDateLockedCompleted')}</p>
) : null}
</label>
</div>
);
}
function renderIncludedDetailSummary() {
if (!activeDetail) return null;
const typeLabel = treatmentTypeLabelFromCatalog(activeDetail.treatmentType, treatmentCatalog);
if (activeDetail.treatmentType !== 'prosthesis' || !activeLabCase) {
return (
<p className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2">
{detailSummary(activeDetail)}
</p>
);
}
const byType = new Map<string, string[]>();
for (const tp of activeLabCase.toothProsthesis) {
if (tp.detailClientId !== activeDetail.clientId) continue;
if (!tp.prosthesisTypeCode) continue;
const list = byType.get(tp.prosthesisTypeCode) ?? [];
list.push(tp.tooth);
byType.set(tp.prosthesisTypeCode, list);
}
const uniqueSelected = [...new Set(activeDetail.teeth)];
const mappedTeeth = new Set<string>();
for (const teeth of byType.values()) {
for (const tooth of teeth) mappedTeeth.add(tooth);
}
const unmapped = uniqueSelected.filter((t) => !mappedTeeth.has(t));
const groups = [...byType.entries()]
.map(([code, teeth]) => ({
code,
teeth: [...new Set(teeth)].sort((a, b) => a.localeCompare(b)),
label: prosthesisOptions.find((p) => p.code === code)?.label ?? code,
}))
.sort((a, b) => a.code.localeCompare(b.code));
return (
<p className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2">
{detailSummary(activeDetail)}
</p>
<div className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2 space-y-1">
<p className="text-text-primary">
{t('detailLabel', { n: activeDetailNumber })} · {typeLabel}
</p>
{groups.map((g) => (
<p
key={g.code}
className="text-[13px]"
style={{ color: prosthesisTypeColorFromCatalog(g.code, prosthesisOptions) }}
>
{g.label}: <span className="text-text-primary">{g.teeth.join(', ')}</span>
</p>
))}
{unmapped.length > 0 ? (
<p className="text-[13px] text-text-muted">
{t('prosthesisUnassigned')}: <span className="text-text-primary">{unmapped.join(', ')}</span>
</p>
) : null}
</div>
);
}
@@ -320,20 +396,22 @@ export function LabCasesDispatchPanel({
{sent ? (
<>
{renderIncludedDetailSummary()}
{hasTrackerSummary && labCaseSummary ? (
<LabCaseTrackerCard
summary={labCaseSummary}
locale={locale}
onSummaryChange={onLabCaseSummaryChange}
onMarkedRead={onLabCaseMarkedRead}
/>
) : null}
{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');
}}
{canShowComments && activeLabCase?.id ? (
<DetailLabCaseCommentsSection
labCaseId={activeLabCase.id}
canPost={canPostComments}
onError={onCommentError}
onMarkRead={onLabCaseMarkedRead}
onActivityChange={onLabCaseActivityChange}
/>
) : null}
@@ -380,23 +458,25 @@ export function LabCasesDispatchPanel({
</div>
) : null}
{activeLabCase.id ? (
<LabCaseCommentsPanel
caseId={activeLabCase.id}
canPost={canEdit && !disabled}
canToggleVisibility={false}
deferSubmit
{hasTrackerSummary && labCaseSummary ? (
<LabCaseTrackerCard
summary={labCaseSummary}
locale={locale}
onSummaryChange={onLabCaseSummaryChange}
onMarkedRead={onLabCaseMarkedRead}
/>
) : null}
{canShowComments && activeLabCase?.id ? (
<DetailLabCaseCommentsSection
labCaseId={activeLabCase.id}
canPost={canPostComments}
deferSubmit={commentsDeferSubmit}
composerValue={pendingComment}
onComposerValueChange={setPendingComment}
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}
onMarkRead={onLabCaseMarkedRead}
onActivityChange={onLabCaseActivityChange}
/>
) : null}