improvement: Some improvements done. some bugs fixed.

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

View File

@@ -877,7 +877,7 @@
"labCommentsMovedHint": "Progress and comments are in Lab shipments.",
"toothChartTitleCompact": "Tooth chart",
"toothChartHint": "Click a tooth to select or deselect it. Hold Shift and click another tooth to select a range. Click the circle between neighbors to connect or disconnect a bridge.",
"toothChartHintAssign": "Click a tooth to assign work. Shift-click a second tooth to select a range, then click any tooth in that range to assign to all of them. Hold Ctrl or drag to copy the last type set. Click Upper arch or Lower arch to assign a jaw-level product. Click the circle between neighbors to connect or disconnect a bridge.",
"toothChartHintAssign": "Click a tooth to assign work. Shift-click a second tooth to select a range the picker opens for that span. Click a selected tooth with no type to deselect it; Clear removes the whole range. Hold Ctrl or drag to copy the last type set. Click Upper arch or Lower arch to assign a jaw-level product. Click the circle between neighbors to connect or disconnect a bridge.",
"toothChartHelpAria": "How to select teeth",
"toothConnectedHint": "Connected selection",
"toothLinkHint": "Connect these teeth",
@@ -1104,7 +1104,7 @@
"arch_lower": "Lower",
"arch_both": "Both",
"category_crown": "Crowns",
"category_indirect": "Indirect restorations",
"category_indirect": "Veneer/Inlay/Onlay/Overlay",
"category_implant": "Implants",
"category_post_core": "Post & core",
"category_removable": "Removable",

View File

@@ -872,7 +872,7 @@
"toothChartTitle": "نمودار دندان‌ها FDI",
"toothChartTitleCompact": "نمودار دندان",
"toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان کلیک کنید. Shift را نگه دارید و روی دندان دیگر کلیک کنید تا یک بازه انتخاب شود. روی دایره بین همسایه‌ها کلیک کنید تا بریج وصل یا قطع شود.",
"toothChartHintAssign": "برای اختصاص کار روی دندان کلیک کنید. Shift را نگه دارید و روی دندان دوم کلیک کنید تا یک بازه انتخاب شود، سپس روی هر دندان آن بازه کلیک کنید تا به همه اختصاص داده شود. Ctrl را نگه دارید یا بکشید تا آخرین مجموعه نوع کپی شود. روی «قوس بالا» یا «قوس پایین» کلیک کنید تا محصول فکی اختصاص داده شود. روی دایره بین همسایه‌ها کلیک کنید تا بریج وصل یا قطع شود.",
"toothChartHintAssign": "برای اختصاص کار روی دندان کلیک کنید. Shift را نگه دارید و روی دندان دوم کلیک کنید تا یک بازه انتخاب شود — انتخاب‌گر برای همان بازه باز می‌شود. روی دندان انتخاب‌شدهٔ بدون نوع کلیک کنید تا از انتخاب خارج شود؛ پاک‌کردن کل بازه را برمی‌دارد. Ctrl را نگه دارید یا بکشید تا آخرین مجموعه نوع کپی شود. روی «قوس بالا» یا «قوس پایین» کلیک کنید تا محصول فکی اختصاص داده شود. روی دایره بین همسایه‌ها کلیک کنید تا بریج وصل یا قطع شود.",
"toothChartHelpAria": "راهنمای انتخاب دندان",
"toothConnectedHint": "انتخاب متصل",
"toothLinkHint": "اتصال این دندان‌ها",
@@ -1105,7 +1105,7 @@
"arch_lower": "فک پایین",
"arch_both": "هر دو",
"category_crown": "روکش‌ها",
"category_indirect": "ترمیم‌های غیرمستقیم",
"category_indirect": "ونیر/اینلی/آنلی/اورلی",
"category_implant": "ایمپلنت",
"category_post_core": "پست و کور",
"category_removable": "متحرک",

View File

@@ -871,7 +871,7 @@
"toothChartTitle": "FDI-tanddiagram",
"toothChartTitleCompact": "Tanddiagram",
"toothChartHint": "Klik op een tand om deze te selecteren of te deselecteren. Houd Shift ingedrukt en klik op een andere tand om een bereik te selecteren. Klik op de cirkel tussen buren om een brug te verbinden of te ontkoppelen.",
"toothChartHintAssign": "Klik op een tand om werk toe te wijzen. Shift-klik op een tweede tand om een bereik te selecteren; klik daarna op een tand in dat bereik om aan allemaal toe te wijzen. Houd Ctrl ingedrukt of sleep om de laatste typeset te kopiëren. Klik op Bovenboog of Onderboog voor een boogproduct. Klik op de cirkel tussen buren om een brug te verbinden of te ontkoppelen.",
"toothChartHintAssign": "Klik op een tand om werk toe te wijzen. Shift-klik op een tweede tand om een bereik te selecteren — de kiezer opent voor dat bereik. Klik op een geselecteerde tand zonder type om die te deselecteren; Wissen verwijdert het hele bereik. Houd Ctrl ingedrukt of sleep om de laatste typeset te kopiëren. Klik op Bovenboog of Onderboog voor een boogproduct. Klik op de cirkel tussen buren om een brug te verbinden of te ontkoppelen.",
"toothChartHelpAria": "Tanden selecteren",
"toothConnectedHint": "Verbonden selectie",
"toothLinkHint": "Deze tanden verbinden",
@@ -1104,7 +1104,7 @@
"arch_lower": "Onder",
"arch_both": "Beide",
"category_crown": "Kronen",
"category_indirect": "Indirecte restauraties",
"category_indirect": "Veneer/Inlay/Onlay/Overlay",
"category_implant": "Implantaten",
"category_post_core": "Stiftopbouw",
"category_removable": "Uitneembaar",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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