improvement: treatment plan turned into a wizard. shift+click control added to FDI tooth chart for cunnected prosthesises.

This commit is contained in:
2026-07-17 00:39:32 +03:30
parent cf07b4d8a8
commit 68fc9d5d6d
28 changed files with 1461 additions and 477 deletions

View File

@@ -4,6 +4,7 @@ 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 { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge';
import { isDetailReadyForLabDispatch, isLabCaseCompleted } from '@/components/treatment/treatmentDetailRules';
import { AppDateInput } from '@/components/ui/shared/AppDateInput';
import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay';
@@ -19,6 +20,7 @@ import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesi
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
interface LabCasesDispatchPanelProps {
details: TreatmentDetailDraft[];
@@ -56,32 +58,50 @@ function sentDetailClientIds(labCases: LabCaseDraft[]): Set<string> {
return ids;
}
function prosthesisTeethRows(
type ProsthesisGroupRow = {
groupId: string;
kind: 'connected' | 'single';
teeth: string[];
detailClientId: string;
detailNumber: number;
};
function prosthesisGroupRows(
labCase: LabCaseDraft,
activeDetail: TreatmentDetailDraft,
detailNumber: number,
): Array<{ detailClientId: string; tooth: string; detailNumber: number }> {
): ProsthesisGroupRow[] {
if (labCase.detailClientId !== activeDetail.clientId) return [];
if (activeDetail.treatmentType !== 'prosthesis') return [];
return activeDetail.teeth.map((tooth) => ({
const groups =
activeDetail.toothSelectionGroups.length > 0
? activeDetail.toothSelectionGroups
: groupsFromFlatTeeth(activeDetail.teeth);
return groups.map((g) => ({
groupId: g.groupId,
kind: g.kind,
teeth: g.teeth,
detailClientId: activeDetail.clientId,
tooth,
detailNumber,
}));
}
function isProsthesisMapComplete(
labCase: LabCaseDraft,
rows: Array<{ detailClientId: string; tooth: string }>,
rows: ProsthesisGroupRow[],
): boolean {
if (rows.length === 0) return true;
return rows.every((row) =>
labCase.toothProsthesis.some(
(tp) =>
tp.detailClientId === row.detailClientId &&
tp.tooth === row.tooth &&
Boolean(tp.prosthesisTypeCode),
row.teeth.every((tooth) =>
labCase.toothProsthesis.some(
(tp) =>
tp.detailClientId === row.detailClientId &&
tp.tooth === tooth &&
tp.selectionGroupId === row.groupId &&
Boolean(tp.prosthesisTypeCode),
),
),
);
}
@@ -148,11 +168,12 @@ export function LabCasesDispatchPanel({
: null;
const prosthesisRows = activeLabCase && activeDetail
? prosthesisTeethRows(activeLabCase, activeDetail, activeDetailNumber)
? prosthesisGroupRows(activeLabCase, activeDetail, activeDetailNumber)
: [];
const prosthesisComplete = activeLabCase
? isProsthesisMapComplete(activeLabCase, prosthesisRows)
: true;
const flatToothCount = prosthesisRows.reduce((sum, row) => sum + row.teeth.length, 0);
useEffect(() => {
if (!activeLabCase?.destinationOrganizationId) {
@@ -196,28 +217,36 @@ export function LabCasesDispatchPanel({
);
}
function setToothProsthesis(
detailClientId: string,
tooth: string,
prosthesisTypeCode: string,
) {
function setGroupProsthesis(row: ProsthesisGroupRow, prosthesisTypeCode: string) {
if (!activeLabCase) return;
const toothSet = new Set(row.teeth);
const rest = activeLabCase.toothProsthesis.filter(
(tp) => !(tp.detailClientId === detailClientId && tp.tooth === tooth),
(tp) => !(tp.detailClientId === row.detailClientId && toothSet.has(tp.tooth)),
);
const next = prosthesisTypeCode
? [...rest, { detailClientId, tooth, prosthesisTypeCode }]
? [
...rest,
...row.teeth.map((tooth) => ({
detailClientId: row.detailClientId,
tooth,
prosthesisTypeCode,
selectionGroupId: row.groupId,
})),
]
: rest;
updateActiveLabCase({ toothProsthesis: next });
}
function applyProsthesisToAll(code: string) {
if (!activeLabCase || !code) return;
const next = prosthesisRows.map((row) => ({
detailClientId: row.detailClientId,
tooth: row.tooth,
prosthesisTypeCode: code,
}));
const next = prosthesisRows.flatMap((row) =>
row.teeth.map((tooth) => ({
detailClientId: row.detailClientId,
tooth,
prosthesisTypeCode: code,
selectionGroupId: row.groupId,
})),
);
updateActiveLabCase({ toothProsthesis: next });
}
@@ -316,49 +345,55 @@ export function LabCasesDispatchPanel({
);
}
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 selectionGroups =
activeDetail.toothSelectionGroups.length > 0
? activeDetail.toothSelectionGroups
: groupsFromFlatTeeth(activeDetail.teeth);
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]) => ({
const rows = selectionGroups.map((group) => {
const codes = new Set(
activeLabCase.toothProsthesis
.filter(
(tp) =>
tp.detailClientId === activeDetail.clientId &&
group.teeth.includes(tp.tooth as never) &&
tp.prosthesisTypeCode,
)
.map((tp) => tp.prosthesisTypeCode),
);
const code = codes.size === 1 ? [...codes][0] : '';
return {
groupId: group.groupId,
kind: group.kind,
teeth: group.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));
label: code
? prosthesisOptions.find((p) => p.code === code)?.label ?? code
: t('prosthesisUnassigned'),
};
});
return (
<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) => (
{rows.map((g) => (
<p
key={g.code}
key={g.groupId}
className="text-[13px]"
style={{ color: prosthesisTypeColorFromCatalog(g.code, prosthesisOptions) }}
style={{
color: g.code
? prosthesisTypeColorFromCatalog(g.code, prosthesisOptions)
: undefined,
}}
>
{g.kind === 'connected' ? (
<ConnectedSelectionBadge className="me-1 align-middle" />
) : null}
{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>
);
}
@@ -514,75 +549,82 @@ export function LabCasesDispatchPanel({
<p className="text-xs font-medium text-text-secondary">
{t('prosthesisTypesTitle')}
</p>
<label className="block text-xs text-text-muted space-y-1">
{t('prosthesisApplyAll')}
<select
value={applyAllProsthesis}
disabled={disabled || prosthesisOptions.length === 0}
onChange={(e) => {
const code = e.target.value;
setApplyAllProsthesis(code);
if (code) applyProsthesisToAll(code);
}}
className={`${FORM_SELECT_CLASS} w-full mt-1`}
>
<option value="">{t('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</label>
<div className="overflow-x-auto overscroll-x-contain">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted">
<th className="pb-2 pr-3 font-medium">{t('prosthesisColTooth')}</th>
<th className="pb-2 pr-3 font-medium">{t('prosthesisColDetail')}</th>
<th className="pb-2 font-medium">{t('prosthesisColType')}</th>
</tr>
</thead>
<tbody>
{prosthesisRows.map((row) => {
const current =
activeLabCase.toothProsthesis.find(
(tp) =>
tp.detailClientId === row.detailClientId &&
tp.tooth === row.tooth,
)?.prosthesisTypeCode ?? '';
return (
<tr key={`${row.detailClientId}-${row.tooth}`} className="border-t border-border/40">
<td className="py-2 pr-3 text-text-primary">{row.tooth}</td>
<td className="py-2 pr-3 text-text-secondary">
{t('detailLabel', { n: row.detailNumber })}
</td>
<td className="py-2">
<select
value={current}
disabled={disabled}
onChange={(e) =>
setToothProsthesis(
row.detailClientId,
row.tooth,
e.target.value,
)
}
className={`${FORM_SELECT_CLASS} w-full min-w-0 sm:min-w-[160px]`}
>
<option value="">{t('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</td>
</tr>
);
})}
</tbody>
</table>
{flatToothCount > 1 && prosthesisRows.every((r) => r.kind === 'single') ? (
<label className="block text-xs text-text-muted space-y-1">
{t('prosthesisApplyAll')}
<select
value={applyAllProsthesis}
disabled={disabled || prosthesisOptions.length === 0}
onChange={(e) => {
const code = e.target.value;
setApplyAllProsthesis(code);
if (code) applyProsthesisToAll(code);
}}
className={`${FORM_SELECT_CLASS} w-full mt-1`}
>
<option value="">{t('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</label>
) : null}
<div className="space-y-3">
{prosthesisRows.map((row) => {
const current =
activeLabCase.toothProsthesis.find(
(tp) =>
tp.detailClientId === row.detailClientId &&
tp.selectionGroupId === row.groupId &&
row.teeth.includes(tp.tooth),
)?.prosthesisTypeCode ??
activeLabCase.toothProsthesis.find(
(tp) =>
tp.detailClientId === row.detailClientId &&
row.teeth.includes(tp.tooth),
)?.prosthesisTypeCode ??
'';
return (
<label
key={row.groupId}
className="block text-xs text-text-muted space-y-1 rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/40 px-3 py-2"
>
<span className="flex flex-wrap items-center gap-2 text-text-secondary">
{row.kind === 'connected' ? <ConnectedSelectionBadge /> : null}
<span>
{row.kind === 'connected'
? t('prosthesisConnectedLabel')
: t('prosthesisColTooth')}
{': '}
<span className="text-text-primary">{row.teeth.join(', ')}</span>
</span>
<span className="text-text-muted">
· {t('detailLabel', { n: row.detailNumber })}
</span>
</span>
<select
value={current}
disabled={disabled}
onChange={(e) => setGroupProsthesis(row, e.target.value)}
className={`${FORM_SELECT_CLASS} w-full mt-1`}
aria-label={
row.kind === 'connected'
? t('prosthesisConnectedLabel')
: t('prosthesisColType')
}
>
<option value="">{t('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</label>
);
})}
</div>
</div>
) : null}