improvement: icons added to prosthesis types catalog.
Some checks failed
Production — tag build, push, deploy / build-and-push (push) Failing after 23s
Production — tag build, push, deploy / deploy (push) Has been skipped

This commit is contained in:
2026-09-05 01:29:53 +03:30
parent 4d7a4b390f
commit 5d3597f973
83 changed files with 1146 additions and 187 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because one or more lines are too long

View 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;
}

View 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(' + ');
}

View File

@@ -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(

View File

@@ -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(' + ');
}

View File

@@ -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, {

View File

@@ -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(',')}`}

View File

@@ -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(

View File

@@ -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 ? (

View File

@@ -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>
);
}

View File

@@ -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) })}

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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,
),

View File

@@ -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;
}