improvement: Some improvements done. some bugs fixed.

This commit is contained in:
2026-09-02 17:24:01 +03:30
parent a3c14a18c1
commit 7f92e735fb
32 changed files with 1092 additions and 315 deletions

View File

@@ -14,7 +14,7 @@ const PROSTHESIS_CATEGORY_ORDER = [
'digital',
] as const;
export const TOOTH_CATEGORIES = ['implant', 'crown', 'indirect', 'post_core'] as const;
export const TOOTH_CATEGORIES = ['implant', 'crown', 'indirect', 'post_core', 'removable'] as const;
export const ARCH_CATEGORIES = ['removable', 'appliance', 'digital'] as const;
/** Not FDI — jaw-level prosthesis rows on LabCaseToothProsthesis.tooth */
@@ -40,11 +40,32 @@ export function catalogByCode(
return new Map(catalog.map((entry) => [entry.code, entry]));
}
export const PARTIAL_DENTURE_CODE = 'partial_denture';
export function effectiveChartRegion(
entry: Pick<ProsthesisCatalogEntry, 'code' | 'chartRegion'>,
): string {
if (entry.code === PARTIAL_DENTURE_CODE) return 'crown';
return entry.chartRegion;
}
export function matchesPickerScope(
entry: Pick<ProsthesisCatalogEntry, 'chartRegion' | 'code'>,
scope?: PickerScope,
): boolean {
if (!scope) return true;
const region = effectiveChartRegion(entry);
if (scope === 'tooth') return region !== 'arch';
return region === 'arch';
}
export function categoryCodesInCatalog(
catalog: readonly ProsthesisCatalogEntry[],
scope?: PickerScope,
): string[] {
const present = new Set(catalog.map((e) => e.category));
const present = new Set(
catalog.filter((e) => matchesPickerScope(e, scope)).map((e) => e.category),
);
const order =
scope === 'tooth'
? TOOTH_CATEGORIES
@@ -57,10 +78,16 @@ export function categoryCodesInCatalog(
export function subcategoriesFor(
catalog: readonly ProsthesisCatalogEntry[],
category: string,
scope?: PickerScope,
): string[] {
const set = new Set(
catalog
.filter((e) => e.category === category && e.subcategory)
.filter(
(e) =>
e.category === category &&
e.subcategory &&
matchesPickerScope(e, scope),
)
.map((e) => e.subcategory),
);
return [...set].sort();
@@ -70,12 +97,14 @@ export function leavesFor(
catalog: readonly ProsthesisCatalogEntry[],
category: string,
subcategory?: string,
scope?: PickerScope,
): ProsthesisCatalogEntry[] {
return catalog
.filter((e) => {
if (e.category !== category) return false;
if (!matchesPickerScope(e, scope)) return false;
if (subcategory) return e.subcategory === subcategory;
const subs = subcategoriesFor(catalog, category);
const subs = subcategoriesFor(catalog, category, scope);
if (subs.length === 0) return true;
return !e.subcategory;
})
@@ -102,12 +131,6 @@ export function applyLeafToJobs(
let jobs = existingCodes.filter((code) => {
const cur = byCode.get(code);
if (!cur) return false;
if (next.code === 'screw_retained') {
return cur.stackGroup !== 'restoration' && cur.stackGroup !== 'implant';
}
if (cur.code === 'screw_retained' && (next.stackGroup === 'restoration' || next.stackGroup === 'implant')) {
return false;
}
return cur.stackGroup !== next.stackGroup;
});
@@ -119,10 +142,10 @@ export function jobsAllowImplantAddon(
jobCodes: readonly string[],
byCode: Map<string, ProsthesisCatalogEntry>,
): boolean {
if (jobCodes.includes('screw_retained')) return false;
const hasArchBlocking = jobCodes.some((code) => {
const e = byCode.get(code);
return e?.stackGroup === 'arch' && e.code !== 'overdenture';
if (!e || e.stackGroup !== 'arch') return false;
return e.code !== 'overdenture' && e.code !== PARTIAL_DENTURE_CODE;
});
if (hasArchBlocking) return false;
return true;
@@ -225,8 +248,9 @@ export function toothRegionColors(
const entry = byCode.get(row.prosthesisTypeCode);
const color = prosthesisTypeColorFromCatalog(row.prosthesisTypeCode, catalog);
const fdi = row.tooth as FdiToothId;
if (entry?.chartRegion === 'root') root[fdi] = color;
else if (entry?.chartRegion === 'arch') {
const region = entry ? effectiveChartRegion(entry) : 'crown';
if (region === 'root') root[fdi] = color;
else if (region === 'arch') {
crown[fdi] = crown[fdi] ?? color;
root[fdi] = root[fdi] ?? color;
} else {
@@ -309,29 +333,80 @@ function sortProsthesisTeeth(teeth: readonly string[]): string[] {
});
}
export type ProsthesisJobDisplayRow = {
teeth: string[];
codes: string[];
connected: boolean;
};
/**
* Dispatch / summary rows.
* Connected teeth that share a type collapse to one row (bridge). An extra type on
* one unit of that bridge is its own row. Unconnected teeth stay one row per tooth
* (stacked codes stay together).
*/
export function toothJobRowsForDetail(
rows: readonly Pick<
LabCaseToothProsthesisDraft,
'tooth' | 'prosthesisTypeCode' | 'detailClientId' | 'selectionGroupId'
>[],
detailClientId: string,
connectedGroupIds: ReadonlySet<string> = new Set(),
): Array<{ tooth: string; codes: string[]; connected: boolean }> {
const byTooth = new Map<string, { codes: string[]; connected: boolean }>();
): ProsthesisJobDisplayRow[] {
const byTooth = new Map<string, { codes: string[]; selectionGroupId: string }>();
for (const row of rows) {
if (row.detailClientId !== detailClientId || !row.prosthesisTypeCode) continue;
const entry = byTooth.get(row.tooth) ?? { codes: [], connected: false };
const entry = byTooth.get(row.tooth) ?? { codes: [], selectionGroupId: '' };
if (!entry.codes.includes(row.prosthesisTypeCode)) {
entry.codes.push(row.prosthesisTypeCode);
}
if (row.selectionGroupId && connectedGroupIds.has(row.selectionGroupId)) {
entry.connected = true;
const groupId = row.selectionGroupId?.trim() || '';
if (!entry.selectionGroupId && groupId) {
entry.selectionGroupId = groupId;
}
byTooth.set(row.tooth, entry);
}
return sortProsthesisTeeth([...byTooth.keys()]).map((tooth) => {
const entry = byTooth.get(tooth)!;
return { tooth, codes: entry.codes, connected: entry.connected };
const teethByGroup = new Map<string, string[]>();
for (const [tooth, entry] of byTooth) {
if (!entry.selectionGroupId) continue;
const list = teethByGroup.get(entry.selectionGroupId) ?? [];
list.push(tooth);
teethByGroup.set(entry.selectionGroupId, list);
}
const bridgeGroupIds = new Set(
[...teethByGroup.entries()]
.filter(([, teeth]) => teeth.length > 1)
.map(([groupId]) => groupId),
);
const connectedByType = new Map<string, { code: string; teeth: string[] }>();
const singles: ProsthesisJobDisplayRow[] = [];
for (const [tooth, entry] of byTooth) {
if (entry.selectionGroupId && bridgeGroupIds.has(entry.selectionGroupId)) {
for (const code of entry.codes) {
const key = `${entry.selectionGroupId}::${code}`;
const group = connectedByType.get(key) ?? { code, teeth: [] };
if (!group.teeth.includes(tooth)) group.teeth.push(tooth);
connectedByType.set(key, group);
}
continue;
}
singles.push({ teeth: [tooth], codes: entry.codes, connected: false });
}
const connectedRows: ProsthesisJobDisplayRow[] = [...connectedByType.values()].map((group) => {
const teeth = sortProsthesisTeeth(group.teeth);
return { teeth, codes: [group.code], connected: teeth.length > 1 };
});
return [...connectedRows, ...singles].sort((a, b) => {
const toothA = a.teeth[0] ?? '';
const toothB = b.teeth[0] ?? '';
if (toothA !== toothB) {
return sortProsthesisTeeth([toothA, toothB])[0] === toothA ? -1 : 1;
}
return (a.codes[0] ?? '').localeCompare(b.codes[0] ?? '');
});
}

View File

@@ -49,6 +49,84 @@ export function prosthesisTypeColorFromCatalog(
return prosthesisTypeColor(code, prosthesisCatalogColorIndex(code, catalog));
}
function hexToRgb(hex: string): [number, number, number] | null {
const raw = hex.replace('#', '').trim();
if (raw.length !== 6) return null;
const n = Number.parseInt(raw, 16);
if (Number.isNaN(n)) return null;
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
const rr = r / 255;
const gg = g / 255;
const bb = b / 255;
const max = Math.max(rr, gg, bb);
const min = Math.min(rr, gg, bb);
const l = (max + min) / 2;
if (max === min) return [0, 0, l];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === rr) h = ((gg - bb) / d + (gg < bb ? 6 : 0)) / 6;
else if (max === gg) h = ((bb - rr) / d + 2) / 6;
else h = ((rr - gg) / d + 4) / 6;
return [h, s, l];
}
function hslToHex(h: number, s: number, l: number): string {
const hue2rgb = (p: number, q: number, t: number) => {
let tt = t;
if (tt < 0) tt += 1;
if (tt > 1) tt -= 1;
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
if (tt < 1 / 2) return q;
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const r = Math.round(hue2rgb(p, q, h + 1 / 3) * 255);
const g = Math.round(hue2rgb(p, q, h) * 255);
const b = Math.round(hue2rgb(p, q, h - 1 / 3) * 255);
return `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`;
}
/** Darker same-hue tone for icons sitting on a pastel fill (readable in light and dark). */
export function prosthesisTypeFillAccent(hex: string): string {
const rgb = hexToRgb(hex);
if (!rgb) return '#334155';
const [h, s, l] = rgbToHsl(rgb[0], rgb[1], rgb[2]);
const nextS = Math.min(1, Math.max(s, 0.4) + 0.14);
const nextL = Math.min(0.38, Math.max(0.24, l * 0.42));
return hslToHex(h, nextS, nextL);
}
/** Boost washed catalog pastels so type-name text stays hued on page backgrounds. */
function prosthesisTypeLabelSwatch(hex: string): string {
const rgb = hexToRgb(hex);
if (!rgb) return hex;
const [h, s, l] = rgbToHsl(rgb[0], rgb[1], rgb[2]);
const nextS = Math.min(1, Math.max(s, 0.42) + 0.1);
const nextL = l > 0.72 ? 0.58 : l < 0.38 ? 0.5 : l;
return hslToHex(h, nextS, nextL);
}
/**
* Type-name color on page backgrounds. Mixes the catalog swatch with theme text
* so the hue stays recognizable in both light and dark.
*/
export function prosthesisTypeLabelStyleFromCatalog(
code: string,
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
): CSSProperties {
const swatch = prosthesisTypeLabelSwatch(prosthesisTypeColorFromCatalog(code, catalog));
return {
['--prosthesis-swatch' as string]: swatch,
color: 'color-mix(in srgb, var(--prosthesis-swatch) 80%, var(--color-text-primary))',
};
}
/** Filled swatch (small indicator dots). */
export function prosthesisTypeSwatchStyle(code: string, index = 0): CSSProperties {
return { backgroundColor: prosthesisTypeColor(code, index), borderColor: 'rgba(0, 0, 0, 0.18)' };

View File

@@ -170,11 +170,8 @@ export function CaseCreatePanel({
? activeLine.toothSelectionGroups
: groupsFromFlatTeeth(activeLine?.teeth ?? []);
const connectedGroupIds = new Set(
groups.filter((group) => group.kind === 'connected').map((group) => group.groupId),
);
const toothJobRows = activeLine
? toothJobRowsForDetail(activeLine.toothProsthesis, activeLine.clientId, connectedGroupIds)
? toothJobRowsForDetail(activeLine.toothProsthesis, activeLine.clientId)
: [];
const buildPayload = useCallback(

View File

@@ -18,10 +18,8 @@ import {
import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import { LabCaseOriginBadge } from '@/components/ui/lab/LabCaseOriginBadge';
import {
formatToothList,
prosthesisTypeBadgeStyleFromCatalog,
} from '@/components/treatment/prosthesisTypeDisplay';
import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay';
import { ProsthesisStackedTypeLabel } from '@/components/ui/lab/ProsthesisStackedTypeLabel';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import {
buildCaseConnectedTeeth,
@@ -313,23 +311,16 @@ export function CaseDetailPanel({
className="rounded-md border border-border p-3 space-y-2"
>
<div className="flex flex-wrap items-center gap-2">
<Badge
truncate
<ProsthesisStackedTypeLabel
className="max-w-[16rem] text-sm font-medium"
code={group.prosthesisTypeCode}
catalog={prosthesisCatalog}
title={group.prosthesisTypeLabel}
style={prosthesisTypeBadgeStyleFromCatalog(
group.prosthesisTypeCode,
prosthesisCatalog,
)}
>
{group.prosthesisTypeLabel}
</Badge>
<span className="text-sm font-medium text-text-primary">
{t('toothGroupTitle', {
teeth: formatToothList(group.teeth, {
UA: tTreatment('selectedArchUpper'),
LA: tTreatment('selectedArchLower'),
}),
prosthesis: group.prosthesisTypeLabel,
/>
<span className="text-sm text-text-secondary">
{formatToothList(group.teeth, {
UA: tTreatment('selectedArchUpper'),
LA: tTreatment('selectedArchLower'),
})}
</span>
{group.connected ? <ConnectedSelectionBadge /> : null}

View File

@@ -5,8 +5,9 @@ import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSele
import {
formatToothList,
prosthesisGroupLabelFromCatalog,
prosthesisTypeColorFromCatalog,
prosthesisTypeLabelStyleFromCatalog,
} from '@/components/treatment/prosthesisTypeDisplay';
import { ProsthesisStackedTypeLabel } from '@/components/ui/lab/ProsthesisStackedTypeLabel';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
export type LabCaseProsthesisGroup = {
@@ -23,7 +24,7 @@ interface LabCaseProsthesisGroupsListProps {
}
function prosthesisLabel(code: string, catalog: readonly ProsthesisCatalogEntry[]): string {
return prosthesisGroupLabelFromCatalog(code, catalog);
return catalog.find((entry) => entry.code === code)?.label ?? prosthesisGroupLabelFromCatalog(code, catalog);
}
export function LabCaseProsthesisGroupsList({
@@ -43,13 +44,12 @@ export function LabCaseProsthesisGroupsList({
<li
key={`${group.selectionGroupId ?? ''}-${group.prosthesisTypeCode}-${group.teeth.join(',')}`}
className="text-[11px] leading-snug"
style={{
color: prosthesisTypeColorFromCatalog(group.prosthesisTypeCode, prosthesisCatalog),
}}
>
<span className="font-medium">
{prosthesisLabel(group.prosthesisTypeCode, prosthesisCatalog)}
</span>
<ProsthesisStackedTypeLabel
className="font-medium"
code={group.prosthesisTypeCode}
catalog={prosthesisCatalog}
/>
{group.teeth.length > 0 ? (
<span className="text-text-muted">
{' · '}
@@ -73,7 +73,7 @@ export function LabCaseProsthesisGroupsList({
}
export type LabCaseToothJobRow = {
tooth: string;
teeth: string[];
codes: string[];
connected?: boolean;
};
@@ -98,11 +98,11 @@ export function LabCaseToothJobsList({
<ul className="space-y-1">
{rows.map((row) => (
<li
key={row.tooth}
key={`${row.teeth.join(',')}-${row.codes.join('+')}`}
className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-[11px] leading-snug"
>
<span className="font-semibold text-text-primary">
{formatToothList([row.tooth], archLabels)}
{formatToothList(row.teeth, archLabels)}
</span>
{row.codes.map((code) => (
<span key={code} className="inline-flex items-baseline gap-x-1.5">
@@ -111,7 +111,7 @@ export function LabCaseToothJobsList({
</span>
<span
className="font-medium"
style={{ color: prosthesisTypeColorFromCatalog(code, prosthesisCatalog) }}
style={prosthesisTypeLabelStyleFromCatalog(code, prosthesisCatalog)}
>
{prosthesisLabel(code, prosthesisCatalog)}
</span>

View File

@@ -0,0 +1,50 @@
'use client';
import {
prosthesisGroupLabelFromCatalog,
prosthesisTypeLabelStyleFromCatalog,
splitProsthesisGroupCode,
} from '@/components/treatment/prosthesisTypeDisplay';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
interface ProsthesisStackedTypeLabelProps {
code: string;
catalog: readonly ProsthesisCatalogEntry[];
className?: string;
title?: string;
}
export function ProsthesisStackedTypeLabel({
code,
catalog,
className,
title,
}: ProsthesisStackedTypeLabelProps) {
const parts = splitProsthesisGroupCode(code);
const fullTitle = title ?? prosthesisGroupLabelFromCatalog(code, catalog);
if (parts.length <= 1) {
const part = parts[0] ?? code;
return (
<span
className={className}
title={fullTitle}
style={prosthesisTypeLabelStyleFromCatalog(part, catalog)}
>
{catalog.find((entry) => entry.code === part)?.label ?? part}
</span>
);
}
return (
<span className={className} title={fullTitle}>
{parts.map((part, index) => (
<span key={`${part}-${index}`}>
{index > 0 ? <span className="text-text-muted"> + </span> : null}
<span style={prosthesisTypeLabelStyleFromCatalog(part, catalog)}>
{catalog.find((entry) => entry.code === part)?.label ?? part}
</span>
</span>
))}
</span>
);
}

View File

@@ -1,8 +1,8 @@
'use client';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/shared/Badge';
import { formatToothList, prosthesisTypeBadgeStyleFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay';
import { ProsthesisStackedTypeLabel } from '@/components/ui/lab/ProsthesisStackedTypeLabel';
import type { ProsthesisTaskGroup } from '@/components/lab/taskListGrouping';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
@@ -24,15 +24,12 @@ export function TaskProsthesisGroupHeader({
return (
<div className="flex flex-wrap items-center gap-2 px-3 py-1.5 bg-background-secondary/30 border-b border-border/40">
<Badge
fixedWidth={false}
truncate
<ProsthesisStackedTypeLabel
className="max-w-[16rem] text-xs font-medium"
code={group.prosthesisTypeCode}
catalog={prosthesisCatalog}
title={group.prosthesisTypeLabel}
style={prosthesisTypeBadgeStyleFromCatalog(group.prosthesisTypeCode, prosthesisCatalog)}
className="max-w-[10rem]"
>
{group.prosthesisTypeLabel}
</Badge>
/>
<span className="text-xs text-text-secondary">
{t('teethLabel', { teeth: formatToothList(group.teeth, archLabels) })}
</span>

View File

@@ -16,10 +16,8 @@ import { LabCaseOriginBadge } from '@/components/ui/lab/LabCaseOriginBadge';
import { isLabGeneratedCase } from '@/components/lab/labCaseOrigin';
import { formatAppDate, APP_DATE } from '@/lib/i18n/format';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import {
formatToothList,
prosthesisTypeBadgeStyleFromCatalog,
} from '@/components/treatment/prosthesisTypeDisplay';
import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay';
import { ProsthesisStackedTypeLabel } from '@/components/ui/lab/ProsthesisStackedTypeLabel';
import { tasksApi } from '@/lib/api/tasks';
import type { LabTaskListItem, LabTaskStatus } from '@/types/cases';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
@@ -207,18 +205,12 @@ export function TaskRow({
{t('showInCase')}
</Button>
) : null}
<Badge
fixedWidth={false}
truncate
<ProsthesisStackedTypeLabel
className="w-full max-w-[10rem] text-xs font-medium sm:w-[8rem]"
code={task.prosthesisTypeCode}
catalog={prosthesisCatalog}
title={task.prosthesisTypeLabel}
style={prosthesisTypeBadgeStyleFromCatalog(
task.prosthesisTypeCode,
prosthesisCatalog,
)}
className="w-full max-w-[8rem] sm:w-[7rem]"
>
{task.prosthesisTypeLabel}
</Badge>
/>
</div>
) : null}
</div>

View File

@@ -254,13 +254,8 @@ export function LabCasesDispatchPanel({
}
const typeLabel = treatmentTypeLabelFromCatalog(activeDetail.treatmentType, treatmentCatalog);
const connectedGroupIds = new Set(
(activeDetail.toothSelectionGroups ?? [])
.filter((group) => group.kind === 'connected')
.map((group) => group.groupId),
);
const toothJobRows = activeLabCase
? toothJobRowsForDetail(activeLabCase.toothProsthesis, activeDetailId, connectedGroupIds)
? toothJobRowsForDetail(activeLabCase.toothProsthesis, activeDetailId)
: [];
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];

View File

@@ -171,8 +171,31 @@ export function ProsthesisAssignChart({
if (!nextGroups || !union) return;
setRange(union);
commit(nextGroups, toothProsthesis);
setScope('tooth');
setAnchor(fdi);
setStep({ kind: 'category' });
setOpen(true);
return;
}
const isSelected = selected.has(fdi);
const hasJobs = codesOnTooth(toothProsthesis, detailClientId, fdi).length > 0;
const inPendingRange = Boolean(pendingRangeRef.current?.includes(fdi));
// Drop a tooth from an in-progress Shift range. A selected tooth with no jobs
// after hydrate must open the picker — otherwise reload looks like a deselect.
if (isSelected && !hasJobs && inPendingRange) {
const nextGroups = removeTeethFromGroups(groups, [fdi]);
const nextRows = clearJobsForTeeth(toothProsthesis, detailClientId, [fdi]);
const remainingRange = (pendingRangeRef.current ?? []).filter((tooth) => tooth !== fdi);
setRange(remainingRange.length > 1 ? remainingRange : null);
commit(nextGroups, nextRows);
if (anchor === fdi) {
setOpen(false);
setAnchor(null);
}
return;
}
openToothPicker(fdi);
}
@@ -256,11 +279,17 @@ export function ProsthesisAssignChart({
setOpen(false);
return;
}
const tooth = focusTooth;
if (!tooth) return;
const pending = pendingRangeRef.current;
const teeth =
pending && pending.length > 0
? pending
: focusTooth
? [focusTooth]
: [];
if (teeth.length === 0) return;
commit(
removeTeethFromGroups(groups, [tooth]),
clearJobsForTeeth(toothProsthesis, detailClientId, [tooth]),
removeTeethFromGroups(groups, teeth),
clearJobsForTeeth(toothProsthesis, detailClientId, teeth),
);
brushRef.current = [];
setOpen(false);
@@ -336,7 +365,10 @@ export function ProsthesisAssignChart({
onPickAddon={handlePickAddon}
onRemoveJob={handleRemoveJob}
onClear={handleClear}
onClose={() => setOpen(false)}
onClose={() => {
setOpen(false);
setRange(null);
}}
/>
}
onAssignPointer={handleAssignPointer}

View File

@@ -2,14 +2,14 @@
import type { ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { X } from 'lucide-react';
import { Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import {
ResponsiveDialogOverlay,
ResponsiveDialogPanel,
} from '@/components/ui/shared/ResponsiveDialog';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
import { prosthesisTypeColorFromCatalog, prosthesisTypeFillAccent } from '@/components/treatment/prosthesisTypeDisplay';
import {
addonLeaves,
categoryCodesInCatalog,
@@ -151,7 +151,7 @@ export function ProsthesisJobPopover({
const back = parentStep(step);
function openCategory(code: string) {
const subs = subcategoriesFor(catalog, code);
const subs = subcategoriesFor(catalog, code, scope);
const arch = scope === 'arch' ? lockedArch : undefined;
if (subs.length > 0) {
onStep({ kind: 'subcategory', category: code, arch });
@@ -264,7 +264,7 @@ export function ProsthesisJobPopover({
} else if (step.kind === 'leaves' && step.subcategory) {
selectedNode = { label: subLabel(step.subcategory) };
childrenKey = `leaves-${step.category}-${step.subcategory}`;
childTiles = leavesFor(catalog, step.category, step.subcategory).map(leafTile);
childTiles = leavesFor(catalog, step.category, step.subcategory, scope).map(leafTile);
} else {
selectedNode = {
label: categoryLabel(step.category),
@@ -274,7 +274,7 @@ export function ProsthesisJobPopover({
const arch = step.arch;
const subs =
step.kind === 'subcategory'
? subcategoriesFor(catalog, step.category).map((sub) => (
? subcategoriesFor(catalog, step.category, scope).map((sub) => (
<Tile
key={sub}
label={subLabel(sub)}
@@ -293,6 +293,7 @@ export function ProsthesisJobPopover({
catalog,
step.category,
step.kind === 'leaves' ? step.subcategory : undefined,
scope,
).map(leafTile);
childTiles = [...subs, ...leaves];
}
@@ -384,32 +385,39 @@ export function ProsthesisJobPopover({
{jobCodes.map((code) => {
const entry = byCode.get(code);
const color = prosthesisTypeColorFromCatalog(code, catalog);
const trashColor = prosthesisTypeFillAccent(color);
return (
<span
key={code}
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium"
className="inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border text-[11px] font-medium"
style={{
backgroundColor: color,
color: CHIP_INK,
borderColor: 'rgba(0,0,0,0.16)',
}}
>
<span className="opacity-70">
{entry?.category
? t(`category_${entry.category}` as never)
: t('regionCrown')}
<span className="inline-flex items-center gap-1 px-2 py-0.5">
<span className="opacity-70">
{entry?.category
? t(`category_${entry.category}` as never)
: t('regionCrown')}
</span>
{entry?.label ?? code}
</span>
{entry?.label ?? code}
<button
type="button"
className="rounded-full p-0.5 hover:bg-black/10"
className="inline-flex items-center justify-center border-s px-1.5 transition-colors hover:brightness-90"
style={{
borderColor: trashColor,
color: trashColor,
}}
aria-label={t('removeJob')}
onClick={(e) => {
e.stopPropagation();
onRemoveJob(code);
}}
>
<X className="h-3 w-3" aria-hidden />
<Trash2 className="lucide-inherit h-3.5 w-3.5" aria-hidden />
</button>
</span>
);

View File

@@ -329,12 +329,70 @@ function mergeServerIdsIntoDetails(
});
}
const PREVIEW_TREATMENT_ID = 'current-draft';
function isPreviewTreatment(treatment: PastTreatment): boolean {
return treatment.id === PREVIEW_TREATMENT_ID;
}
function serializeLabCases(drafts: LabCaseDraft[]): string {
return JSON.stringify(
[...drafts]
.sort((a, b) => a.clientId.localeCompare(b.clientId))
.map((lc) => ({
clientId: lc.clientId,
id: lc.id ?? null,
destinationOrganizationId: lc.destinationOrganizationId ?? null,
detailClientId: lc.detailClientId,
dueDate: lc.dueDate ?? null,
attachmentIds: [...lc.attachmentIds].sort(),
toothProsthesis: [...lc.toothProsthesis]
.map((tp) => ({
detailClientId: tp.detailClientId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
selectionGroupId: tp.selectionGroupId ?? '',
}))
.sort(
(a, b) =>
a.tooth.localeCompare(b.tooth) ||
a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode),
),
})),
);
}
function isLabCasesDirty(drafts: LabCaseDraft[], savedSnapshot: string | null): boolean {
if (savedSnapshot === null) {
return drafts.some(
(lc) =>
lc.toothProsthesis.length > 0 ||
Boolean(lc.destinationOrganizationId) ||
Boolean(lc.dueDate) ||
lc.attachmentIds.length > 0,
);
}
return serializeLabCases(drafts) !== savedSnapshot;
}
function mergeServerIdsIntoLabCases(
local: LabCaseDraft[],
fromServer: LabCaseDraft[],
): LabCaseDraft[] {
const byClientId = new Map(fromServer.map((lc) => [lc.clientId, lc]));
return local.map((lc) => {
const s = byClientId.get(lc.clientId);
if (!s) return lc;
return { ...lc, id: s.id ?? lc.id };
});
}
function detailsToPreviewTreatment(
details: TreatmentDetailDraft[],
meta: { title: string; patientId: string; treatmentAt: string; id?: string },
): PastTreatment {
return {
id: meta.id ?? 'current-draft',
id: meta.id ?? PREVIEW_TREATMENT_ID,
patientId: meta.patientId,
title: meta.title,
treatmentAt: meta.treatmentAt,
@@ -434,6 +492,7 @@ export function TreatmentWorkspace({
const [activeDetailId, setActiveDetailId] = useState<string>(() => details[0].clientId);
const [activeLabCaseId, setActiveLabCaseId] = useState<string | null>(null);
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
const [savedLabCasesSnapshot, setSavedLabCasesSnapshot] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'dirty' | 'saving' | 'saved' | 'error'>('idle');
const [selectedPreviewId, setSelectedPreviewId] = useState<string | null>(null);
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('live');
@@ -445,6 +504,8 @@ export function TreatmentWorkspace({
detailsRef.current = details;
const savedSnapshotRef = useRef(savedSnapshot);
savedSnapshotRef.current = savedSnapshot;
const savedLabCasesSnapshotRef = useRef(savedLabCasesSnapshot);
savedLabCasesSnapshotRef.current = savedLabCasesSnapshot;
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveInFlightRef = useRef(false);
const saveQueuedRef = useRef(false);
@@ -498,8 +559,10 @@ export function TreatmentWorkspace({
}, [labCaseDrafts, activeDetailId]);
const isDirty = useMemo(
() => isDetailsDirty(details, savedSnapshot),
[details, savedSnapshot],
() =>
isDetailsDirty(details, savedSnapshot) ||
isLabCasesDirty(labCaseDrafts, savedLabCasesSnapshot),
[details, savedSnapshot, labCaseDrafts, savedLabCasesSnapshot],
);
const AUTOSAVE_DEBOUNCE_MS = 600;
@@ -799,7 +862,11 @@ export function TreatmentWorkspace({
const mappedLabCases = withoutEmptyLabCaseDrafts(
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
);
const labSnap = serializeLabCases(mappedLabCases);
labCaseDraftsRef.current = mappedLabCases;
savedLabCasesSnapshotRef.current = labSnap;
setLabCaseDrafts(mappedLabCases);
setSavedLabCasesSnapshot(labSnap);
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
setSaveStatus('idle');
@@ -1158,7 +1225,11 @@ export function TreatmentWorkspace({
const mappedLabCases = withoutEmptyLabCaseDrafts(
(response.data?.labCases ?? []).map(mapLabCaseDraftFromApi),
);
const labSnap = serializeLabCases(mappedLabCases);
labCaseDraftsRef.current = mappedLabCases;
savedLabCasesSnapshotRef.current = labSnap;
setLabCaseDrafts(mappedLabCases);
setSavedLabCasesSnapshot(labSnap);
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
setSaveStatus('idle');
@@ -1241,12 +1312,15 @@ export function TreatmentWorkspace({
// If the user changed details while this save was in flight (e.g. removed a
// detail), do not clobber local state with the stale response.
if (serializeDetails(localNow) === sentSnapshot) {
detailsRef.current = mapped;
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : (mapped[0]?.clientId ?? '');
});
setSavedSnapshot(serializeDetails(mapped));
const snap = serializeDetails(mapped);
savedSnapshotRef.current = snap;
setSavedSnapshot(snap);
} else {
const merged = mergeServerIdsIntoDetails(localNow, mapped);
detailsRef.current = merged;
@@ -1262,6 +1336,111 @@ export function TreatmentWorkspace({
[selectedAppointment, selectedStandalone, t],
);
const persistLabCases = useCallback(
async (savedTreatment: PastTreatment, draftsOverride?: LabCaseDraft[]) => {
if (!selectedAppointment && !selectedStandalone) throw new Error('No visit selected');
if (isPreviewTreatment(savedTreatment)) {
return savedTreatment;
}
const drafts = draftsOverride ?? labCaseDraftsRef.current;
const detailIdByClientId = new Map(
savedTreatment.details.map((d) => [d.clientId, d.id]),
);
const payload = drafts
.map((lc) => {
if (!lc.detailClientId) return null;
const treatmentDetailId = detailIdByClientId.get(lc.detailClientId);
if (!treatmentDetailId) return null;
return {
clientId: lc.clientId,
id: lc.id,
destinationOrganizationId: lc.destinationOrganizationId ?? undefined,
treatmentDetailId,
toothProsthesis: lc.toothProsthesis
.map((tp) => {
const detailId = detailIdByClientId.get(tp.detailClientId);
if (!detailId) return null;
return {
treatmentDetailId: detailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
selectionGroupId: tp.selectionGroupId ?? '',
};
})
.filter(
(
row,
): row is {
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId: string;
} => row !== null,
),
attachmentIds: lc.attachmentIds,
dueDate: lc.dueDate ?? null,
};
})
.filter((row): row is NonNullable<typeof row> => row !== null);
const localJobCount = drafts.reduce((n, lc) => n + lc.toothProsthesis.length, 0);
const payloadJobCount = payload.reduce((n, lc) => n + lc.toothProsthesis.length, 0);
if (payloadJobCount < localJobCount) {
throw new Error('Lab case jobs could not be mapped to saved details');
}
const applySavedDrafts = (mapped: LabCaseDraft[]) => {
const sent = serializeLabCases(drafts);
const localNow = labCaseDraftsRef.current;
if (serializeLabCases(localNow) === sent) {
const snap = serializeLabCases(mapped);
labCaseDraftsRef.current = mapped;
savedLabCasesSnapshotRef.current = snap;
setLabCaseDrafts(mapped);
setSavedLabCasesSnapshot(snap);
setActiveLabCaseId((prev) => {
if (prev && mapped.some((lc) => lc.clientId === prev)) return prev;
return mapped[0]?.clientId ?? null;
});
return;
}
const merged = mergeServerIdsIntoLabCases(localNow, mapped);
labCaseDraftsRef.current = merged;
setLabCaseDrafts(merged);
};
const emptySnap = serializeLabCases([]);
if (payload.length === 0) {
if (drafts.length > 0) {
throw new Error('Lab case drafts could not be mapped to saved details');
}
if (
!savedLabCasesSnapshotRef.current ||
savedLabCasesSnapshotRef.current === emptySnap
) {
savedLabCasesSnapshotRef.current = emptySnap;
setSavedLabCasesSnapshot(emptySnap);
return savedTreatment;
}
}
const response = selectedAppointment
? await treatmentsApi.saveLabCases(selectedAppointment.id, {
labCases: payload,
})
: await treatmentsApi.saveLabCasesByTreatment(selectedStandalone!.id, {
labCases: payload,
});
const mapped = withoutEmptyLabCaseDrafts(response.data.labCases.map(mapLabCaseDraftFromApi));
applySavedDrafts(mapped);
return response.data;
},
[selectedAppointment, selectedStandalone],
);
const refreshHistory = useCallback(async (patientId: string, options?: { silentLabCases?: boolean }) => {
const requestId = ++historyRequestRef.current;
try {
@@ -1281,17 +1460,28 @@ export function TreatmentWorkspace({
return;
}
if (
!isDetailsDirty(detailsRef.current, savedSnapshotRef.current) ||
!areDetailsPersistable(detailsRef.current)
) {
const detailsDirty = isDetailsDirty(detailsRef.current, savedSnapshotRef.current);
const labDirty = isLabCasesDirty(
labCaseDraftsRef.current,
savedLabCasesSnapshotRef.current,
);
if (!detailsDirty && !labDirty) {
return;
}
if (!areDetailsPersistable(detailsRef.current)) {
return;
}
saveInFlightRef.current = true;
setSaveStatus('saving');
const draftsAtSave = labCaseDraftsRef.current;
try {
await persistDraft();
const saved = await persistDraft({ force: labDirty && !detailsDirty });
if (isPreviewTreatment(saved)) {
setSaveStatus('dirty');
return;
}
await persistLabCases(saved, draftsAtSave);
setSaveStatus('saved');
if (historyPatientId) {
await refreshHistory(historyPatientId);
@@ -1304,12 +1494,15 @@ export function TreatmentWorkspace({
saveInFlightRef.current = false;
if (saveQueuedRef.current) {
saveQueuedRef.current = false;
if (isDetailsDirty(detailsRef.current, savedSnapshotRef.current)) {
if (
isDetailsDirty(detailsRef.current, savedSnapshotRef.current) ||
isLabCasesDirty(labCaseDraftsRef.current, savedLabCasesSnapshotRef.current)
) {
void runDraftSave();
}
}
}
}, [hasLiveContext, persistDraft, showError, t, historyPatientId, refreshHistory]);
}, [hasLiveContext, persistDraft, persistLabCases, showError, t, historyPatientId, refreshHistory]);
const flushDraftSave = useCallback(async (): Promise<boolean> => {
if (autosaveTimerRef.current) {
@@ -1325,7 +1518,10 @@ export function TreatmentWorkspace({
await new Promise((resolve) => setTimeout(resolve, 50));
}
if (!isDetailsDirty(detailsRef.current, savedSnapshotRef.current)) {
if (
!isDetailsDirty(detailsRef.current, savedSnapshotRef.current) &&
!isLabCasesDirty(labCaseDraftsRef.current, savedLabCasesSnapshotRef.current)
) {
return true;
}
@@ -1850,79 +2046,11 @@ export function TreatmentWorkspace({
[canEditTreatmentForDay, isDetailLocked, hasLiveContext, selectedAppointment, selectedStandalone, showSuccess, showError, t, tErrors],
);
const persistLabCases = useCallback(
async (savedTreatment: PastTreatment, draftsOverride?: LabCaseDraft[]) => {
if (!selectedAppointment && !selectedStandalone) throw new Error('No visit selected');
const drafts = draftsOverride ?? labCaseDrafts;
const detailIdByClientId = new Map(
savedTreatment.details.map((d) => [d.clientId, d.id]),
);
const payload = drafts
.map((lc) => {
if (!lc.detailClientId) return null;
const treatmentDetailId = detailIdByClientId.get(lc.detailClientId);
if (!treatmentDetailId) return null;
return {
clientId: lc.clientId,
id: lc.id,
destinationOrganizationId: lc.destinationOrganizationId ?? undefined,
treatmentDetailId,
toothProsthesis: lc.toothProsthesis
.map((tp) => {
const detailId = detailIdByClientId.get(tp.detailClientId);
if (!detailId) return null;
return {
treatmentDetailId: detailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
selectionGroupId: tp.selectionGroupId ?? '',
};
})
.filter(
(
row,
): row is {
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId: string;
} => row !== null,
),
attachmentIds: lc.attachmentIds,
dueDate: lc.dueDate ?? null,
};
})
.filter((row): row is NonNullable<typeof row> => row !== null);
if (payload.length === 0) {
return savedTreatment;
}
const response = selectedAppointment
? await treatmentsApi.saveLabCases(selectedAppointment.id, {
labCases: payload,
})
: await treatmentsApi.saveLabCasesByTreatment(selectedStandalone!.id, {
labCases: payload,
});
const mapped = withoutEmptyLabCaseDrafts(response.data.labCases.map(mapLabCaseDraftFromApi));
setLabCaseDrafts(mapped);
setActiveLabCaseId((prev) => {
if (prev && mapped.some((lc) => lc.clientId === prev)) return prev;
return mapped[0]?.clientId ?? null;
});
return response.data;
},
[labCaseDrafts, selectedAppointment, selectedStandalone],
);
const handleLabCasesChange = useCallback(
(next: LabCaseDraft[]) => {
const prevCleaned = withoutEmptyLabCaseDrafts(labCaseDrafts);
const cleaned = withoutEmptyLabCaseDrafts(next);
labCaseDraftsRef.current = cleaned;
setLabCaseDrafts(cleaned);
if (!cleaned.some((lc) => lc.detailClientId === activeDetailId)) {
@@ -2036,9 +2164,7 @@ export function TreatmentWorkspace({
const updatedLabCases = [...labCaseDrafts, draft];
setLabCaseDrafts(updatedLabCases);
// Autosave only watches `details`, so a lab draft left in state alone loses the
// lab, the due date and the prosthesis map on reload — silently, because the
// detail itself survives.
// Persist the new detail first so lab-case rows can use real treatmentDetailIds.
void (async () => {
try {
const saved = await persistDraft({ force: true });

View File

@@ -388,6 +388,12 @@ select option {
stroke: currentColor;
}
/* Inherit the parent color (job-chip trash, catalog-tinted controls). */
.lucide.lucide-inherit {
color: inherit;
stroke: currentColor;
}
@keyframes picker-expand {
from {
opacity: 0;