improvement: all confirmed suggestions implemented

This commit is contained in:
2026-09-04 17:04:48 +03:30
parent 7f92e735fb
commit 72f885d9dd
23 changed files with 1070 additions and 679 deletions

View File

@@ -757,6 +757,8 @@
"noOrgInvitePermission": "You do not have permission to invite labs. Contact your organization owner.",
"sendThisCase": "Send this case",
"labDispatchTitle": "Lab dispatch",
"showLabDispatch": "Lab dispatch",
"showChart": "Chart",
"labDispatchSubtitle": "Choose a lab and send.",
"labDispatchSendHint": "Shipment grouping is saved when you send to the lab.",
"addLabShipment": "Add lab shipment",
@@ -898,6 +900,7 @@
"selectedArchLower": "Lower arch",
"upperArch": "Upper arch",
"lowerArch": "Lower arch",
"archAssignHint": "Click to assign a jaw-level product",
"toothAria": "FDI tooth {fdi}",
"toothSelectedSuffix": ", selected",
"sentToAt": "Sent to {orgName} at {datetime}",
@@ -1120,6 +1123,7 @@
"sub_partial_denture": "Partial denture",
"sub_night_guard": "Night guard",
"addonNone": "None",
"addonCrownCanBeAdded": "Crown can be added",
"clear": "Clear this tooth",
"done": "Done",
"pickerTeeth": "Teeth {teeth}",
@@ -1133,8 +1137,10 @@
"removeJob": "Remove this type",
"regionCrown": "Crown",
"pickerTooth": "Tooth {tooth}",
"slotImplant": "Implant add-on",
"slotPostCore": "Post & core"
"slotCrown": "Crown",
"suggestionsLabel": "Suggested add-ons",
"pickerBack": "Back",
"categoryDisabledHint": "This category cannot be combined with the types already on this tooth."
},
"errors": {
"GENERIC": "Something went wrong. Please try again.",

View File

@@ -758,6 +758,8 @@
"noOrgInvitePermission": "شما مجوز دعوت از آزمایشگاه را ندارید. با مالک سازمان تماس بگیرید.",
"sendThisCase": "ارسال این پرونده",
"labDispatchTitle": "ارسال به لابراتوار",
"showLabDispatch": "ارسال به لابراتوار",
"showChart": "نمودار",
"labDispatchSubtitle": "لابراتوار را انتخاب کنید و ارسال کنید.",
"labDispatchSendHint": "گروه‌بندی محموله هنگام ارسال به لاب ذخیره می‌شود.",
"addLabShipment": "افزودن محموله لاب",
@@ -899,6 +901,7 @@
"selectedArchLower": "قوس پایین",
"upperArch": "قوس بالا",
"lowerArch": "قوس پایین",
"archAssignHint": "برای اختصاص محصول فکی کلیک کنید",
"toothAria": "دندان FDI {fdi}",
"toothSelectedSuffix": "، انتخاب شده",
"sentToAt": "ارسال به {orgName} در {datetime}",
@@ -1121,6 +1124,7 @@
"sub_partial_denture": "پارسیل",
"sub_night_guard": "نایت گارد",
"addonNone": "هیچ‌کدام",
"addonCrownCanBeAdded": "روکش می‌تواند افزوده شود",
"clear": "پاک کردن این دندان",
"done": "تمام",
"pickerTeeth": "دندان‌های {teeth}",
@@ -1134,8 +1138,10 @@
"removeJob": "حذف این نوع",
"regionCrown": "تاج",
"pickerTooth": "دندان {tooth}",
"slotImplant": "افزونه ایمپلنت",
"slotPostCore": "پست و کور"
"slotCrown": "روکش",
"suggestionsLabel": "افزودنی‌های پیشنهادی",
"pickerBack": "بازگشت",
"categoryDisabledHint": "این دسته با انواعی که روی این دندان هستند قابل ترکیب نیست."
},
"errors": {
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",

View File

@@ -757,6 +757,8 @@
"noOrgInvitePermission": "U heeft geen toestemming om labs uit te nodigen. Neem contact op met de organisatie-eigenaar.",
"sendThisCase": "Verzend deze case",
"labDispatchTitle": "Lab-dispatch",
"showLabDispatch": "Lab-dispatch",
"showChart": "Gebitskaart",
"labDispatchSubtitle": "Kies een lab en verstuur.",
"labDispatchSendHint": "Groepering wordt opgeslagen wanneer u naar het lab verzendt.",
"addLabShipment": "Labzending toevoegen",
@@ -898,6 +900,7 @@
"selectedArchLower": "Onderboog",
"upperArch": "Bovenboog",
"lowerArch": "Onderboog",
"archAssignHint": "Klik om een boogproduct toe te wijzen",
"toothAria": "FDI-tand {fdi}",
"toothSelectedSuffix": ", geselecteerd",
"sentToAt": "Verzonden naar {orgName} op {datetime}",
@@ -1120,6 +1123,7 @@
"sub_partial_denture": "Partiële prothese",
"sub_night_guard": "Nachtbeugel",
"addonNone": "Geen",
"addonCrownCanBeAdded": "Kroon kan worden toegevoegd",
"clear": "Deze tand wissen",
"done": "Klaar",
"pickerTeeth": "Tanden {teeth}",
@@ -1133,8 +1137,10 @@
"removeJob": "Dit type verwijderen",
"regionCrown": "Kroon",
"pickerTooth": "Tand {tooth}",
"slotImplant": "Implantaat-add-on",
"slotPostCore": "Stiftopbouw"
"slotCrown": "Kroon",
"suggestionsLabel": "Voorgestelde toevoegingen",
"pickerBack": "Terug",
"categoryDisabledHint": "Deze categorie kan niet worden gecombineerd met de types die al op deze tand staan."
},
"errors": {
"GENERIC": "Er is iets misgegaan. Probeer het opnieuw.",

View File

@@ -20,61 +20,124 @@ export const TREATMENT_TYPE_COLORS: Record<string, string> = {
continue_treatment: '#99f6e4',
};
/** Dedicated prosthesis palette — one distinct pastel per catalog code. */
export const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
pfm_crown: '#e2e8f0',
pfz_crown: '#bbf7d0',
monolithic_zirconia: '#bae6fd',
glass_ceramic_crown: '#fef08a',
full_metal_crown: '#d4d4d8',
temporary_resin_crown: '#ddd6fe',
pmma: '#fcd34d',
peek_crown: '#fda4af',
veneer_zirconia: '#86efac',
veneer_ips_press: '#fed7aa',
veneer_ips_cad: '#fdba74',
soft_structure: '#e9d5ff',
customized_abutment: '#a5b4fc',
prefabricated_abutment: '#c7d2fe',
ti_base_abutment: '#93c5fd',
multi_unit_abutment: '#818cf8',
zirconia_abutment: '#4ade80',
screw_retained: '#f0abfc',
zirconia_overlay: '#2dd4bf',
ips_overlay: '#fef3c7',
smile_design: '#f9a8d4',
mockup: '#fbcfe8',
press_ceramic: '#fde68a',
veneer_full_contour: '#fdba74',
veneer_layered: '#86efac',
inlay_full_contour: '#fcd34d',
inlay_layered: '#bbf7d0',
onlay_full_contour: '#fed7aa',
onlay_layered: '#a7f3d0',
overlay_full_contour: '#fef3c7',
overlay_layered: '#2dd4bf',
cast_post_core: '#d6d3d1',
fiber_post_core: '#e7e5e4',
complete_denture: '#c4b5fd',
partial_denture: '#ddd6fe',
overdenture: '#a78bfa',
night_guard_soft: '#e9d5ff',
night_guard_hard: '#d8b4fe',
night_guard_dual: '#c084fc',
bleaching_tray: '#a5f3fc',
clear_aligner: '#67e8f9',
surgical_guide: '#94a3b8',
/** Category tiles — heaviest (darkest) pastel of each family; leaves lighten from here. */
export const PROSTHESIS_CATEGORY_COLORS: Record<string, string> = {
crown: '#e6c84f',
indirect: '#f59e4c',
implant: '#38bdf8',
post_core: '#a8a29e',
removable: '#a78bfa',
appliance: '#d8b4fe',
digital: '#f472b6',
};
/** Category tiles in the assign overlay (not per-leaf). */
export const PROSTHESIS_CATEGORY_COLORS: Record<string, string> = {
crown: '#bae6fd',
indirect: '#fdba74',
implant: '#a5b4fc',
post_core: '#d6d3d1',
removable: '#c4b5fd',
appliance: '#e9d5ff',
digital: '#f9a8d4',
function hexToRgbChannels(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 rgbToHslChannels(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 hslToHexChannels(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('')}`;
}
function clamp01(n: number): number {
return Math.min(1, Math.max(0, n));
}
/** Same-hue sibling of a category pastel. Index 0 is the parent (heaviest); higher indices only lighten. */
export function prosthesisFamilyTone(parentHex: string, siblingIndex: number): string {
if (siblingIndex <= 0) return parentHex;
const rgb = hexToRgbChannels(parentHex);
if (!rgb) return parentHex;
const [h, s, l] = rgbToHslChannels(rgb[0], rgb[1], rgb[2]);
const nextL = clamp01(Math.min(0.9, l + siblingIndex * 0.038));
const nextS = clamp01(s - siblingIndex * 0.025);
return hslToHexChannels(h, nextS, nextL);
}
export function prosthesisCategoryFamilyColor(category: string, siblingIndex = 0): string {
const parent = PROSTHESIS_CATEGORY_COLORS[category] ?? '#e2e8f0';
return prosthesisFamilyTone(parent, siblingIndex);
}
/** Dedicated prosthesis palette — family tones (legacy map kept as fallback). */
export const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
pfm_crown: prosthesisCategoryFamilyColor('crown', 1),
pfz_crown: prosthesisCategoryFamilyColor('crown', 2),
monolithic_zirconia: prosthesisCategoryFamilyColor('crown', 3),
glass_ceramic_crown: prosthesisCategoryFamilyColor('crown', 4),
full_metal_crown: prosthesisCategoryFamilyColor('crown', 5),
temporary_resin_crown: prosthesisCategoryFamilyColor('crown', 6),
pmma: prosthesisCategoryFamilyColor('crown', 7),
peek_crown: prosthesisCategoryFamilyColor('crown', 8),
press_ceramic: prosthesisCategoryFamilyColor('crown', 9),
veneer_zirconia: prosthesisCategoryFamilyColor('indirect', 1),
veneer_ips_press: prosthesisCategoryFamilyColor('indirect', 2),
veneer_ips_cad: prosthesisCategoryFamilyColor('indirect', 3),
zirconia_overlay: prosthesisCategoryFamilyColor('indirect', 4),
ips_overlay: prosthesisCategoryFamilyColor('indirect', 5),
veneer_full_contour: prosthesisCategoryFamilyColor('indirect', 1),
veneer_layered: prosthesisCategoryFamilyColor('indirect', 2),
inlay_full_contour: prosthesisCategoryFamilyColor('indirect', 3),
inlay_layered: prosthesisCategoryFamilyColor('indirect', 4),
onlay_full_contour: prosthesisCategoryFamilyColor('indirect', 5),
onlay_layered: prosthesisCategoryFamilyColor('indirect', 6),
overlay_full_contour: prosthesisCategoryFamilyColor('indirect', 7),
overlay_layered: prosthesisCategoryFamilyColor('indirect', 8),
customized_abutment: prosthesisCategoryFamilyColor('implant', 1),
prefabricated_abutment: prosthesisCategoryFamilyColor('implant', 2),
ti_base_abutment: prosthesisCategoryFamilyColor('implant', 3),
multi_unit_abutment: prosthesisCategoryFamilyColor('implant', 4),
zirconia_abutment: prosthesisCategoryFamilyColor('implant', 5),
screw_retained: prosthesisCategoryFamilyColor('implant', 6),
cast_post_core: prosthesisCategoryFamilyColor('post_core', 1),
fiber_post_core: prosthesisCategoryFamilyColor('post_core', 2),
complete_denture: prosthesisCategoryFamilyColor('removable', 1),
partial_denture: prosthesisCategoryFamilyColor('removable', 2),
overdenture: prosthesisCategoryFamilyColor('removable', 3),
night_guard_soft: prosthesisCategoryFamilyColor('appliance', 1),
night_guard_hard: prosthesisCategoryFamilyColor('appliance', 2),
night_guard_dual: prosthesisCategoryFamilyColor('appliance', 3),
bleaching_tray: prosthesisCategoryFamilyColor('appliance', 4),
clear_aligner: prosthesisCategoryFamilyColor('appliance', 5),
soft_structure: prosthesisCategoryFamilyColor('appliance', 6),
smile_design: prosthesisCategoryFamilyColor('digital', 1),
mockup: prosthesisCategoryFamilyColor('digital', 2),
surgical_guide: prosthesisCategoryFamilyColor('digital', 3),
};
export const CATALOG_FALLBACK_COLORS = [

View File

@@ -2,7 +2,11 @@ import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { FdiToothId, LabCaseToothProsthesisDraft, ToothSelectionGroup } from '@/types/treatment';
import { PROSTHESIS_CATEGORY_COLORS } from '@/components/shared/catalog-type-colors';
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
import { groupsFromFlatTeeth, newGroupId } from '@/components/treatment/toothSelectionGroups';
import {
deriveTeethFromGroups,
groupsFromFlatTeeth,
newGroupId,
} from '@/components/treatment/toothSelectionGroups';
const PROSTHESIS_CATEGORY_ORDER = [
'implant',
@@ -111,13 +115,35 @@ export function leavesFor(
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
}
export function addonLeaves(
catalog: readonly ProsthesisCatalogEntry[],
kind: 'implant' | 'post_core',
): ProsthesisCatalogEntry[] {
return catalog
.filter((e) => e.addonKind === kind)
.sort((a, b) => a.sortOrder - b.sortOrder);
export const SCREW_RETAINED_CODE = 'screw_retained';
export type AddonSlot = 'crown';
export function jobsHaveStackGroup(
jobCodes: readonly string[],
byCode: Map<string, ProsthesisCatalogEntry>,
group: string,
): boolean {
return jobCodes.some((code) => byCode.get(code)?.stackGroup === group);
}
export function canStackLeaf(
existingCodes: readonly string[],
nextCode: string,
byCode: Map<string, ProsthesisCatalogEntry>,
): boolean {
const next = byCode.get(nextCode);
if (!next) return false;
const hasRestoration = jobsHaveStackGroup(existingCodes, byCode, 'restoration');
const hasImplant = jobsHaveStackGroup(existingCodes, byCode, 'implant');
const hasPost = jobsHaveStackGroup(existingCodes, byCode, 'post_core');
if (next.stackGroup === 'post_core') return !hasRestoration && !hasImplant;
if (next.stackGroup === 'implant') return !hasPost;
// Screw-retained already paints the crown — do not stack a second restoration.
if (next.stackGroup === 'restoration' && existingCodes.includes(SCREW_RETAINED_CODE)) {
return false;
}
return true;
}
export function applyLeafToJobs(
@@ -127,36 +153,115 @@ export function applyLeafToJobs(
): string[] {
const next = byCode.get(nextCode);
if (!next) return [...existingCodes];
if (!canStackLeaf(existingCodes, nextCode, byCode)) return [...existingCodes];
let jobs = existingCodes.filter((code) => {
const jobs = existingCodes.filter((code) => {
const cur = byCode.get(code);
if (!cur) return false;
return cur.stackGroup !== next.stackGroup;
if (cur.stackGroup === next.stackGroup) return false;
if (nextCode === SCREW_RETAINED_CODE && cur.stackGroup === 'restoration') return false;
return true;
});
jobs.push(nextCode);
return jobs;
}
export function jobsAllowImplantAddon(
export function crownRestorationCode(
jobCodes: readonly string[],
byCode: Map<string, ProsthesisCatalogEntry>,
): boolean {
const hasArchBlocking = jobCodes.some((code) => {
const e = byCode.get(code);
if (!e || e.stackGroup !== 'arch') return false;
return e.code !== 'overdenture' && e.code !== PARTIAL_DENTURE_CODE;
): string | undefined {
return jobCodes.find((code) => {
const entry = byCode.get(code);
return entry?.category === 'crown' && entry.stackGroup === 'restoration';
});
if (hasArchBlocking) return false;
return true;
}
export function jobsAllowPostCoreAddon(
/**
* Crown suggestion slot after implant or post & core.
* Stays visible when the restoration is a crown (filled chip). Hidden for
* screw-retained and for non-crown restorations (veneer / inlay / onlay / overlay).
*/
export function jobsAllowCrownAddon(
jobCodes: readonly string[],
byCode: Map<string, ProsthesisCatalogEntry>,
): boolean {
if (jobCodes.includes('screw_retained')) return false;
return jobCodes.some((code) => byCode.get(code)?.stackGroup === 'restoration');
if (jobCodes.length === 0) return false;
if (jobCodes.includes(SCREW_RETAINED_CODE)) return false;
if (
!jobsHaveStackGroup(jobCodes, byCode, 'implant') &&
!jobsHaveStackGroup(jobCodes, byCode, 'post_core')
) {
return false;
}
const restoration = jobCodes.find((code) => byCode.get(code)?.stackGroup === 'restoration');
if (!restoration) return true;
return byCode.get(restoration)?.category === 'crown';
}
export function categoryDisabledForJobs(
category: string,
jobCodes: readonly string[],
byCode: Map<string, ProsthesisCatalogEntry>,
): boolean {
const hasRestoration = jobsHaveStackGroup(jobCodes, byCode, 'restoration');
const hasImplant = jobsHaveStackGroup(jobCodes, byCode, 'implant');
const hasPost = jobsHaveStackGroup(jobCodes, byCode, 'post_core');
if (category === 'post_core') return hasRestoration || hasImplant;
if (category === 'implant') return hasPost;
if (
jobCodes.includes(SCREW_RETAINED_CODE) &&
(category === 'crown' || category === 'indirect')
) {
return true;
}
return false;
}
export function fdiTeethWithJobs(
rows: readonly Pick<LabCaseToothProsthesisDraft, 'tooth' | 'prosthesisTypeCode' | 'detailClientId'>[],
detailClientId: string,
): Set<string> {
const teeth = new Set<string>();
for (const row of rows) {
if (
row.detailClientId === detailClientId &&
row.prosthesisTypeCode &&
!isArchSentinel(row.tooth)
) {
teeth.add(row.tooth);
}
}
return teeth;
}
export function pruneGroupsToJobTeeth(
groups: ToothSelectionGroup[],
jobTeeth: Set<string>,
): ToothSelectionGroup[] {
const extra = groups.flatMap((g) => g.teeth).filter((tooth) => !jobTeeth.has(tooth));
if (extra.length === 0) return groups;
return removeTeethFromGroups(groups, extra);
}
export function pruneDetailTeethToJobs<
T extends {
clientId: string;
teeth: readonly string[];
toothSelectionGroups: ToothSelectionGroup[];
},
>(detail: T, jobs: readonly LabCaseToothProsthesisDraft[]): T {
const jobTeeth = fdiTeethWithJobs(jobs, detail.clientId);
const groups =
detail.toothSelectionGroups.length > 0
? detail.toothSelectionGroups
: groupsFromFlatTeeth(detail.teeth as FdiToothId[]);
const nextGroups = pruneGroupsToJobTeeth(groups, jobTeeth);
return {
...detail,
toothSelectionGroups: nextGroups,
teeth: deriveTeethFromGroups(nextGroups),
};
}
export function codesOnTooth(
@@ -295,9 +400,8 @@ export function writeArchJobs(
arch: ArchTarget,
code: string,
): LabCaseToothProsthesisDraft[] {
const replace = new Set(archSentinels(arch));
const rest = rows.filter(
(row) => !(row.detailClientId === detailClientId && replace.has(row.tooth)),
(row) => !(row.detailClientId === detailClientId && isArchSentinel(row.tooth)),
);
const next = archSentinels(arch).map((tooth) => ({
detailClientId,
@@ -308,6 +412,23 @@ export function writeArchJobs(
return [...rest, ...next];
}
/** Move / copy / drop UA·LA rows so they match the segmented arch control. */
export function retargetArchJobs(
rows: LabCaseToothProsthesisDraft[],
detailClientId: string,
from: ArchTarget,
to: ArchTarget,
): LabCaseToothProsthesisDraft[] {
if (from === to) return rows;
const keep = archSentinels(to).filter((tooth) => archSentinels(from).includes(tooth));
const code =
uniqueCodesOnGroup(rows, detailClientId, keep)[0] ??
uniqueCodesOnGroup(rows, detailClientId, archSentinels(from))[0] ??
uniqueCodesOnGroup(rows, detailClientId, [ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER])[0];
if (!code) return rows;
return writeArchJobs(rows, detailClientId, to, code);
}
export function uniqueCodesOnGroup(
rows: readonly LabCaseToothProsthesisDraft[],
detailClientId: string,

View File

@@ -3,15 +3,19 @@ import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import {
PROSTHESIS_FALLBACK_COLORS,
PROSTHESIS_TYPE_COLORS,
prosthesisCategoryFamilyColor,
resolveCatalogTypeColor,
} from '@/components/shared/catalog-type-colors';
/**
* Prosthesis-type colors for Cases, Tasks, Treatment chart/dispatch, and Today.
* Uses a dedicated pastel map (unique per prosthesis code).
* Uses a category family pastel; sibling types are stepped tones of that hue.
*/
/** Dark ink that stays readable on every pastel in the palette. */
type ProsthesisColorCatalog = readonly Pick<
ProsthesisCatalogEntry,
'code' | 'sortOrder' | 'category'
>[];
const BADGE_INK = '#14253d';
export function primaryProsthesisCode(code: string): string {
@@ -25,7 +29,7 @@ export function splitProsthesisGroupCode(code: string): string[] {
/** Stable catalog index for fallback colors — use sortOrder, not list row position. */
export function prosthesisCatalogColorIndex(
code: string,
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
catalog: ProsthesisColorCatalog,
): number {
const entry = catalog.find((e) => e.code === primaryProsthesisCode(code));
if (entry) return Math.max(0, entry.sortOrder - 1);
@@ -44,9 +48,18 @@ export function prosthesisTypeColor(code: string, index = 0): string {
export function prosthesisTypeColorFromCatalog(
code: string,
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
catalog: ProsthesisColorCatalog,
): string {
return prosthesisTypeColor(code, prosthesisCatalogColorIndex(code, catalog));
const primary = primaryProsthesisCode(code);
const entry = catalog.find((e) => e.code === primary);
if (!entry?.category) {
return prosthesisTypeColor(primary, prosthesisCatalogColorIndex(code, catalog));
}
const siblings = catalog
.filter((e) => e.category === entry.category)
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
const idx = siblings.findIndex((e) => e.code === primary);
return prosthesisCategoryFamilyColor(entry.category, Math.max(0, idx) + 1);
}
function hexToRgb(hex: string): [number, number, number] | null {
@@ -118,7 +131,7 @@ function prosthesisTypeLabelSwatch(hex: string): string {
*/
export function prosthesisTypeLabelStyleFromCatalog(
code: string,
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
catalog: ProsthesisColorCatalog,
): CSSProperties {
const swatch = prosthesisTypeLabelSwatch(prosthesisTypeColorFromCatalog(code, catalog));
return {
@@ -134,9 +147,12 @@ export function prosthesisTypeSwatchStyle(code: string, index = 0): CSSPropertie
export function prosthesisTypeSwatchStyleFromCatalog(
code: string,
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
catalog: ProsthesisColorCatalog,
): CSSProperties {
return prosthesisTypeSwatchStyle(code, prosthesisCatalogColorIndex(code, catalog));
return {
backgroundColor: prosthesisTypeColorFromCatalog(code, catalog),
borderColor: 'rgba(0, 0, 0, 0.18)',
};
}
/** Pastel pill / banner fill with readable dark text (group headers, badges). */
@@ -150,9 +166,13 @@ export function prosthesisTypeBadgeStyle(code: string, index = 0): CSSProperties
export function prosthesisTypeBadgeStyleFromCatalog(
code: string,
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
catalog: ProsthesisColorCatalog,
): CSSProperties {
return prosthesisTypeBadgeStyle(code, prosthesisCatalogColorIndex(code, catalog));
return {
backgroundColor: prosthesisTypeColorFromCatalog(code, catalog),
borderColor: 'rgba(0, 0, 0, 0.16)',
color: BADGE_INK,
};
}
export function formatToothList(

View File

@@ -2,7 +2,7 @@
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { Info } from 'lucide-react';
import { Info, Plus } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import {
@@ -117,6 +117,76 @@ function ToothNumber({
);
}
const CHART_TOOL_BTN_CLASS = 'h-8';
function ArchAssignControl({
label,
hint,
highlighted,
disabled,
onClick,
}: {
label: string;
hint: string;
highlighted: boolean;
disabled: boolean;
onClick: () => void;
}) {
return (
<Button
type="button"
size="sm"
variant={highlighted ? 'secondary' : 'outline'}
disabled={disabled}
title={hint}
aria-pressed={highlighted}
onClick={onClick}
className={`${CHART_TOOL_BTN_CLASS} gap-1 ${highlighted ? '' : 'border-dashed'}`}
>
{highlighted ? null : <Plus className="h-3.5 w-3.5 shrink-0" aria-hidden />}
{label}
</Button>
);
}
function ArchLabelRow({
placement,
interactive,
label,
hint,
highlighted,
disabled,
onClick,
}: {
placement: 'upper' | 'lower';
interactive: boolean;
label: string;
hint: string;
highlighted: boolean;
disabled: boolean;
onClick: () => void;
}) {
const spacing = placement === 'upper' ? 'mb-1' : 'mt-1';
if (!interactive) {
return (
<p className={`text-[11px] uppercase tracking-wide text-text-muted ${spacing} text-center`}>
{label}
</p>
);
}
return (
<div className={`${spacing} flex justify-center`}>
<ArchAssignControl
label={label}
hint={hint}
highlighted={highlighted}
disabled={disabled}
onClick={onClick}
/>
</div>
);
}
interface FdiToothChartProps {
selected: ReadonlySet<FdiToothId>;
/**
@@ -129,7 +199,7 @@ interface FdiToothChartProps {
linkedEdges?: ReadonlySet<string>;
onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void;
/**
* Click-to-assign (Exocad): click opens the job picker; shift/drag paints.
* Click-to-assign (Exocad): click opens the job picker; Ctrl/drag or Shift copies the last jobs.
* When set, this replaces `onToggle` for tooth hits.
*/
onAssignPointer?: (
@@ -148,7 +218,7 @@ interface FdiToothChartProps {
crownColors?: Partial<Record<FdiToothId, string>>;
rootColors?: Partial<Record<FdiToothId, string>>;
archHighlight?: 'upper' | 'lower' | 'both' | null;
/** Extra control rendered in the chart header (e.g. whole-plan checkbox). */
/** Extra control rendered next to Reset in the chart header (e.g. Full View). */
headerControl?: ReactNode;
/** Centered overlay on the odontogram (prosthesis picker). */
overlay?: ReactNode;
@@ -540,22 +610,15 @@ export function FdiToothChart({
const chartBody = (
<>
{onArchClick && !readOnly ? (
<button
type="button"
disabled={isDisabled}
onClick={() => onArchClick('upper')}
className={`mx-auto mb-1 block text-[11px] uppercase tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 rounded-sm px-1 ${
archHighlight === 'upper' || archHighlight === 'both'
? 'text-primary font-semibold'
: 'text-text-muted hover:text-text-primary'
}`}
>
{t('upperArch')}
</button>
) : (
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">{t('upperArch')}</p>
)}
<ArchLabelRow
placement="upper"
interactive={Boolean(onArchClick && !readOnly)}
label={t('upperArch')}
hint={t('archAssignHint')}
highlighted={archHighlight === 'upper' || archHighlight === 'both'}
disabled={isDisabled}
onClick={() => onArchClick?.('upper')}
/>
<div className="overflow-x-auto py-1 -mx-1 px-1 select-none" dir="ltr">
<div className="relative isolate min-w-max mx-auto w-fit select-none">
@@ -657,28 +720,21 @@ export function FdiToothChart({
</div>
</div>
{onArchClick && !readOnly ? (
<button
type="button"
disabled={isDisabled}
onClick={() => onArchClick('lower')}
className={`mx-auto mt-1 block text-[11px] uppercase tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 rounded-sm px-1 ${
archHighlight === 'lower' || archHighlight === 'both'
? 'text-primary font-semibold'
: 'text-text-muted hover:text-text-primary'
}`}
>
{t('lowerArch')}
</button>
) : (
<p className="text-[11px] uppercase tracking-wide text-text-muted mt-1 text-center">{t('lowerArch')}</p>
)}
<ArchLabelRow
placement="lower"
interactive={Boolean(onArchClick && !readOnly)}
label={t('lowerArch')}
hint={t('archAssignHint')}
highlighted={archHighlight === 'lower' || archHighlight === 'both'}
disabled={isDisabled}
onClick={() => onArchClick?.('lower')}
/>
</>
);
return (
<div className={`${cardClass} ${className}`}>
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-x-2">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-1">
<h3 className="text-sm font-semibold text-text-primary">
{compact || embedded ? t('toothChartTitleCompact') : t('toothChartTitle')}
@@ -694,18 +750,24 @@ export function FdiToothChart({
</button>
) : null}
</div>
<div className="flex justify-center">{headerControl}</div>
<div className="flex min-w-0 flex-col items-end gap-1">
{onReset && !readOnly ? (
<button
type="button"
disabled={resetDisabled || isDisabled || !(hasResetWork ?? selected.size > 0)}
onClick={onReset}
aria-label={t('resetChartAria')}
className="rounded-[var(--radius-md)] border border-border/70 px-2 py-0.5 text-[11px] text-text-secondary hover:border-border hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 disabled:cursor-not-allowed disabled:opacity-40"
>
{t('resetChart')}
</button>
<div className="flex min-w-0 flex-col items-end gap-1.5">
{headerControl || (onReset && !readOnly) ? (
<div className="flex flex-wrap items-center justify-end gap-1.5">
{headerControl}
{onReset && !readOnly ? (
<Button
type="button"
variant="outline"
size="sm"
className={CHART_TOOL_BTN_CLASS}
disabled={resetDisabled || isDisabled || !(hasResetWork ?? selected.size > 0)}
onClick={onReset}
aria-label={t('resetChartAria')}
>
{t('resetChart')}
</Button>
) : null}
</div>
) : null}
<p className="min-w-0 text-[11px] text-text-secondary tabular-nums text-end">
{t('selectedLabel')}{' '}

View File

@@ -19,22 +19,22 @@ import {
ensureTeethInGroups,
hasArchJobs,
isArchSentinel,
jobsAllowImplantAddon,
jobsAllowPostCoreAddon,
jobsAllowCrownAddon,
removeTeethFromGroups,
retargetArchJobs,
toothRegionColors,
uniqueCodesOnGroup,
writeArchJobs,
writeJobsForTeeth,
type AddonSlot,
type ArchTarget,
type PickerScope,
} from '@/components/treatment/prosthesisTree';
import {
applyShiftRange,
deriveTeethFromGroups,
groupsFromFlatTeeth,
linkedEdgesFromGroups,
shiftRangeTeeth,
teethBetweenInclusive,
} from '@/components/treatment/toothSelectionGroups';
interface ProsthesisAssignChartProps {
@@ -64,7 +64,6 @@ export function ProsthesisAssignChart({
onChange,
}: ProsthesisAssignChartProps) {
const tTreatment = useTranslations('treatment');
const tProsthesis = useTranslations('prosthesis');
const [open, setOpen] = useState(false);
const [mobile, setMobile] = useState(false);
const [anchor, setAnchor] = useState<FdiToothId | null>(null);
@@ -73,8 +72,6 @@ export function ProsthesisAssignChart({
const [lockedArch, setLockedArch] = useState<ArchTarget>('upper');
const brushRef = useRef<string[]>([]);
const rangeAnchorRef = useRef<FdiToothId | null>(null);
const pendingRangeRef = useRef<FdiToothId[] | null>(null);
const [pendingRange, setPendingRange] = useState<FdiToothId[] | null>(null);
const byCode = useMemo(() => catalogByCode(catalog), [catalog]);
useEffect(() => {
@@ -102,12 +99,11 @@ export function ProsthesisAssignChart({
const archHighlight = open && scope === 'arch' ? lockedArch : persistedArch;
const archWork = hasArchJobs(toothProsthesis, detailClientId);
const focusTooth = anchor ?? [...selected][0] ?? null;
const jobCodes =
scope === 'arch'
? uniqueCodesOnGroup(toothProsthesis, detailClientId, archSentinels(lockedArch))
: focusTooth
? codesOnTooth(toothProsthesis, detailClientId, focusTooth)
: anchor
? codesOnTooth(toothProsthesis, detailClientId, anchor)
: [];
function groupIdForTooth(tooth: string, nextGroups: ToothSelectionGroup[]): string {
@@ -119,6 +115,7 @@ export function ProsthesisAssignChart({
}
function applyCodes(teeth: FdiToothId[], codes: string[]) {
if (codes.length === 0 || teeth.length === 0) return;
const nextGroups = ensureTeethInGroups(groups, teeth);
const nextRows = writeJobsForTeeth(
toothProsthesis,
@@ -127,24 +124,21 @@ export function ProsthesisAssignChart({
codes,
(tooth) => groupIdForTooth(tooth, nextGroups),
);
brushRef.current = codes;
commit(nextGroups, nextRows);
}
function setRange(next: FdiToothId[] | null) {
pendingRangeRef.current = next;
setPendingRange(next);
function rememberBrushFromTooth(tooth: FdiToothId | null) {
if (!tooth) return;
const codes = codesOnTooth(toothProsthesis, detailClientId, tooth);
if (codes.length === 0) return;
brushRef.current = codes;
rangeAnchorRef.current = tooth;
}
function openToothPicker(fdi: FdiToothId) {
const pending = pendingRangeRef.current;
if (pending && !pending.includes(fdi)) {
setRange(null);
}
setScope('tooth');
setAnchor(fdi);
setStep({ kind: 'category' });
rangeAnchorRef.current = fdi;
setOpen(true);
}
@@ -155,44 +149,21 @@ export function ProsthesisAssignChart({
if (disabled) return;
if (event.paint || event.ctrlKey) {
if (brushRef.current.length === 0) return;
setRange(null);
applyCodes([fdi], brushRef.current);
return;
}
if (event.shiftKey) {
if (brushRef.current.length === 0) return;
const start = rangeAnchorRef.current;
if (!start || start === fdi) {
rangeAnchorRef.current = fdi;
applyCodes([fdi], brushRef.current);
return;
}
const nextGroups = applyShiftRange(groups, start, fdi);
const union = shiftRangeTeeth(groups, start, fdi);
const span = teethBetweenInclusive(start, fdi);
if (!span || span.length === 0) return;
applyCodes(span, brushRef.current);
rangeAnchorRef.current = fdi;
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;
}
@@ -201,45 +172,55 @@ export function ProsthesisAssignChart({
function handleArchClick(arch: 'upper' | 'lower') {
if (disabled) return;
setRange(null);
setScope('arch');
setLockedArch(arch);
setLockedArch(archTargetFromJobs(toothProsthesis, detailClientId) ?? arch);
setStep({ kind: 'category' });
setOpen(true);
}
function handlePickLeaf(code: string, arch?: ArchTarget) {
if (scope === 'arch' || arch) {
const target = arch ?? lockedArch;
commit(groups, writeArchJobs(toothProsthesis, detailClientId, target, code));
brushRef.current = [code];
function handleLockedArchChange(next: ArchTarget) {
if (next === lockedArch) return;
const previous = lockedArch;
setLockedArch(next);
setStep((current) =>
current.kind === 'subcategory' || current.kind === 'leaves'
? { ...current, arch: next }
: current,
);
const nextRows = retargetArchJobs(toothProsthesis, detailClientId, previous, next);
if (nextRows !== toothProsthesis) {
commit(groups, nextRows);
}
}
function handlePickLeaf(code: string) {
if (scope === 'arch') {
commit(groups, writeArchJobs(toothProsthesis, detailClientId, lockedArch, code));
setStep({ kind: 'category' });
return;
}
const pending = pendingRangeRef.current;
const targets =
pending && pending.length > 0 && anchor && pending.includes(anchor)
? pending
: anchor
? [anchor]
: [];
if (targets.length === 0) return;
const base = codesOnTooth(toothProsthesis, detailClientId, targets[0]);
if (!anchor) return;
const base = codesOnTooth(toothProsthesis, detailClientId, anchor);
const nextCodes = applyLeafToJobs(base, code, byCode);
applyCodes(targets, nextCodes);
setRange(null);
applyCodes([anchor], nextCodes);
setStep({ kind: 'category' });
}
function handlePickAddon(code: string | null, kind: 'implant' | 'post_core') {
const tooth = focusTooth;
if (!tooth || scope === 'arch') return;
const current = codesOnTooth(toothProsthesis, detailClientId, tooth).filter((c) => {
const entry = byCode.get(c);
return entry?.addonKind !== kind;
function handlePickAddon(code: string | null, kind: AddonSlot) {
if (!anchor || scope === 'arch' || kind !== 'crown') return;
const current = codesOnTooth(toothProsthesis, detailClientId, anchor).filter((c) => {
return byCode.get(c)?.stackGroup !== 'restoration';
});
const next = code ? applyLeafToJobs(current, code, byCode) : current;
applyCodes([tooth], next);
if (next.length === 0) {
commit(
removeTeethFromGroups(groups, [anchor]),
clearJobsForTeeth(toothProsthesis, detailClientId, [anchor]),
);
setStep({ kind: 'category' });
return;
}
applyCodes([anchor], next);
setStep({ kind: 'category' });
}
@@ -249,14 +230,14 @@ export function ProsthesisAssignChart({
if (rest.length === 0) {
commit(
groups,
clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels(lockedArch)),
clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels('both')),
);
return;
}
commit(groups, writeArchJobs(toothProsthesis, detailClientId, lockedArch, rest[0]));
return;
}
const tooth = focusTooth;
const tooth = anchor;
if (!tooth) return;
const next = codesOnTooth(toothProsthesis, detailClientId, tooth).filter((c) => c !== code);
if (next.length === 0) {
@@ -264,7 +245,10 @@ export function ProsthesisAssignChart({
removeTeethFromGroups(groups, [tooth]),
clearJobsForTeeth(toothProsthesis, detailClientId, [tooth]),
);
brushRef.current = [];
if (rangeAnchorRef.current === tooth) {
brushRef.current = [];
rangeAnchorRef.current = null;
}
return;
}
applyCodes([tooth], next);
@@ -274,27 +258,28 @@ export function ProsthesisAssignChart({
if (scope === 'arch') {
commit(
groups,
clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels(lockedArch)),
clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels('both')),
);
setOpen(false);
return;
}
const pending = pendingRangeRef.current;
const teeth =
pending && pending.length > 0
? pending
: focusTooth
? [focusTooth]
: [];
const teeth = anchor ? [anchor] : [];
if (teeth.length === 0) return;
commit(
removeTeethFromGroups(groups, teeth),
clearJobsForTeeth(toothProsthesis, detailClientId, teeth),
);
brushRef.current = [];
rangeAnchorRef.current = null;
setOpen(false);
setAnchor(null);
setRange(null);
}
function handleClosePicker() {
if (scope === 'tooth') {
rememberBrushFromTooth(anchor);
}
setOpen(false);
}
function handleReset() {
@@ -307,9 +292,9 @@ export function ProsthesisAssignChart({
);
commit(groupsFromFlatTeeth([]), nextRows);
brushRef.current = [];
rangeAnchorRef.current = null;
setOpen(false);
setAnchor(null);
setRange(null);
}
const selectedSummary = (() => {
@@ -349,26 +334,15 @@ export function ProsthesisAssignChart({
scope={scope}
lockedArch={scope === 'arch' ? lockedArch : undefined}
jobCodes={jobCodes}
tooth={focusTooth}
rangeLabel={
pendingRange && pendingRange.length > 1 ? pendingRange.join(', ') : null
}
allowImplantAddon={
scope === 'tooth' && jobsAllowImplantAddon(jobCodes, byCode)
}
allowPostCoreAddon={
scope === 'tooth' && jobsAllowPostCoreAddon(jobCodes, byCode)
}
tooth={anchor}
allowCrownAddon={scope === 'tooth' && jobsAllowCrownAddon(jobCodes, byCode)}
onStep={setStep}
onLockedArchChange={setLockedArch}
onLockedArchChange={handleLockedArchChange}
onPickLeaf={handlePickLeaf}
onPickAddon={handlePickAddon}
onRemoveJob={handleRemoveJob}
onClear={handleClear}
onClose={() => {
setOpen(false);
setRange(null);
}}
onClose={handleClosePicker}
/>
}
onAssignPointer={handleAssignPointer}

View File

@@ -2,36 +2,48 @@
import type { ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { Trash2 } from 'lucide-react';
import { ArrowLeft, Plus, 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 { prosthesisFamilyTone } from '@/components/shared/catalog-type-colors';
import { prosthesisTypeColorFromCatalog, prosthesisTypeFillAccent } from '@/components/treatment/prosthesisTypeDisplay';
import {
addonLeaves,
catalogByCode,
categoryCodesInCatalog,
categoryDisabledForJobs,
categoryTileColor,
crownRestorationCode,
leavesFor,
subcategoriesFor,
type AddonSlot,
type ArchTarget,
type PickerScope,
} from '@/components/treatment/prosthesisTree';
const CHIP_INK = '#14253d';
const TILE_CLASS =
'flex h-full min-h-0 w-[3.6rem] shrink-0 flex-row items-center justify-center gap-0.5 rounded-[var(--radius-md)] border px-0.5 py-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40';
'flex min-h-[3.25rem] w-full flex-col items-center justify-center gap-0.5 rounded-[var(--radius-md)] border px-2.5 py-2 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-45';
const TILE_TEXT_CLASS =
'max-h-full overflow-hidden [writing-mode:vertical-rl] [text-orientation:mixed] whitespace-normal break-words text-center text-xs font-medium leading-tight sm:text-[13px]';
const HEADER_BTN_CLASS = 'h-auto min-h-8 px-2.5 py-1 text-xs leading-tight';
'w-full whitespace-normal break-words text-center text-xs font-medium leading-snug sm:text-[13px]';
const TILE_GRID_CLASS =
'grid gap-2 [grid-template-columns:repeat(auto-fill,minmax(8rem,1fr))]';
const HEADER_BTN_CLASS = 'h-auto min-h-8 w-full px-2 py-1 text-xs leading-tight whitespace-normal';
function tileSurfaceClass(active?: boolean) {
return active
? 'border-primary ring-1 ring-primary/30'
: 'border-border/70 hover:border-border hover:bg-background-card/70';
}
export type PickerStep =
| { kind: 'category' }
| { kind: 'subcategory'; category: string; arch?: ArchTarget }
| { kind: 'leaves'; category: string; subcategory?: string; arch?: ArchTarget }
| { kind: 'addon'; slot: 'implant' | 'post_core' };
| { kind: 'addon'; slot: AddonSlot };
interface ProsthesisJobPopoverProps {
open: boolean;
@@ -42,13 +54,11 @@ interface ProsthesisJobPopoverProps {
lockedArch?: ArchTarget;
jobCodes: string[];
tooth: string | null;
rangeLabel?: string | null;
allowImplantAddon: boolean;
allowPostCoreAddon: boolean;
allowCrownAddon: boolean;
onStep: (step: PickerStep) => void;
onLockedArchChange?: (arch: ArchTarget) => void;
onPickLeaf: (code: string, arch?: ArchTarget) => void;
onPickAddon: (code: string | null, kind: 'implant' | 'post_core') => void;
onPickAddon: (code: string | null, kind: AddonSlot) => void;
onRemoveJob: (code: string) => void;
onClear: () => void;
onClose: () => void;
@@ -56,54 +66,47 @@ interface ProsthesisJobPopoverProps {
function Tile({
label,
caption,
color,
active,
disabled,
hint,
className = TILE_CLASS,
onClick,
}: {
label: string;
caption?: string;
color?: string;
active?: boolean;
disabled?: boolean;
hint?: string;
className?: string;
onClick: () => void;
}) {
return (
<button
type="button"
title={caption ? `${caption}: ${label}` : label}
title={hint ?? label}
onClick={onClick}
className={`${TILE_CLASS} ${
active
? 'border-primary ring-1 ring-primary/30'
: 'border-border/70 hover:border-border hover:bg-background-card/70'
}`}
disabled={disabled}
className={`${className} ${tileSurfaceClass(active)}`}
style={color ? { backgroundColor: color, color: CHIP_INK } : undefined}
>
{caption ? (
<span className="max-h-full max-w-[0.7rem] shrink-0 overflow-hidden [writing-mode:vertical-rl] [text-orientation:mixed] whitespace-normal break-words text-center text-[10px] font-medium leading-tight opacity-70">
{caption}
</span>
) : null}
<span className={TILE_TEXT_CLASS}>{label}</span>
</button>
);
}
function VRule() {
return <div className="w-px shrink-0 self-stretch bg-border-strong" aria-hidden />;
}
function parentStep(step: PickerStep): PickerStep | null {
if (step.kind === 'category') return null;
if (step.kind === 'addon') return { kind: 'category' };
if (step.kind === 'leaves') {
if (step.subcategory) {
return { kind: 'subcategory', category: step.category, arch: step.arch };
}
return { kind: 'category' };
switch (step.kind) {
case 'category':
return null;
case 'addon':
case 'subcategory':
return { kind: 'category' };
case 'leaves':
return step.subcategory
? { kind: 'subcategory', category: step.category, arch: step.arch }
: { kind: 'category' };
}
if (step.kind === 'subcategory') return { kind: 'category' };
return { kind: 'category' };
}
export function ProsthesisJobPopover({
@@ -115,9 +118,7 @@ export function ProsthesisJobPopover({
lockedArch,
jobCodes,
tooth,
rangeLabel,
allowImplantAddon,
allowPostCoreAddon,
allowCrownAddon,
onStep,
onLockedArchChange,
onPickLeaf,
@@ -130,14 +131,10 @@ export function ProsthesisJobPopover({
if (!open) return null;
const categories = categoryCodesInCatalog(catalog, scope);
const implantAddons = addonLeaves(catalog, 'implant');
const postCoreAddons = addonLeaves(catalog, 'post_core');
const currentImplant =
jobCodes.find((c) => catalog.find((e) => e.code === c)?.addonKind === 'implant') ?? '';
const currentPost =
jobCodes.find((c) => catalog.find((e) => e.code === c)?.addonKind === 'post_core') ?? '';
const byCode = catalogByCode(catalog);
const crownLeaves = leavesFor(catalog, 'crown', undefined, 'tooth');
const currentCrown = crownRestorationCode(jobCodes, byCode) ?? '';
const hasLeaf = jobCodes.length > 0;
const byCode = new Map(catalog.map((e) => [e.code, e]));
const archForLeaves =
step.kind === 'leaves' || step.kind === 'subcategory' ? step.arch : lockedArch;
@@ -151,6 +148,7 @@ export function ProsthesisJobPopover({
const back = parentStep(step);
function openCategory(code: string) {
if (categoryDisabledForJobs(code, jobCodes, byCode)) return;
const subs = subcategoriesFor(catalog, code, scope);
const arch = scope === 'arch' ? lockedArch : undefined;
if (subs.length > 0) {
@@ -173,53 +171,38 @@ export function ProsthesisJobPopover({
}
const addonTiles: ReactNode[] = [];
if (scope === 'tooth' && hasLeaf) {
if (allowImplantAddon && implantAddons.length > 0) {
if (scope === 'tooth' && allowCrownAddon && crownLeaves.length > 0) {
const addonActive = step.kind === 'addon' && step.slot === 'crown';
const openAddon = () =>
onStep(addonActive ? { kind: 'category' } : { kind: 'addon', slot: 'crown' });
if (currentCrown) {
addonTiles.push(
<Tile
key="slot-implant"
caption={t('slotImplant')}
label={
currentImplant
? (byCode.get(currentImplant)?.label ?? currentImplant)
: t('addonNone')
}
color={
currentImplant
? prosthesisTypeColorFromCatalog(currentImplant, catalog)
: undefined
}
active={step.kind === 'addon' && step.slot === 'implant'}
onClick={() =>
onStep(
step.kind === 'addon' && step.slot === 'implant'
? { kind: 'category' }
: { kind: 'addon', slot: 'implant' },
)
}
key="slot-crown"
className={`${TILE_CLASS} h-full`}
label={byCode.get(currentCrown)?.label ?? currentCrown}
color={prosthesisTypeColorFromCatalog(currentCrown, catalog)}
active={addonActive}
onClick={openAddon}
/>,
);
}
if (allowPostCoreAddon && postCoreAddons.length > 0) {
} else {
addonTiles.push(
<Tile
key="slot-post"
caption={t('slotPostCore')}
label={
currentPost ? (byCode.get(currentPost)?.label ?? currentPost) : t('addonNone')
}
color={
currentPost ? prosthesisTypeColorFromCatalog(currentPost, catalog) : undefined
}
active={step.kind === 'addon' && step.slot === 'post_core'}
onClick={() =>
onStep(
step.kind === 'addon' && step.slot === 'post_core'
? { kind: 'category' }
: { kind: 'addon', slot: 'post_core' },
)
}
/>,
<button
key="slot-crown"
type="button"
title={t('addonCrownCanBeAdded')}
aria-pressed={addonActive}
onClick={openAddon}
className={`${TILE_CLASS} h-full ${tileSurfaceClass(addonActive)}`}
>
<span className="inline-flex max-w-full items-center justify-center gap-1 rounded-[var(--radius-md)] border border-dashed border-border px-3 py-1.5 text-sm font-medium text-text-primary">
<Plus className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="whitespace-normal break-words text-center leading-tight">
{t('addonCrownCanBeAdded')}
</span>
</span>
</button>,
);
}
}
@@ -228,103 +211,155 @@ export function ProsthesisJobPopover({
let childTiles: ReactNode[] = [];
let childrenKey = 'root';
if (step.kind === 'category') {
childTiles = categories.map((code) => (
<Tile
key={code}
label={categoryLabel(code)}
color={categoryTileColor(code)}
onClick={() => openCategory(code)}
/>
));
} else if (step.kind === 'addon') {
selectedNode = {
label: step.slot === 'implant' ? t('slotImplant') : t('slotPostCore'),
};
childrenKey = `addon-${step.slot}`;
const options = step.slot === 'implant' ? implantAddons : postCoreAddons;
const current = step.slot === 'implant' ? currentImplant : currentPost;
childTiles = [
<Tile
key="none"
label={t('addonNone')}
active={!current}
onClick={() => onPickAddon(null, step.slot)}
/>,
...options.map((opt) => (
switch (step.kind) {
case 'category':
childTiles = categories.map((code) => {
const disabled = categoryDisabledForJobs(code, jobCodes, byCode);
return (
<Tile
key={code}
label={categoryLabel(code)}
color={categoryTileColor(code)}
disabled={disabled}
hint={disabled ? t('categoryDisabledHint') : undefined}
onClick={() => openCategory(code)}
/>
);
});
break;
case 'addon':
selectedNode = { label: t('slotCrown'), color: categoryTileColor('crown') };
childrenKey = 'addon-crown';
childTiles = [
<Tile
key={opt.code}
label={opt.label}
color={prosthesisTypeColorFromCatalog(opt.code, catalog)}
active={current === opt.code}
onClick={() => onPickAddon(opt.code, step.slot)}
/>
)),
];
} 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, scope).map(leafTile);
} else {
selectedNode = {
label: categoryLabel(step.category),
color: categoryTileColor(step.category),
};
childrenKey = `${step.kind}-${step.category}`;
const arch = step.arch;
const subs =
step.kind === 'subcategory'
? subcategoriesFor(catalog, step.category, scope).map((sub) => (
<Tile
key={sub}
label={subLabel(sub)}
onClick={() =>
onStep({
kind: 'leaves',
category: step.category,
subcategory: sub,
arch,
})
}
/>
))
: [];
const leaves = leavesFor(
catalog,
step.category,
step.kind === 'leaves' ? step.subcategory : undefined,
scope,
).map(leafTile);
childTiles = [...subs, ...leaves];
key="none"
label={t('addonNone')}
active={!currentCrown}
onClick={() => onPickAddon(null, 'crown')}
/>,
...crownLeaves.map((opt) => (
<Tile
key={opt.code}
label={opt.label}
color={prosthesisTypeColorFromCatalog(opt.code, catalog)}
active={currentCrown === opt.code}
onClick={() => onPickAddon(opt.code, 'crown')}
/>
)),
];
break;
case 'subcategory': {
const parentFill = categoryTileColor(step.category);
selectedNode = { label: categoryLabel(step.category), color: parentFill };
childrenKey = `sub-${step.category}`;
childTiles = [
...subcategoriesFor(catalog, step.category, scope).map((sub, i) => (
<Tile
key={sub}
label={subLabel(sub)}
color={prosthesisFamilyTone(parentFill, i + 1)}
onClick={() =>
onStep({
kind: 'leaves',
category: step.category,
subcategory: sub,
arch: step.arch,
})
}
/>
)),
...leavesFor(catalog, step.category, undefined, scope).map(leafTile),
];
break;
}
case 'leaves':
if (step.subcategory) {
selectedNode = {
label: subLabel(step.subcategory),
color: prosthesisFamilyTone(categoryTileColor(step.category), 1),
};
childrenKey = `leaves-${step.category}-${step.subcategory}`;
childTiles = leavesFor(catalog, step.category, step.subcategory, scope).map(leafTile);
} else {
selectedNode = {
label: categoryLabel(step.category),
color: categoryTileColor(step.category),
};
childrenKey = `leaves-${step.category}`;
childTiles = leavesFor(catalog, step.category, undefined, scope).map(leafTile);
}
break;
}
const tileRow = (
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-row items-stretch gap-2 overflow-x-auto overflow-y-hidden pe-1">
{selectedNode ? (
<div className="flex h-full min-h-0 shrink-0 flex-row items-stretch gap-4">
<Tile
label={selectedNode.label}
color={selectedNode.color}
active
onClick={() => back && onStep(back)}
/>
<VRule />
<div
key={childrenKey}
className="picker-expand flex h-full min-h-0 shrink-0 flex-row items-stretch gap-2"
>
{childTiles}
</div>
</div>
) : (
childTiles
)}
const tileGrid = (
<div
key={childrenKey}
className={`picker-expand min-w-0 flex-1 ${TILE_GRID_CLASS}${
selectedNode ? '' : ' h-full min-h-0 [grid-auto-rows:minmax(3.25rem,1fr)]'
}`}
>
{childTiles}
</div>
);
const title = rangeLabel
? t('pickerTeeth', { teeth: rangeLabel })
: scope === 'arch'
const branchColor = selectedNode?.color ?? 'var(--color-border-strong)';
const tileRow = selectedNode ? (
<div className="flex min-h-0 flex-col gap-2">
<button
type="button"
onClick={() => {
if (back) onStep(back);
}}
disabled={!back}
className={`${TILE_CLASS} flex-row justify-start gap-2 px-3 text-start`}
style={
selectedNode.color
? { backgroundColor: selectedNode.color, color: CHIP_INK }
: undefined
}
aria-label={t('pickerBack')}
>
<ArrowLeft
className="lucide-inherit h-4 w-4 shrink-0 rtl:rotate-180"
style={
selectedNode.color
? { color: prosthesisTypeFillAccent(selectedNode.color) }
: undefined
}
aria-hidden
/>
<span className={`${TILE_TEXT_CLASS} text-start`}>{selectedNode.label}</span>
</button>
<div className="flex items-start">
<div className="relative h-[3.25rem] w-3 shrink-0" aria-hidden>
<span
className="absolute w-0.5"
style={{
backgroundColor: branchColor,
insetInlineStart: 'calc(50% - 1px)',
top: 0,
height: '50%',
}}
/>
<span
className="absolute h-0.5"
style={{
backgroundColor: branchColor,
top: 'calc(50% - 1px)',
insetInlineStart: '50%',
width: '50%',
}}
/>
</div>
{tileGrid}
</div>
</div>
) : (
tileGrid
);
const title =
scope === 'arch'
? lockedArch === 'both'
? t('pickerArchBoth')
: lockedArch === 'lower'
@@ -334,11 +369,90 @@ export function ProsthesisJobPopover({
? t('pickerTooth', { tooth })
: t('pickerTitle');
const header = (
<div className="space-y-1.5 shrink-0">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 text-xs font-semibold text-text-primary">{title}</p>
<div className="flex shrink-0 items-center gap-1.5">
const jobChips =
jobCodes.length === 0 ? (
<p className="text-xs text-text-muted">{t('noneYet')}</p>
) : (
<div className="flex flex-wrap gap-1.5">
{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-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="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>
<button
type="button"
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);
}}
>
<Trash2 className="lucide-inherit h-3.5 w-3.5" aria-hidden />
</button>
</span>
);
})}
</div>
);
const body = (
<div className="grid h-full min-h-0 flex-1 grid-cols-[4fr_1fr] gap-x-3">
<section className="flex min-h-0 min-w-0 flex-col gap-2.5">
<p className="shrink-0 text-xs font-semibold leading-none text-text-primary">{title}</p>
{scope === 'arch' && onLockedArchChange ? (
<div
className="flex h-8 w-1/2 shrink-0 rounded-[var(--radius-md)] border border-border p-0.5"
role="group"
aria-label={title}
>
{(['upper', 'lower', 'both'] as const).map((arch) => (
<button
key={arch}
type="button"
onClick={() => onLockedArchChange(arch)}
className={`h-full min-w-0 flex-1 rounded-[var(--radius-sm)] px-2 text-xs font-medium leading-tight ${
lockedArch === arch
? 'bg-primary text-white'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{t(`arch_${arch}`)}
</button>
))}
</div>
) : null}
<div className="shrink-0">{jobChips}</div>
<p className="shrink-0 text-[10px] leading-tight text-text-muted">
{scope === 'arch' ? t('clearHintArch') : t('clearHint')}
</p>
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pe-0.5">
{tileRow}
</div>
</section>
<section className="flex min-h-0 min-w-0 flex-col gap-1">
<div className="flex shrink-0 flex-col gap-1">
<Button
type="button"
variant="outline"
@@ -359,91 +473,15 @@ export function ProsthesisJobPopover({
{t('done')}
</Button>
</div>
</div>
{scope === 'arch' && onLockedArchChange ? (
<div className="flex gap-1">
{(['upper', 'lower', 'both'] as const).map((arch) => (
<button
key={arch}
type="button"
onClick={() => onLockedArchChange(arch)}
className={`flex-1 min-h-9 rounded-[var(--radius-sm)] border text-xs font-medium ${
lockedArch === arch
? 'border-primary bg-primary-soft text-text-primary'
: 'border-border/70 text-text-secondary hover:border-border'
}`}
>
{t(`arch_${arch}`)}
</button>
))}
</div>
) : null}
{jobCodes.length === 0 ? (
<p className="text-xs text-text-muted">{t('noneYet')}</p>
) : (
<div className="flex flex-wrap gap-1.5">
{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-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="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>
<button
type="button"
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);
}}
>
<Trash2 className="lucide-inherit h-3.5 w-3.5" aria-hidden />
</button>
</span>
);
})}
</div>
)}
<p className="text-[10px] text-text-muted">
{scope === 'arch' ? t('clearHintArch') : t('clearHint')}
</p>
</div>
);
const addonRail =
addonTiles.length > 0 ? (
<div className="flex shrink-0 flex-row items-stretch gap-2 border-s border-border ps-3">
{addonTiles}
</div>
) : null;
const body = (
<div className="flex h-full min-h-0 flex-col gap-3">
{header}
<div className="flex min-h-0 flex-1 flex-row items-stretch gap-0">
{tileRow}
{addonRail}
</div>
{addonTiles.length > 0 ? (
<div className="flex min-h-0 flex-1 flex-col gap-1">
<p className="shrink-0 text-[10px] font-medium leading-tight text-text-muted">
{t('suggestionsLabel')}
</p>
<div className="min-h-0 flex-1">{addonTiles}</div>
</div>
) : null}
</section>
</div>
);
@@ -453,7 +491,7 @@ export function ProsthesisJobPopover({
<ResponsiveDialogPanel
role="dialog"
aria-modal="true"
maxWidthClass="sm:max-w-lg"
maxWidthClass="sm:max-w-2xl"
className="max-h-[90dvh] flex flex-col"
>
<h4 className="sr-only">{t('pickerTitle')}</h4>
@@ -472,7 +510,7 @@ export function ProsthesisJobPopover({
onClick={onClose}
/>
<div
className="absolute inset-0 z-10 pointer-events-auto flex flex-col overflow-hidden rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
className="absolute inset-0 z-10 pointer-events-auto flex flex-col overflow-hidden rounded-[var(--radius-md)] border border-border bg-background-secondary px-3 pb-3 pt-2 shadow-lg"
role="dialog"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}

View File

@@ -53,6 +53,9 @@ interface TreatmentDetailsEditorProps {
onTreatmentTypeChange?: (nextType: string) => void;
/** Arch-level jobs count as work for the missing-teeth lab banner. */
hasArchJobs?: boolean;
showLabDispatchToggle?: boolean;
labWorkspaceView?: 'chart' | 'dispatch';
onLabWorkspaceViewChange?: (view: 'chart' | 'dispatch') => void;
}
export function TreatmentDetailsEditor({
@@ -79,6 +82,9 @@ export function TreatmentDetailsEditor({
chartLockMessage,
onTreatmentTypeChange,
hasArchJobs = false,
showLabDispatchToggle = false,
labWorkspaceView = 'chart',
onLabWorkspaceViewChange,
}: TreatmentDetailsEditorProps) {
const t = useTranslations('treatment');
const tCommon = useTranslations('common');
@@ -229,7 +235,7 @@ export function TreatmentDetailsEditor({
{showFields && activeDetail ? (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-end">
<Dropdown
label={t('treatmentType')}
value={activeDetail.treatmentType}
@@ -253,6 +259,32 @@ export function TreatmentDetailsEditor({
onUploadFiles={onUploadFiles}
onRemoveAttachment={readOnly ? undefined : onRemoveAttachment}
/>
{showLabDispatchToggle ? (
<Button
type="button"
variant={labWorkspaceView === 'dispatch' ? 'outline' : 'primary'}
className={`h-[2.875rem] sm:h-9 shrink-0 px-3 text-sm ${
labWorkspaceView === 'dispatch' ? 'bg-background-secondary' : ''
}`}
onClick={() =>
onLabWorkspaceViewChange?.(
labWorkspaceView === 'dispatch' ? 'chart' : 'dispatch',
)
}
>
<span className="inline-grid justify-items-center">
<span className="invisible col-start-1 row-start-1 whitespace-nowrap" aria-hidden>
{t('showLabDispatch')}
</span>
<span className="invisible col-start-1 row-start-1 whitespace-nowrap" aria-hidden>
{t('showChart')}
</span>
<span className="col-start-1 row-start-1 whitespace-nowrap">
{labWorkspaceView === 'dispatch' ? t('showChart') : t('showLabDispatch')}
</span>
</span>
</Button>
) : null}
</div>
{chart ? (

View File

@@ -60,7 +60,7 @@ import {
toggleToothInGroups,
unlinkAdjacentTeeth,
} from '@/components/treatment/toothSelectionGroups';
import { hasArchJobs } from '@/components/treatment/prosthesisTree';
import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import {
@@ -114,6 +114,19 @@ function labCaseDraftForDetail(
return rows.find((lc) => !lc.sentAt) ?? rows[0];
}
function pruneLabDependentDetailsToJobs(
details: TreatmentDetailDraft[],
drafts: LabCaseDraft[],
labDependentCodes: Set<string>,
): TreatmentDetailDraft[] {
if (labDependentCodes.size === 0) return details;
return details.map((detail) => {
if (!labDependentCodes.has(detail.treatmentType)) return detail;
const jobs = labCaseDraftForDetail(drafts, detail.clientId)?.toothProsthesis ?? [];
return pruneDetailTeethToJobs(detail, jobs);
});
}
function labCaseDraftsToPast(
labCaseDrafts: LabCaseDraft[],
details: TreatmentDetailDraft[],
@@ -422,6 +435,29 @@ interface TreatmentWorkspaceProps {
initialLabCaseId?: string | null;
}
function WholePlanToggle({
pressed,
label,
onToggle,
}: {
pressed: boolean;
label: string;
onToggle: () => void;
}) {
return (
<Button
type="button"
variant={pressed ? 'secondary' : 'outline'}
size="sm"
className="h-8"
aria-pressed={pressed}
onClick={onToggle}
>
{label}
</Button>
);
}
export function TreatmentWorkspace({
userId,
currentOrganization,
@@ -518,6 +554,7 @@ export function TreatmentWorkspace({
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
const pendingLabCaseIdRef = useRef<string | null>(initialLabCaseId);
const labPanelRef = useRef<HTMLDivElement>(null);
const [labWorkspaceView, setLabWorkspaceView] = useState<'chart' | 'dispatch'>('chart');
const historyRequestRef = useRef(0);
/** When set, switching the active detail scrolls to the lab dispatch panel. */
const pendingScrollToLabRef = useRef(false);
@@ -704,6 +741,20 @@ export function TreatmentWorkspace({
[activeDetail, labDependentCodes],
);
useEffect(() => {
if (labDependentCodes.size === 0) return;
setDetails((prev) => {
const next = pruneLabDependentDetailsToJobs(
prev,
labCaseDraftsRef.current,
labDependentCodes,
);
if (serializeDetails(next) === serializeDetails(prev)) return prev;
detailsRef.current = next;
return next;
});
}, [labDependentCodes]);
const activeTypeSelected = Boolean(activeDetail && isDetailTypeSelected(activeDetail));
const activeLocked = Boolean(activeDetail && isDetailLocked(activeDetail));
const activeHasArchJobs = hasArchJobs(
@@ -846,7 +897,14 @@ export function TreatmentWorkspace({
treatment: PastTreatment,
options?: { seedBlankIfEmpty?: boolean },
) => {
const mapped = treatment.details.map(mapDetailFromApi);
const mappedLabCases = withoutEmptyLabCaseDrafts(
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
);
const mapped = pruneLabDependentDetailsToJobs(
treatment.details.map(mapDetailFromApi),
mappedLabCases,
labDependentCodes,
);
const nextDetails =
mapped.length > 0
? mapped
@@ -859,9 +917,6 @@ export function TreatmentWorkspace({
return stillExists ? prev : (nextDetails[0]?.clientId ?? '');
});
setSavedSnapshot(serializeDetails(nextDetails));
const mappedLabCases = withoutEmptyLabCaseDrafts(
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
);
const labSnap = serializeLabCases(mappedLabCases);
labCaseDraftsRef.current = mappedLabCases;
savedLabCasesSnapshotRef.current = labSnap;
@@ -870,7 +925,7 @@ export function TreatmentWorkspace({
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
setSaveStatus('idle');
}, []);
}, [labDependentCodes]);
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const connectedSelectedTeeth = useMemo(
@@ -929,12 +984,19 @@ export function TreatmentWorkspace({
setOrganizationSearch('');
if (pendingScrollToLabRef.current) {
pendingScrollToLabRef.current = false;
setLabWorkspaceView('dispatch');
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
} else {
setLabWorkspaceView('chart');
}
}, [activeDetailId]);
useEffect(() => {
if (!showLabDispatch) setLabWorkspaceView('chart');
}, [showLabDispatch]);
// Sync active lab shipment when the selected treatment detail changes.
useEffect(() => {
const match = labCaseDraftForDetail(labCaseDrafts, activeDetailId);
@@ -1201,8 +1263,16 @@ export function TreatmentWorkspace({
: await treatmentsApi.getDraftByTreatment(treatmentId!);
if (cancelled) return;
const mappedLabCases = withoutEmptyLabCaseDrafts(
(response.data?.labCases ?? []).map(mapLabCaseDraftFromApi),
);
if (response.data?.details?.length) {
const mapped = response.data.details.map(mapDetailFromApi);
const mapped = pruneLabDependentDetailsToJobs(
response.data.details.map(mapDetailFromApi),
mappedLabCases,
labDependentCodes,
);
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
@@ -1222,9 +1292,6 @@ export function TreatmentWorkspace({
setSavedSnapshot(serializeDetails([first]));
}
const mappedLabCases = withoutEmptyLabCaseDrafts(
(response.data?.labCases ?? []).map(mapLabCaseDraftFromApi),
);
const labSnap = serializeLabCases(mappedLabCases);
labCaseDraftsRef.current = mappedLabCases;
savedLabCasesSnapshotRef.current = labSnap;
@@ -1253,6 +1320,7 @@ export function TreatmentWorkspace({
selectedStandalone?.id,
workspaceMode,
treatmentCatalog,
labDependentCodes,
showError,
t,
tErrors,
@@ -1270,7 +1338,15 @@ export function TreatmentWorkspace({
const patientId = selectedAppointment?.patientId ?? selectedStandalone!.patientId;
const treatmentAt = selectedAppointment?.startAt ?? selectedStandalone!.treatmentAt;
const currentDetails = detailsRef.current;
const currentDetails = pruneLabDependentDetailsToJobs(
detailsRef.current,
labCaseDraftsRef.current,
labDependentCodes,
);
if (serializeDetails(currentDetails) !== serializeDetails(detailsRef.current)) {
detailsRef.current = currentDetails;
setDetails(currentDetails);
}
const dirty = isDetailsDirty(currentDetails, savedSnapshotRef.current);
if (!options?.force && !dirty) {
@@ -1333,7 +1409,7 @@ export function TreatmentWorkspace({
}
return response.data;
},
[selectedAppointment, selectedStandalone, t],
[labDependentCodes, selectedAppointment, selectedStandalone, t],
);
const persistLabCases = useCallback(
@@ -1881,8 +1957,11 @@ export function TreatmentWorkspace({
(item: LabDispatchAttentionItem) => {
if (item.isCurrentDraft) {
exitBrowse();
pendingScrollToLabRef.current = true;
setActiveDetailId(item.detailClientId);
setLabWorkspaceView('dispatch');
if (item.detailClientId !== activeDetailId) {
pendingScrollToLabRef.current = true;
setActiveDetailId(item.detailClientId);
}
const linked = labCaseDrafts.find(
(lc) => !lc.sentAt && lc.detailClientId === item.detailClientId,
);
@@ -1901,7 +1980,7 @@ export function TreatmentWorkspace({
if (!treatment) return;
void loadTreatmentIntoWorkspace(treatment, item.detailClientId);
},
[exitBrowse, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace],
[exitBrowse, activeDetailId, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace],
);
const handleSelectPatientLabCase = useCallback(
@@ -1949,8 +2028,11 @@ export function TreatmentWorkspace({
workspaceMode === 'live' &&
!isBrowsing
) {
pendingScrollToLabRef.current = true;
setActiveDetailId(item.detailClientId);
setLabWorkspaceView('dispatch');
if (item.detailClientId !== activeDetailId) {
pendingScrollToLabRef.current = true;
setActiveDetailId(item.detailClientId);
}
const matchingDraft = labCaseDrafts.find((lc) => lc.id === item.labCaseId);
if (matchingDraft) {
setActiveLabCaseId(matchingDraft.clientId);
@@ -1967,6 +2049,7 @@ export function TreatmentWorkspace({
})();
},
[
activeDetailId,
activePatientId,
currentDraftPreview,
handleLabCaseMarkedRead,
@@ -2244,6 +2327,7 @@ export function TreatmentWorkspace({
if (detail.treatmentType === nextType) return;
const leavingLab = labDependentCodes.has(detail.treatmentType);
const enteringLab = labDependentCodes.has(nextType);
const jobs =
labCaseDraftForDetail(labCaseDrafts, activeDetailId)?.toothProsthesis ?? [];
const hasWork =
@@ -2257,7 +2341,7 @@ export function TreatmentWorkspace({
const nextDetails = details.map((d) => {
if (d.clientId !== activeDetailId) return d;
if (leavingLab) {
if (leavingLab || enteringLab) {
return {
...d,
treatmentType: nextType,
@@ -2495,6 +2579,39 @@ export function TreatmentWorkspace({
],
);
const labDispatchPanel = (
<LabCasesDispatchPanel
details={details}
activeDetailId={activeDetailId}
labCases={labCaseDrafts}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
prosthesisCatalog={prosthesisCatalog}
labCaseSummary={activeLabCaseSummary}
locale={locale}
onLabCaseSummaryChange={handleLabCaseSummaryChange}
onLabCaseMarkedRead={handleLabCaseMarkedRead}
onLabCaseActivityChange={() => {
if (historyPatientId) {
void refreshPatientLabCases(historyPatientId, { silent: true });
}
}}
activeLabCaseId={activeLabCaseId}
onLabCasesChange={handleLabCasesChange}
disabled={!canEditTreatmentForDay}
canEdit={canEdit}
orgs={orgs}
organizationSearch={organizationSearch}
onOrganizationSearchChange={setOrganizationSearch}
recentOrganizationIds={recentOrganizationIds}
sendBusyId={sendBusyId}
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
onCommentError={showError}
canInviteLab={canAccessOrganizations}
onInviteLab={() => router.push('/organizations?action=invite-lab')}
/>
);
if (!canView) {
return (
<div className="surface-card p-6 max-w-xl">
@@ -2784,10 +2901,19 @@ export function TreatmentWorkspace({
showFields
hasArchJobs={activeHasArchJobs}
voice={voiceForEditor}
chartLocked={!activeTypeSelected && !showWholeTreatmentPlan}
chartLocked={!activeTypeSelected && !showWholeTreatmentPlan && labWorkspaceView !== 'dispatch'}
chartLockMessage={t('selectTypeBeforeTeeth')}
showLabDispatchToggle={showLabDispatch}
labWorkspaceView={labWorkspaceView}
onLabWorkspaceViewChange={(view) => {
setShowWholeTreatmentPlan(false);
setLabWorkspaceView(view);
}}
chart={
prosthesisAssignActive ? (
<div ref={labPanelRef} className="min-w-0 w-full">
{showLabDispatch && labWorkspaceView === 'dispatch' ? (
labDispatchPanel
) : prosthesisAssignActive ? (
<ProsthesisAssignChart
className="w-full"
groups={
@@ -2803,22 +2929,11 @@ export function TreatmentWorkspace({
catalog={prosthesisCatalog}
disabled={!canEditTreatmentForDay || activeLocked}
headerControl={
<button
type="button"
onClick={() => setShowWholeTreatmentPlan((open) => !open)}
aria-pressed={showWholeTreatmentPlan}
className={`
rounded-[var(--radius-md)] border px-2.5 py-1 text-[11px] leading-none transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${
showWholeTreatmentPlan
? 'border-primary bg-primary-soft font-medium text-text-primary'
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
}
`}
>
{t('toothChartWholePlan')}
</button>
<WholePlanToggle
pressed={showWholeTreatmentPlan}
label={t('toothChartWholePlan')}
onToggle={() => setShowWholeTreatmentPlan((open) => !open)}
/>
}
onToggleLink={(a, b) => {
if (
@@ -2868,35 +2983,34 @@ export function TreatmentWorkspace({
);
}}
onChange={({ groups, toothProsthesis }) => {
setDetails((prev) =>
prev.map((d) =>
d.clientId !== activeDetailId
? d
: {
...d,
toothSelectionGroups: groups,
teeth: deriveTeethFromGroups(groups),
},
),
const nextDetails = details.map((d) =>
d.clientId !== activeDetailId
? d
: {
...d,
toothSelectionGroups: groups,
teeth: deriveTeethFromGroups(groups),
},
);
setLabCaseDrafts((prev) => {
const existing = prev.find(
(lc) => lc.detailClientId === activeDetailId && !lc.sentAt,
);
if (existing) {
return prev.map((lc) =>
detailsRef.current = nextDetails;
setDetails(nextDetails);
const prev = labCaseDraftsRef.current;
const existing = prev.find(
(lc) => lc.detailClientId === activeDetailId && !lc.sentAt,
);
const nextDrafts = existing
? prev.map((lc) =>
lc.clientId === existing.clientId ? { ...lc, toothProsthesis } : lc,
);
}
return [
...prev,
{
...newLabCaseDraft(),
detailClientId: activeDetailId,
toothProsthesis,
},
];
});
)
: [
...prev,
{
...newLabCaseDraft(),
detailClientId: activeDetailId,
toothProsthesis,
},
];
handleLabCasesChange(nextDrafts);
}}
/>
) : (
@@ -2908,22 +3022,11 @@ export function TreatmentWorkspace({
toothColors={chartToothColors}
readOnly={showWholeTreatmentPlan}
headerControl={
<button
type="button"
onClick={() => setShowWholeTreatmentPlan((open) => !open)}
aria-pressed={showWholeTreatmentPlan}
className={`
rounded-[var(--radius-md)] border px-2.5 py-1 text-[11px] leading-none transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${
showWholeTreatmentPlan
? 'border-primary bg-primary-soft font-medium text-text-primary'
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
}
`}
>
{t('toothChartWholePlan')}
</button>
<WholePlanToggle
pressed={showWholeTreatmentPlan}
label={t('toothChartWholePlan')}
onToggle={() => setShowWholeTreatmentPlan((open) => !open)}
/>
}
onToggle={(fdi, event) => {
if (
@@ -3084,43 +3187,10 @@ export function TreatmentWorkspace({
}}
/>
)
}
</div>
}
/>
{showLabDispatch ? (
<div ref={labPanelRef} className="space-y-3">
<LabCasesDispatchPanel
details={details}
activeDetailId={activeDetailId}
labCases={labCaseDrafts}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
prosthesisCatalog={prosthesisCatalog}
labCaseSummary={activeLabCaseSummary}
locale={locale}
onLabCaseSummaryChange={handleLabCaseSummaryChange}
onLabCaseMarkedRead={handleLabCaseMarkedRead}
onLabCaseActivityChange={() => {
if (historyPatientId) {
void refreshPatientLabCases(historyPatientId, { silent: true });
}
}}
activeLabCaseId={activeLabCaseId}
onLabCasesChange={handleLabCasesChange}
disabled={!canEditTreatmentForDay}
canEdit={canEdit}
orgs={orgs}
organizationSearch={organizationSearch}
onOrganizationSearchChange={setOrganizationSearch}
recentOrganizationIds={recentOrganizationIds}
sendBusyId={sendBusyId}
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
onCommentError={showError}
canInviteLab={canAccessOrganizations}
onInviteLab={() => router.push('/organizations?action=invite-lab')}
/>
</div>
) : null}
</>
)}
</div>

View File

@@ -6,10 +6,10 @@
--radius-md: 6px;
--radius-lg: 8px;
--color-background-primary: #f6f9fc;
--color-background-secondary: #ffffff;
--color-background-card: #ffffff;
--color-surface-elevated: #f8fbff;
--color-background-primary: #f2f3f5;
--color-background-secondary: #f7f8f9;
--color-background-card: #f7f8f9;
--color-surface-elevated: #eceef1;
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
@@ -18,7 +18,7 @@
--color-border: #d2dcec;
--color-border-strong: #aec0de;
--color-card-background: #ffffff;
--color-card-background: #f7f8f9;
--color-card-foreground: #0f172a;
--color-card-muted: #475569;
--color-card-border: #d2dcec;
@@ -397,7 +397,7 @@ select option {
@keyframes picker-expand {
from {
opacity: 0;
transform: scaleX(0.88);
transform: translateY(4px) scale(0.98);
}
to {
opacity: 1;
@@ -406,7 +406,7 @@ select option {
}
.picker-expand {
transform-origin: inline-start;
transform-origin: top center;
animation: picker-expand 180ms ease-out;
}

View File

@@ -16,5 +16,4 @@ export interface ProsthesisCatalogEntry {
subcategory: string;
chartRegion: 'crown' | 'root' | 'arch' | string;
stackGroup: 'restoration' | 'implant' | 'post_core' | 'arch' | string;
addonKind: '' | 'implant' | 'post_core' | string;
}