improvement: icons added to prosthesis types catalog.
5
frontend/src/assets/prosthesis-catalog/crown.svg
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
25
frontend/src/assets/prosthesis-catalog/crown_full_metal.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
19
frontend/src/assets/prosthesis-catalog/crown_pfm.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
8
frontend/src/assets/prosthesis-catalog/denture.svg
Normal file
|
After Width: | Height: | Size: 306 KiB |
6
frontend/src/assets/prosthesis-catalog/implant.svg
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
6
frontend/src/assets/prosthesis-catalog/inlay.svg
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
9
frontend/src/assets/prosthesis-catalog/mock_up.svg
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
8
frontend/src/assets/prosthesis-catalog/night_guard.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
6
frontend/src/assets/prosthesis-catalog/onlay.svg
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
9
frontend/src/assets/prosthesis-catalog/overdenture.svg
Normal file
|
After Width: | Height: | Size: 33 KiB |
6
frontend/src/assets/prosthesis-catalog/overlay.svg
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 18 KiB |
87
frontend/src/assets/prosthesis-catalog/post_and_core.svg
Normal file
@@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 330">
|
||||
|
||||
|
||||
<!-- Complete tooth silhouette: one continuous, non-crossing contour -->
|
||||
<path d="
|
||||
M29 62
|
||||
C38 41 57 31 77 34
|
||||
C89 36 98 42 110 42
|
||||
C122 42 131 36 143 34
|
||||
C163 31 182 41 191 62
|
||||
C201 86 198 116 187 142
|
||||
C181 156 174 168 168 177
|
||||
C163 185 160 194 159 205
|
||||
C157 232 153 260 146 285
|
||||
C140 304 132 316 124 319
|
||||
C119 321 116 317 117 310
|
||||
C120 289 119 270 116 249
|
||||
C114 237 111 228 110 223
|
||||
C109 228 106 237 104 249
|
||||
C101 270 100 289 103 310
|
||||
C104 317 101 321 96 319
|
||||
C88 316 80 304 74 285
|
||||
C67 260 63 232 61 205
|
||||
C60 194 57 185 52 177
|
||||
C46 168 39 156 33 142
|
||||
C22 116 19 86 29 62 Z"
|
||||
fill="#F3F3F3"
|
||||
stroke="#243549"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Crown overlay; same clean upper contour, no duplicated/crossing segment -->
|
||||
<path d="
|
||||
M29 62
|
||||
C38 41 57 31 77 34
|
||||
C89 36 98 42 110 42
|
||||
C122 42 131 36 143 34
|
||||
C163 31 182 41 191 62
|
||||
C201 86 198 116 187 142
|
||||
C181 156 174 168 168 177
|
||||
C164 183 160 187 154 187
|
||||
H145
|
||||
L138 137
|
||||
C137 131 133 128 127 128
|
||||
H93
|
||||
C87 128 83 131 82 137
|
||||
L75 187
|
||||
H66
|
||||
C60 187 56 183 52 177
|
||||
C46 168 39 156 33 142
|
||||
C22 116 19 86 29 62 Z"
|
||||
fill="#FFFFFF"
|
||||
stroke="#243549"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Core -->
|
||||
<path d="
|
||||
M82 137
|
||||
C83 131 87 128 93 128
|
||||
H127
|
||||
C133 128 137 131 138 137
|
||||
L145 187
|
||||
H75 Z"
|
||||
fill="#9A9A9A"
|
||||
stroke="#243549"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Post -->
|
||||
<path d="
|
||||
M94 187
|
||||
H126
|
||||
L121 257
|
||||
C120 273 116 282 110 282
|
||||
C104 282 100 273 99 257
|
||||
Z"
|
||||
fill="#8A8A8A"
|
||||
stroke="#243549"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 20 KiB |
8
frontend/src/assets/prosthesis-catalog/removable.svg
Normal file
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 31 KiB |
9
frontend/src/assets/prosthesis-catalog/smile_design.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
6
frontend/src/assets/prosthesis-catalog/veneer.svg
Normal file
99
frontend/src/components/treatment/prosthesisCatalogIcons.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
const BASE = '/prosthesis-catalog';
|
||||
|
||||
const icon = {
|
||||
crown: `${BASE}/crown.svg`,
|
||||
crown_anatomical: `${BASE}/crown_anatomical.svg`,
|
||||
crown_full_metal: `${BASE}/crown_full_metal.svg`,
|
||||
crown_pfm: `${BASE}/crown_pfm.svg`,
|
||||
custom_abutment_titanium: `${BASE}/custom_abutment_titanium.svg`,
|
||||
custom_abutment_zirconia: `${BASE}/custom_abutment_zirconia.svg`,
|
||||
denture: `${BASE}/denture.svg`,
|
||||
implant: `${BASE}/implant.svg`,
|
||||
inlay: `${BASE}/inlay.svg`,
|
||||
mock_up: `${BASE}/mock_up.svg`,
|
||||
multi_unit_abutment: `${BASE}/multi_unit_abutment.svg`,
|
||||
night_guard: `${BASE}/night_guard.svg`,
|
||||
onlay: `${BASE}/onlay.svg`,
|
||||
overdenture: `${BASE}/overdenture.svg`,
|
||||
overlay: `${BASE}/overlay.svg`,
|
||||
partial_denture: `${BASE}/partial_denture.svg`,
|
||||
pfz_crown_layered: `${BASE}/pfz_crown_layered.svg`,
|
||||
post_and_core: `${BASE}/post_and_core.svg`,
|
||||
prefabricated_abutment: `${BASE}/prefabricated_abutment.svg`,
|
||||
removable: `${BASE}/removable.svg`,
|
||||
screw_retained: `${BASE}/screw_retained.svg`,
|
||||
smile_design: `${BASE}/smile_design.svg`,
|
||||
surgical_guide: `${BASE}/surgical_guide.svg`,
|
||||
ti_base_abutment: `${BASE}/ti_base_abutment.svg`,
|
||||
veneer: `${BASE}/veneer.svg`,
|
||||
} as const;
|
||||
|
||||
const BY_CODE: Record<string, string> = {
|
||||
pfm_crown: icon.crown_pfm,
|
||||
pfz_crown: icon.pfz_crown_layered,
|
||||
full_metal_crown: icon.crown_full_metal,
|
||||
monolithic_zirconia: icon.crown_anatomical,
|
||||
glass_ceramic_crown: icon.crown_anatomical,
|
||||
temporary_resin_crown: icon.crown_anatomical,
|
||||
pmma: icon.crown_anatomical,
|
||||
peek_crown: icon.crown_anatomical,
|
||||
press_ceramic: icon.crown_anatomical,
|
||||
veneer_full_contour: icon.veneer,
|
||||
veneer_layered: icon.veneer,
|
||||
inlay_full_contour: icon.inlay,
|
||||
inlay_layered: icon.inlay,
|
||||
onlay_full_contour: icon.onlay,
|
||||
onlay_layered: icon.onlay,
|
||||
overlay_full_contour: icon.overlay,
|
||||
overlay_layered: icon.overlay,
|
||||
cast_post_core: icon.post_and_core,
|
||||
fiber_post_core: icon.post_and_core,
|
||||
prefabricated_abutment: icon.prefabricated_abutment,
|
||||
ti_base_abutment: icon.ti_base_abutment,
|
||||
multi_unit_abutment: icon.multi_unit_abutment,
|
||||
customized_abutment: icon.custom_abutment_titanium,
|
||||
zirconia_abutment: icon.custom_abutment_zirconia,
|
||||
screw_retained: icon.screw_retained,
|
||||
complete_denture: icon.denture,
|
||||
partial_denture: icon.partial_denture,
|
||||
overdenture: icon.overdenture,
|
||||
night_guard_soft: icon.night_guard,
|
||||
night_guard_hard: icon.night_guard,
|
||||
night_guard_dual: icon.night_guard,
|
||||
surgical_guide: icon.surgical_guide,
|
||||
smile_design: icon.smile_design,
|
||||
mockup: icon.mock_up,
|
||||
};
|
||||
|
||||
const BY_SUBCATEGORY: Record<string, string> = {
|
||||
veneer: icon.veneer,
|
||||
inlay: icon.inlay,
|
||||
onlay: icon.onlay,
|
||||
overlay: icon.overlay,
|
||||
night_guard: icon.night_guard,
|
||||
};
|
||||
|
||||
const BY_CATEGORY: Record<string, string> = {
|
||||
crown: icon.crown,
|
||||
implant: icon.implant,
|
||||
post_core: icon.post_and_core,
|
||||
indirect: icon.inlay,
|
||||
removable: icon.removable,
|
||||
appliance: icon.night_guard,
|
||||
digital: icon.smile_design,
|
||||
};
|
||||
|
||||
export function prosthesisCatalogIconSrc(opts: {
|
||||
code?: string;
|
||||
category?: string;
|
||||
subcategory?: string;
|
||||
}): string | undefined {
|
||||
const code = opts.code?.trim();
|
||||
if (code && BY_CODE[code]) return BY_CODE[code];
|
||||
const category = opts.category?.trim();
|
||||
if (code && category === 'crown') return icon.crown_anatomical;
|
||||
const sub = opts.subcategory?.trim();
|
||||
if (sub && BY_SUBCATEGORY[sub]) return BY_SUBCATEGORY[sub];
|
||||
if (category && BY_CATEGORY[category]) return BY_CATEGORY[category];
|
||||
return undefined;
|
||||
}
|
||||
83
frontend/src/components/treatment/prosthesisJobPath.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
import { splitProsthesisGroupCode } from '@/components/treatment/prosthesisTypeDisplay';
|
||||
|
||||
/** Non-breaking spaces so the gap around · does not collapse between inline spans. */
|
||||
export const PROSTHESIS_JOB_PATH_SEP = '\u00a0·\u00a0';
|
||||
|
||||
type CatalogLite = readonly Pick<
|
||||
ProsthesisCatalogEntry,
|
||||
'code' | 'label' | 'category' | 'subcategory'
|
||||
>[];
|
||||
|
||||
export type ProsthesisMessageFn = (key: string) => string;
|
||||
|
||||
function sameLabel(a: string, b: string): boolean {
|
||||
return a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0;
|
||||
}
|
||||
|
||||
function leafFromCatalogLabel(label: string): string {
|
||||
const parts = label
|
||||
.split(/\s*·\s*/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
return parts.length > 1 ? parts[parts.length - 1] : label;
|
||||
}
|
||||
|
||||
function humanizeCode(code: string): string {
|
||||
return code
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/** Picker root whose title is a slash-joined list of its children (redundant in job titles). */
|
||||
const SKIP_CATEGORY_IN_JOB_PATH = new Set(['indirect']);
|
||||
|
||||
/** Category → subcategory → last picker node for one catalog code. */
|
||||
export function prosthesisJobPathParts(
|
||||
code: string,
|
||||
catalog: CatalogLite,
|
||||
t: ProsthesisMessageFn,
|
||||
): { ancestors: string[]; leaf: string } {
|
||||
const entry = catalog.find((item) => item.code === code);
|
||||
if (!entry) {
|
||||
return { ancestors: [], leaf: humanizeCode(code) };
|
||||
}
|
||||
|
||||
const ancestors: string[] = [];
|
||||
const subcategory = entry.subcategory?.trim() ?? '';
|
||||
const skipCategory = SKIP_CATEGORY_IN_JOB_PATH.has(entry.category);
|
||||
if (entry.category && !skipCategory) {
|
||||
ancestors.push(t(`category_${entry.category}`));
|
||||
}
|
||||
if (subcategory) {
|
||||
ancestors.push(t(`sub_${subcategory}`));
|
||||
}
|
||||
|
||||
const leaf = leafFromCatalogLabel(entry.label) || entry.label || code;
|
||||
const uniqueAncestors: string[] = [];
|
||||
for (const ancestor of ancestors) {
|
||||
if (!ancestor || sameLabel(ancestor, leaf)) continue;
|
||||
if (uniqueAncestors.some((existing) => sameLabel(existing, ancestor))) continue;
|
||||
uniqueAncestors.push(ancestor);
|
||||
}
|
||||
return { ancestors: uniqueAncestors, leaf };
|
||||
}
|
||||
|
||||
function formatProsthesisJobPath(
|
||||
parts: { ancestors: string[]; leaf: string },
|
||||
): string {
|
||||
return [...parts.ancestors, parts.leaf].filter(Boolean).join(PROSTHESIS_JOB_PATH_SEP);
|
||||
}
|
||||
|
||||
/** Full picker path for a type or stacked `code+code` group. */
|
||||
export function prosthesisJobPathLabel(
|
||||
code: string,
|
||||
catalog: CatalogLite,
|
||||
t: ProsthesisMessageFn,
|
||||
): string {
|
||||
return splitProsthesisGroupCode(code)
|
||||
.map((part) => formatProsthesisJobPath(prosthesisJobPathParts(part, catalog, t)))
|
||||
.join(' + ');
|
||||
}
|
||||
@@ -79,6 +79,11 @@ export function categoryCodesInCatalog(
|
||||
return order.filter((code) => present.has(code));
|
||||
}
|
||||
|
||||
/** Matches category titles (e.g. Veneer/Inlay/Onlay/Overlay), not alphabetical codes. */
|
||||
const SUBCATEGORY_ORDER: Record<string, readonly string[]> = {
|
||||
indirect: ['veneer', 'inlay', 'onlay', 'overlay'],
|
||||
};
|
||||
|
||||
export function subcategoriesFor(
|
||||
catalog: readonly ProsthesisCatalogEntry[],
|
||||
category: string,
|
||||
@@ -94,7 +99,10 @@ export function subcategoriesFor(
|
||||
)
|
||||
.map((e) => e.subcategory),
|
||||
);
|
||||
return [...set].sort();
|
||||
const preferred = SUBCATEGORY_ORDER[category];
|
||||
if (!preferred) return [...set].sort();
|
||||
const rest = [...set].filter((code) => !preferred.includes(code)).sort();
|
||||
return [...preferred.filter((code) => set.has(code)), ...rest];
|
||||
}
|
||||
|
||||
export function leavesFor(
|
||||
|
||||
@@ -187,12 +187,3 @@ export function formatToothList(
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
export function prosthesisGroupLabelFromCatalog(
|
||||
code: string,
|
||||
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'label'>[],
|
||||
): string {
|
||||
const parts = splitProsthesisGroupCode(code);
|
||||
if (parts.length === 0) return code;
|
||||
return parts.map((part) => catalog.find((entry) => entry.code === part)?.label ?? part).join(' + ');
|
||||
}
|
||||
|
||||
@@ -312,10 +312,9 @@ export function CaseDetailPanel({
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ProsthesisStackedTypeLabel
|
||||
className="max-w-[16rem] text-sm font-medium"
|
||||
className="min-w-0 whitespace-normal break-words text-sm font-medium"
|
||||
code={group.prosthesisTypeCode}
|
||||
catalog={prosthesisCatalog}
|
||||
title={group.prosthesisTypeLabel}
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">
|
||||
{formatToothList(group.teeth, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import QRCode from 'react-qr-code';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CaseSheetFdiChart } from '@/components/ui/lab/CaseSheetFdiChart';
|
||||
import {
|
||||
buildCaseConnectedTeeth,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
formatPatientName,
|
||||
} from '@/components/lab/caseDetailUtils';
|
||||
import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import { prosthesisJobPathLabel } from '@/components/treatment/prosthesisJobPath';
|
||||
import type { LabCaseDetail } from '@/types/cases';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { FdiToothId } from '@/types/treatment';
|
||||
@@ -54,6 +56,7 @@ export function CaseSheetPrintLayout({
|
||||
labels,
|
||||
prosthesisCatalog,
|
||||
}: CaseSheetPrintLayoutProps) {
|
||||
const tProsthesis = useTranslations('prosthesis');
|
||||
const prosthesisRows = buildCaseProsthesisRows(labCase);
|
||||
const connectedTeeth = buildCaseConnectedTeeth(labCase);
|
||||
const selected = new Set<FdiToothId>();
|
||||
@@ -156,16 +159,11 @@ export function CaseSheetPrintLayout({
|
||||
<li style={{ color: '#64748b' }}>—</li>
|
||||
) : (
|
||||
prosthesisRows.map((row) => {
|
||||
const fromCatalog = prosthesisCatalog.find(
|
||||
(e) => e.code === row.prosthesisTypeCode,
|
||||
)?.label;
|
||||
const fromTasks = labCase.tasksByTooth.find(
|
||||
(g) => g.prosthesisTypeCode === row.prosthesisTypeCode,
|
||||
)?.prosthesisTypeLabel;
|
||||
const label =
|
||||
fromCatalog?.trim() ||
|
||||
fromTasks?.trim() ||
|
||||
row.prosthesisTypeCode.replace(/_/g, ' ');
|
||||
const label = prosthesisJobPathLabel(
|
||||
row.prosthesisTypeCode,
|
||||
prosthesisCatalog,
|
||||
(key) => tProsthesis(key as never),
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={`${row.selectionGroupId ?? ''}-${row.prosthesisTypeCode}-${row.teeth.join(',')}`}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { casesApi } from '@/lib/api/cases';
|
||||
import { tasksApi } from '@/lib/api/tasks';
|
||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||
import { prosthesisJobPathLabel } from '@/components/treatment/prosthesisJobPath';
|
||||
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
|
||||
import { Badge } from '@/components/ui/shared/Badge';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
@@ -45,6 +46,7 @@ const PAGE_SIZE = 10;
|
||||
|
||||
export function CasesPage() {
|
||||
const t = useTranslations('cases');
|
||||
const tProsthesis = useTranslations('prosthesis');
|
||||
const tErrors = useTranslations('errors');
|
||||
const tCommon = useTranslations('common');
|
||||
const { currentOrganization, user } = useAuth();
|
||||
@@ -90,8 +92,8 @@ export function CasesPage() {
|
||||
|
||||
const prosthesisLabel = useCallback(
|
||||
(code: string) =>
|
||||
prosthesisCatalog.find((entry) => entry.code === code)?.label ?? code,
|
||||
[prosthesisCatalog],
|
||||
prosthesisJobPathLabel(code, prosthesisCatalog, (key) => tProsthesis(key as never)),
|
||||
[prosthesisCatalog, tProsthesis],
|
||||
);
|
||||
|
||||
const treatmentDetailLabel = useCallback(
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge';
|
||||
import {
|
||||
formatToothList,
|
||||
prosthesisGroupLabelFromCatalog,
|
||||
prosthesisTypeLabelStyleFromCatalog,
|
||||
} from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import { ProsthesisStackedTypeLabel } from '@/components/ui/lab/ProsthesisStackedTypeLabel';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
|
||||
@@ -23,10 +19,6 @@ interface LabCaseProsthesisGroupsListProps {
|
||||
fallbackTeeth?: string[];
|
||||
}
|
||||
|
||||
function prosthesisLabel(code: string, catalog: readonly ProsthesisCatalogEntry[]): string {
|
||||
return catalog.find((entry) => entry.code === code)?.label ?? prosthesisGroupLabelFromCatalog(code, catalog);
|
||||
}
|
||||
|
||||
export function LabCaseProsthesisGroupsList({
|
||||
groups,
|
||||
prosthesisCatalog,
|
||||
@@ -109,12 +101,11 @@ export function LabCaseToothJobsList({
|
||||
<span className="text-text-muted" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span
|
||||
<ProsthesisStackedTypeLabel
|
||||
className="font-medium"
|
||||
style={prosthesisTypeLabelStyleFromCatalog(code, prosthesisCatalog)}
|
||||
>
|
||||
{prosthesisLabel(code, prosthesisCatalog)}
|
||||
</span>
|
||||
code={code}
|
||||
catalog={prosthesisCatalog}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
{row.connected ? (
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
prosthesisGroupLabelFromCatalog,
|
||||
prosthesisTypeLabelStyleFromCatalog,
|
||||
splitProsthesisGroupCode,
|
||||
} from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import {
|
||||
PROSTHESIS_JOB_PATH_SEP,
|
||||
prosthesisJobPathLabel,
|
||||
prosthesisJobPathParts,
|
||||
} from '@/components/treatment/prosthesisJobPath';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
|
||||
interface ProsthesisStackedTypeLabelProps {
|
||||
@@ -20,31 +25,28 @@ export function ProsthesisStackedTypeLabel({
|
||||
className,
|
||||
title,
|
||||
}: ProsthesisStackedTypeLabelProps) {
|
||||
const t = useTranslations('prosthesis');
|
||||
const tr = (key: string) => t(key as never);
|
||||
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>
|
||||
);
|
||||
}
|
||||
const tooltip = title?.trim() || prosthesisJobPathLabel(code, catalog, tr);
|
||||
|
||||
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 className={className} title={tooltip}>
|
||||
{parts.map((part, index) => {
|
||||
const path = prosthesisJobPathParts(part, catalog, tr);
|
||||
return (
|
||||
<span key={`${part}-${index}`}>
|
||||
{index > 0 ? <span className="text-text-muted"> + </span> : null}
|
||||
{path.ancestors.length > 0 ? (
|
||||
<span className="font-normal text-text-muted">
|
||||
{path.ancestors.join(PROSTHESIS_JOB_PATH_SEP)}
|
||||
{PROSTHESIS_JOB_PATH_SEP}
|
||||
</span>
|
||||
) : null}
|
||||
<span style={prosthesisTypeLabelStyleFromCatalog(part, catalog)}>{path.leaf}</span>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,10 +25,9 @@ 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">
|
||||
<ProsthesisStackedTypeLabel
|
||||
className="max-w-[16rem] text-xs font-medium"
|
||||
className="min-w-0 whitespace-normal break-words text-xs font-medium"
|
||||
code={group.prosthesisTypeCode}
|
||||
catalog={prosthesisCatalog}
|
||||
title={group.prosthesisTypeLabel}
|
||||
/>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{t('teethLabel', { teeth: formatToothList(group.teeth, archLabels) })}
|
||||
|
||||
@@ -206,10 +206,9 @@ export function TaskRow({
|
||||
</Button>
|
||||
) : null}
|
||||
<ProsthesisStackedTypeLabel
|
||||
className="w-full max-w-[10rem] text-xs font-medium sm:w-[8rem]"
|
||||
className="min-w-0 max-w-full whitespace-normal break-words text-xs font-medium sm:max-w-[16rem] sm:text-end"
|
||||
code={task.prosthesisTypeCode}
|
||||
catalog={prosthesisCatalog}
|
||||
title={task.prosthesisTypeLabel}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
toothJobRowsForDetail,
|
||||
} from '@/components/treatment/prosthesisTree';
|
||||
import { LabCaseToothJobsList } from '@/components/ui/lab/LabCaseProsthesisGroupsList';
|
||||
import {
|
||||
LabCaseShareQrDialog,
|
||||
LabCaseShareQrThumb,
|
||||
} from '@/components/ui/lab/LabCaseShareQrDialog';
|
||||
|
||||
interface LabCasesDispatchPanelProps {
|
||||
details: TreatmentDetailDraft[];
|
||||
@@ -105,6 +109,7 @@ export function LabCasesDispatchPanel({
|
||||
const tErrors = useTranslations('errors');
|
||||
const [fetchedCatalog, setFetchedCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
||||
const [pendingComment, setPendingComment] = useState('');
|
||||
const [shareQrOpen, setShareQrOpen] = useState(false);
|
||||
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
|
||||
|
||||
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
||||
@@ -161,6 +166,7 @@ export function LabCasesDispatchPanel({
|
||||
|
||||
useEffect(() => {
|
||||
setPendingComment('');
|
||||
setShareQrOpen(false);
|
||||
}, [activeLabCase?.clientId]);
|
||||
|
||||
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
|
||||
@@ -258,6 +264,7 @@ export function LabCasesDispatchPanel({
|
||||
? toothJobRowsForDetail(activeLabCase.toothProsthesis, activeDetailId)
|
||||
: [];
|
||||
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
|
||||
const shareUrl = activeLabCase?.shareUrl?.trim() ?? '';
|
||||
|
||||
return (
|
||||
<div className="surface-card p-3 sm:p-4 space-y-4 min-w-0 overflow-x-hidden">
|
||||
@@ -272,32 +279,44 @@ export function LabCasesDispatchPanel({
|
||||
{activeLabCase ? (
|
||||
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-3 sm:p-4 bg-background-secondary/30 min-w-0">
|
||||
{sent ? (
|
||||
<>
|
||||
{activeLabOrgName ? (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
|
||||
<p className="text-sm text-text-primary mt-1">{activeLabOrgName}</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col-reverse items-stretch gap-3 sm:flex-row sm:items-start">
|
||||
<div className="min-w-0 flex-1 space-y-4">
|
||||
{activeLabOrgName ? (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
|
||||
<p className="text-sm text-text-primary mt-1">{activeLabOrgName}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CaseSentLabel
|
||||
treatmentCase={{
|
||||
destinationOrganizationId: activeLabCase.destinationOrganizationId,
|
||||
sendToOrganizationIds: activeLabCase.destinationOrganizationId
|
||||
? [activeLabCase.destinationOrganizationId]
|
||||
: [],
|
||||
sentAt: activeLabCase.sentAt ?? null,
|
||||
sends: activeLabCase.sends,
|
||||
}}
|
||||
orgs={orgs}
|
||||
/>
|
||||
|
||||
{toothJobRows.length > 0 ? (
|
||||
<LabCaseToothJobsList
|
||||
rows={toothJobRows}
|
||||
prosthesisCatalog={catalog}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CaseSentLabel
|
||||
treatmentCase={{
|
||||
destinationOrganizationId: activeLabCase.destinationOrganizationId,
|
||||
sendToOrganizationIds: activeLabCase.destinationOrganizationId
|
||||
? [activeLabCase.destinationOrganizationId]
|
||||
: [],
|
||||
sentAt: activeLabCase.sentAt ?? null,
|
||||
sends: activeLabCase.sends,
|
||||
}}
|
||||
orgs={orgs}
|
||||
/>
|
||||
|
||||
{toothJobRows.length > 0 ? (
|
||||
<LabCaseToothJobsList
|
||||
rows={toothJobRows}
|
||||
prosthesisCatalog={catalog}
|
||||
/>
|
||||
) : null}
|
||||
{shareUrl ? (
|
||||
<div className="shrink-0 self-start">
|
||||
<LabCaseShareQrThumb
|
||||
shareUrl={shareUrl}
|
||||
onClick={() => setShareQrOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasTrackerSummary && labCaseSummary ? (
|
||||
<LabCaseTrackerCard
|
||||
@@ -317,7 +336,7 @@ export function LabCasesDispatchPanel({
|
||||
onActivityChange={onLabCaseActivityChange}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
@@ -430,6 +449,13 @@ export function LabCasesDispatchPanel({
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{shareUrl ? (
|
||||
<LabCaseShareQrDialog
|
||||
open={shareQrOpen}
|
||||
onClose={() => setShareQrOpen(false)}
|
||||
shareUrl={shareUrl}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export function ProsthesisCatalogIcon({
|
||||
src,
|
||||
size = 'md',
|
||||
}: {
|
||||
src: string;
|
||||
size?: 'sm' | 'md';
|
||||
}) {
|
||||
const box = size === 'sm' ? 'h-8 w-8 sm:h-9 sm:w-9' : 'h-9 w-9 sm:h-10 sm:w-10';
|
||||
return (
|
||||
<span className={`inline-flex shrink-0 items-center justify-center ${box}`} aria-hidden>
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="pointer-events-none max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ArrowLeft, Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
import { prosthesisFamilyTone } from '@/components/shared/catalog-type-colors';
|
||||
import { prosthesisTypeColorFromCatalog, prosthesisTypeFillAccent } from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import { prosthesisCatalogIconSrc } from '@/components/treatment/prosthesisCatalogIcons';
|
||||
import {
|
||||
PROSTHESIS_JOB_PATH_SEP,
|
||||
prosthesisJobPathParts,
|
||||
} from '@/components/treatment/prosthesisJobPath';
|
||||
import {
|
||||
catalogByCode,
|
||||
categoryCodesInCatalog,
|
||||
@@ -23,14 +28,24 @@ import {
|
||||
type ArchTarget,
|
||||
type PickerScope,
|
||||
} from '@/components/treatment/prosthesisTree';
|
||||
import { ProsthesisCatalogIcon } from '@/components/ui/treatment/ProsthesisCatalogIcon';
|
||||
|
||||
const CHIP_INK = '#14253d';
|
||||
const PARENT_BACK_INK = '#000000';
|
||||
const TILE_CLASS =
|
||||
'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';
|
||||
'flex min-h-[4.5rem] min-w-0 w-full flex-col items-center justify-center 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 CHILD_TILE_CLASS =
|
||||
'flex min-h-[3.75rem] min-w-0 w-full flex-col items-center justify-center rounded-[var(--radius-md)] border px-2 py-1 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-45';
|
||||
const PARENT_BAR_CLASS =
|
||||
'flex min-h-[3.75rem] min-w-0 w-full flex-row items-center justify-start gap-2 rounded-[var(--radius-md)] border px-3 py-1.5 text-start 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 =
|
||||
'w-full whitespace-normal break-words text-center text-xs font-medium leading-snug sm:text-[13px]';
|
||||
'w-full whitespace-normal break-words text-center text-xs font-medium leading-tight sm:text-[13px]';
|
||||
const TILE_TEXT_CHILD_CLASS =
|
||||
'w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-center text-[11px] font-medium leading-tight sm:text-xs';
|
||||
const TILE_GRID_CLASS =
|
||||
'grid gap-2 [grid-template-columns:repeat(auto-fill,minmax(8rem,1fr))]';
|
||||
const TILE_GRID_CHILD_CLASS =
|
||||
'grid gap-2 [grid-template-columns:repeat(auto-fill,minmax(10.2rem,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) {
|
||||
@@ -70,7 +85,9 @@ function Tile({
|
||||
active,
|
||||
disabled,
|
||||
hint,
|
||||
className = TILE_CLASS,
|
||||
iconSrc,
|
||||
compact,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
@@ -78,19 +95,23 @@ function Tile({
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
iconSrc?: string;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const base = className ?? (compact ? CHILD_TILE_CLASS : TILE_CLASS);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={hint ?? label}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`${className} ${tileSurfaceClass(active)}`}
|
||||
className={`${base} ${compact ? 'gap-0.5' : 'gap-2'} ${tileSurfaceClass(active)}`}
|
||||
style={color ? { backgroundColor: color, color: CHIP_INK } : undefined}
|
||||
>
|
||||
<span className={TILE_TEXT_CLASS}>{label}</span>
|
||||
{iconSrc ? <ProsthesisCatalogIcon src={iconSrc} /> : null}
|
||||
<span className={compact ? TILE_TEXT_CHILD_CLASS : TILE_TEXT_CLASS}>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -109,6 +130,19 @@ function parentStep(step: PickerStep): PickerStep | null {
|
||||
}
|
||||
}
|
||||
|
||||
function pickerStepKey(step: PickerStep): string {
|
||||
switch (step.kind) {
|
||||
case 'category':
|
||||
return 'category';
|
||||
case 'addon':
|
||||
return `addon:${step.slot}`;
|
||||
case 'subcategory':
|
||||
return `sub:${step.category}:${step.arch ?? ''}`;
|
||||
case 'leaves':
|
||||
return `leaves:${step.category}:${step.subcategory ?? ''}:${step.arch ?? ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProsthesisJobPopover({
|
||||
open,
|
||||
mobile,
|
||||
@@ -128,6 +162,31 @@ export function ProsthesisJobPopover({
|
||||
onClose,
|
||||
}: ProsthesisJobPopoverProps) {
|
||||
const t = useTranslations('prosthesis');
|
||||
const childGridRef = useRef<HTMLDivElement>(null);
|
||||
const [branchH, setBranchH] = useState(0);
|
||||
const stepKey = pickerStepKey(step);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || stepKey === 'category') return;
|
||||
const grid = childGridRef.current;
|
||||
if (!grid) return;
|
||||
|
||||
const sync = () => {
|
||||
const first = grid.firstElementChild;
|
||||
if (!(first instanceof HTMLElement)) return;
|
||||
setBranchH(first.offsetHeight);
|
||||
};
|
||||
|
||||
sync();
|
||||
const ro = new ResizeObserver(sync);
|
||||
ro.observe(grid);
|
||||
grid.addEventListener('animationend', sync);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
grid.removeEventListener('animationend', sync);
|
||||
};
|
||||
}, [open, stepKey]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const categories = categoryCodesInCatalog(catalog, scope);
|
||||
@@ -159,10 +218,15 @@ export function ProsthesisJobPopover({
|
||||
}
|
||||
|
||||
function leafTile(leaf: ProsthesisCatalogEntry) {
|
||||
const path = prosthesisJobPathParts(leaf.code, catalog, (key) => t(key as never));
|
||||
const underIndirectSub =
|
||||
leaf.category === 'indirect' && step.kind === 'leaves' && Boolean(step.subcategory);
|
||||
return (
|
||||
<Tile
|
||||
key={leaf.code}
|
||||
label={leaf.label}
|
||||
label={underIndirectSub ? path.leaf : leaf.label}
|
||||
compact
|
||||
iconSrc={prosthesisCatalogIconSrc(leaf)}
|
||||
color={prosthesisTypeColorFromCatalog(leaf.code, catalog)}
|
||||
active={jobCodes.includes(leaf.code)}
|
||||
onClick={() => onPickLeaf(leaf.code, archForLeaves)}
|
||||
@@ -180,7 +244,11 @@ export function ProsthesisJobPopover({
|
||||
<Tile
|
||||
key="slot-crown"
|
||||
className={`${TILE_CLASS} h-full`}
|
||||
compact
|
||||
label={byCode.get(currentCrown)?.label ?? currentCrown}
|
||||
iconSrc={prosthesisCatalogIconSrc(
|
||||
byCode.get(currentCrown) ?? { code: currentCrown, category: 'crown' },
|
||||
)}
|
||||
color={prosthesisTypeColorFromCatalog(currentCrown, catalog)}
|
||||
active={addonActive}
|
||||
onClick={openAddon}
|
||||
@@ -207,7 +275,7 @@ export function ProsthesisJobPopover({
|
||||
}
|
||||
}
|
||||
|
||||
let selectedNode: { label: string; color?: string } | null = null;
|
||||
let selectedNode: { label: string; color?: string; iconSrc?: string } | null = null;
|
||||
let childTiles: ReactNode[] = [];
|
||||
let childrenKey = 'root';
|
||||
|
||||
@@ -219,6 +287,7 @@ export function ProsthesisJobPopover({
|
||||
<Tile
|
||||
key={code}
|
||||
label={categoryLabel(code)}
|
||||
iconSrc={prosthesisCatalogIconSrc({ category: code })}
|
||||
color={categoryTileColor(code)}
|
||||
disabled={disabled}
|
||||
hint={disabled ? t('categoryDisabledHint') : undefined}
|
||||
@@ -228,11 +297,16 @@ export function ProsthesisJobPopover({
|
||||
});
|
||||
break;
|
||||
case 'addon':
|
||||
selectedNode = { label: t('slotCrown'), color: categoryTileColor('crown') };
|
||||
selectedNode = {
|
||||
label: t('slotCrown'),
|
||||
color: categoryTileColor('crown'),
|
||||
iconSrc: prosthesisCatalogIconSrc({ category: 'crown' }),
|
||||
};
|
||||
childrenKey = 'addon-crown';
|
||||
childTiles = [
|
||||
<Tile
|
||||
key="none"
|
||||
compact
|
||||
label={t('addonNone')}
|
||||
active={!currentCrown}
|
||||
onClick={() => onPickAddon(null, 'crown')}
|
||||
@@ -240,7 +314,9 @@ export function ProsthesisJobPopover({
|
||||
...crownLeaves.map((opt) => (
|
||||
<Tile
|
||||
key={opt.code}
|
||||
compact
|
||||
label={opt.label}
|
||||
iconSrc={prosthesisCatalogIconSrc(opt)}
|
||||
color={prosthesisTypeColorFromCatalog(opt.code, catalog)}
|
||||
active={currentCrown === opt.code}
|
||||
onClick={() => onPickAddon(opt.code, 'crown')}
|
||||
@@ -250,13 +326,19 @@ export function ProsthesisJobPopover({
|
||||
break;
|
||||
case 'subcategory': {
|
||||
const parentFill = categoryTileColor(step.category);
|
||||
selectedNode = { label: categoryLabel(step.category), color: parentFill };
|
||||
selectedNode = {
|
||||
label: categoryLabel(step.category),
|
||||
color: parentFill,
|
||||
iconSrc: prosthesisCatalogIconSrc({ category: step.category }),
|
||||
};
|
||||
childrenKey = `sub-${step.category}`;
|
||||
childTiles = [
|
||||
...subcategoriesFor(catalog, step.category, scope).map((sub, i) => (
|
||||
<Tile
|
||||
key={sub}
|
||||
compact
|
||||
label={subLabel(sub)}
|
||||
iconSrc={prosthesisCatalogIconSrc({ category: step.category, subcategory: sub })}
|
||||
color={prosthesisFamilyTone(parentFill, i + 1)}
|
||||
onClick={() =>
|
||||
onStep({
|
||||
@@ -277,6 +359,10 @@ export function ProsthesisJobPopover({
|
||||
selectedNode = {
|
||||
label: subLabel(step.subcategory),
|
||||
color: prosthesisFamilyTone(categoryTileColor(step.category), 1),
|
||||
iconSrc: prosthesisCatalogIconSrc({
|
||||
category: step.category,
|
||||
subcategory: step.subcategory,
|
||||
}),
|
||||
};
|
||||
childrenKey = `leaves-${step.category}-${step.subcategory}`;
|
||||
childTiles = leavesFor(catalog, step.category, step.subcategory, scope).map(leafTile);
|
||||
@@ -284,6 +370,7 @@ export function ProsthesisJobPopover({
|
||||
selectedNode = {
|
||||
label: categoryLabel(step.category),
|
||||
color: categoryTileColor(step.category),
|
||||
iconSrc: prosthesisCatalogIconSrc({ category: step.category }),
|
||||
};
|
||||
childrenKey = `leaves-${step.category}`;
|
||||
childTiles = leavesFor(catalog, step.category, undefined, scope).map(leafTile);
|
||||
@@ -294,9 +381,10 @@ export function ProsthesisJobPopover({
|
||||
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)]'
|
||||
}`}
|
||||
ref={childGridRef}
|
||||
className={`picker-expand min-w-0 flex-1 ${
|
||||
selectedNode ? TILE_GRID_CHILD_CLASS : TILE_GRID_CLASS
|
||||
}${selectedNode ? '' : ' h-full min-h-0 [grid-auto-rows:minmax(4.5rem,1fr)]'}`}
|
||||
>
|
||||
{childTiles}
|
||||
</div>
|
||||
@@ -311,7 +399,7 @@ export function ProsthesisJobPopover({
|
||||
if (back) onStep(back);
|
||||
}}
|
||||
disabled={!back}
|
||||
className={`${TILE_CLASS} flex-row justify-start gap-2 px-3 text-start`}
|
||||
className={PARENT_BAR_CLASS}
|
||||
style={
|
||||
selectedNode.color
|
||||
? { backgroundColor: selectedNode.color, color: CHIP_INK }
|
||||
@@ -320,18 +408,19 @@ export function ProsthesisJobPopover({
|
||||
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
|
||||
}
|
||||
className="h-4 w-4 shrink-0 rtl:rotate-180"
|
||||
style={{ color: PARENT_BACK_INK }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={`${TILE_TEXT_CLASS} text-start`}>{selectedNode.label}</span>
|
||||
{selectedNode.iconSrc ? (
|
||||
<ProsthesisCatalogIcon src={selectedNode.iconSrc} size="sm" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 whitespace-normal break-words text-start text-xs font-medium leading-tight sm:text-[13px]">
|
||||
{selectedNode.label}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-start">
|
||||
<div className="relative h-[3.25rem] w-3 shrink-0" aria-hidden>
|
||||
<div className="relative w-3 shrink-0" style={{ height: branchH || undefined }} aria-hidden>
|
||||
<span
|
||||
className="absolute w-0.5"
|
||||
style={{
|
||||
@@ -375,7 +464,7 @@ export function ProsthesisJobPopover({
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{jobCodes.map((code) => {
|
||||
const entry = byCode.get(code);
|
||||
const path = prosthesisJobPathParts(code, catalog, (key) => t(key as never));
|
||||
const color = prosthesisTypeColorFromCatalog(code, catalog);
|
||||
const trashColor = prosthesisTypeFillAccent(color);
|
||||
return (
|
||||
@@ -388,13 +477,14 @@ export function ProsthesisJobPopover({
|
||||
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 className="inline-flex items-center px-2 py-0.5">
|
||||
{path.ancestors.length > 0 ? (
|
||||
<span className="opacity-70">
|
||||
{path.ancestors.join(PROSTHESIS_JOB_PATH_SEP)}
|
||||
{PROSTHESIS_JOB_PATH_SEP}
|
||||
</span>
|
||||
) : null}
|
||||
{path.leaf}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -418,8 +508,8 @@ export function ProsthesisJobPopover({
|
||||
);
|
||||
|
||||
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">
|
||||
<div className="grid h-full min-h-0 flex-1 grid-cols-[4fr_1fr]">
|
||||
<section className="flex min-h-0 min-w-0 flex-col gap-2.5 border-e border-border pe-3">
|
||||
<p className="shrink-0 text-xs font-semibold leading-none text-text-primary">{title}</p>
|
||||
{scope === 'arch' && onLockedArchChange ? (
|
||||
<div
|
||||
@@ -451,7 +541,7 @@ export function ProsthesisJobPopover({
|
||||
{tileRow}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex min-h-0 min-w-0 flex-col gap-1">
|
||||
<section className="flex min-h-0 min-w-0 flex-col gap-1 ps-3">
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
isLabDependentDetailMissingTeeth,
|
||||
} from '@/components/treatment/treatmentDetailRules';
|
||||
|
||||
const WORKSPACE_ACTION_BTN_HEIGHT = 'h-[2.875rem] sm:h-9';
|
||||
const WORKSPACE_ACTION_BTN_CLASS = `${WORKSPACE_ACTION_BTN_HEIGHT} shrink-0 px-3 text-sm`;
|
||||
|
||||
interface TreatmentDetailsEditorProps {
|
||||
details: TreatmentDetailDraft[];
|
||||
activeDetailId: string;
|
||||
@@ -116,6 +119,8 @@ export function TreatmentDetailsEditor({
|
||||
);
|
||||
}
|
||||
|
||||
const actionWidthLabels = [t('showLabDispatch'), t('showChart'), t('addDetail')];
|
||||
|
||||
return (
|
||||
<div className="surface-card p-3 sm:p-4 space-y-4">
|
||||
{showChrome ? (
|
||||
@@ -127,6 +132,7 @@ export function TreatmentDetailsEditor({
|
||||
{voice ? (
|
||||
<AddDetailWithVoice
|
||||
addLabel={t('addDetail')}
|
||||
widthLabels={actionWidthLabels}
|
||||
startLabel={t('voiceStart')}
|
||||
stopLabel={t('voiceStop')}
|
||||
disabled={!canEdit || disabled}
|
||||
@@ -137,12 +143,12 @@ export function TreatmentDetailsEditor({
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!canEdit || disabled}
|
||||
onClick={onAddDetail}
|
||||
fullWidth
|
||||
className="sm:w-auto shrink-0"
|
||||
className={WORKSPACE_ACTION_BTN_CLASS}
|
||||
>
|
||||
{t('addDetail')}
|
||||
<WorkspaceActionLabel label={t('addDetail')} widthLabels={actionWidthLabels} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -263,7 +269,8 @@ export function TreatmentDetailsEditor({
|
||||
<Button
|
||||
type="button"
|
||||
variant={labWorkspaceView === 'dispatch' ? 'outline' : 'primary'}
|
||||
className={`h-[2.875rem] sm:h-9 shrink-0 px-3 text-sm ${
|
||||
size="sm"
|
||||
className={`${WORKSPACE_ACTION_BTN_CLASS} ${
|
||||
labWorkspaceView === 'dispatch' ? 'bg-background-secondary' : ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
@@ -272,17 +279,10 @@ export function TreatmentDetailsEditor({
|
||||
)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<WorkspaceActionLabel
|
||||
label={labWorkspaceView === 'dispatch' ? t('showChart') : t('showLabDispatch')}
|
||||
widthLabels={actionWidthLabels}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -327,6 +327,29 @@ export function TreatmentDetailsEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceActionLabel({
|
||||
label,
|
||||
widthLabels,
|
||||
}: {
|
||||
label: string;
|
||||
widthLabels: string[];
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-grid justify-items-center">
|
||||
{widthLabels.map((text, index) => (
|
||||
<span
|
||||
key={`${index}-${text}`}
|
||||
className="invisible col-start-1 row-start-1 whitespace-nowrap"
|
||||
aria-hidden
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
))}
|
||||
<span className="col-start-1 row-start-1 whitespace-nowrap">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesField({
|
||||
textareaRef,
|
||||
value,
|
||||
@@ -377,6 +400,7 @@ function NotesField({
|
||||
*/
|
||||
function AddDetailWithVoice({
|
||||
addLabel,
|
||||
widthLabels,
|
||||
startLabel,
|
||||
stopLabel,
|
||||
disabled,
|
||||
@@ -384,6 +408,7 @@ function AddDetailWithVoice({
|
||||
voice,
|
||||
}: {
|
||||
addLabel: string;
|
||||
widthLabels: string[];
|
||||
startLabel: string;
|
||||
stopLabel: string;
|
||||
disabled: boolean;
|
||||
@@ -397,8 +422,8 @@ function AddDetailWithVoice({
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
inline-flex w-full items-stretch overflow-hidden rounded-[var(--radius-md)]
|
||||
bg-primary text-white shrink-0 sm:w-auto
|
||||
inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)]
|
||||
bg-primary text-white shrink-0 ${WORKSPACE_ACTION_BTN_HEIGHT}
|
||||
${disabled ? 'opacity-60' : ''}
|
||||
`}
|
||||
>
|
||||
@@ -407,13 +432,13 @@ function AddDetailWithVoice({
|
||||
onClick={onAddDetail}
|
||||
disabled={disabled || isBusy}
|
||||
className="
|
||||
flex-1 px-4 py-2 text-sm font-medium transition-all duration-200
|
||||
px-3 text-sm font-medium transition-all duration-200
|
||||
hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset
|
||||
focus-visible:ring-white/60
|
||||
disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:opacity-60
|
||||
"
|
||||
>
|
||||
{addLabel}
|
||||
<WorkspaceActionLabel label={addLabel} widthLabels={widthLabels} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -295,6 +295,7 @@ function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
|
||||
sends: lc.sends ?? [],
|
||||
dueDate: lc.dueDate ?? null,
|
||||
taskProgress: lc.taskProgress ?? null,
|
||||
shareUrl: lc.shareUrl ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -396,7 +397,7 @@ function mergeServerIdsIntoLabCases(
|
||||
return local.map((lc) => {
|
||||
const s = byClientId.get(lc.clientId);
|
||||
if (!s) return lc;
|
||||
return { ...lc, id: s.id ?? lc.id };
|
||||
return { ...lc, id: s.id ?? lc.id, shareUrl: s.shareUrl ?? lc.shareUrl };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2541,6 +2542,7 @@ export function TreatmentWorkspace({
|
||||
destinationOrganizationId: response.data.destinationOrganizationId,
|
||||
sends: response.data.sends,
|
||||
taskProgress: response.data.taskProgress ?? lc.taskProgress,
|
||||
shareUrl: response.data.shareUrl ?? lc.shareUrl,
|
||||
}
|
||||
: lc,
|
||||
),
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface PastLabCase {
|
||||
dueDate?: string | null;
|
||||
taskProgress?: LabCaseTaskProgress | null;
|
||||
attachments?: TreatmentAttachmentMeta[];
|
||||
/** Present on sent cases — same share URL the lab Cases QR encodes. */
|
||||
shareUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface PastTreatment {
|
||||
@@ -183,6 +185,7 @@ export interface LabCaseDraft {
|
||||
sends?: LabCaseSendInfo[];
|
||||
dueDate?: string | null;
|
||||
taskProgress?: LabCaseTaskProgress | null;
|
||||
shareUrl?: string | null;
|
||||
}
|
||||
|
||||
export type SavedTreatmentDetailPayload = {
|
||||
@@ -237,4 +240,5 @@ export interface LabCaseResponse {
|
||||
taskProgress?: LabCaseTaskProgress | null;
|
||||
toothProsthesis?: LabCaseToothProsthesisDraft[];
|
||||
attachments?: TreatmentAttachmentMeta[];
|
||||
shareUrl?: string | null;
|
||||
}
|
||||
|
||||