improvement: all confirmed suggestions implemented
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')}{' '}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user