improvement: treatment plan turned into a wizard. shift+click control added to FDI tooth chart for cunnected prosthesises.
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user