improvement: attachment selection for lab dispatch added. attachment preview added to cases feature.

This commit is contained in:
2026-07-07 18:43:10 +03:30
parent b2d40b3e97
commit 86b1e3afff
25 changed files with 767 additions and 84 deletions

View File

@@ -0,0 +1,63 @@
'use client';
import { useMemo } from 'react';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { prosthesisTypeColor } from '@/components/ui/treatment/prosthesisTypeDisplay';
import type { FdiToothId } from '@/types/treatment';
export interface CaseToothChartDetail {
teeth: string[];
}
export interface CaseToothChartProsthesisRow {
teeth: string[];
prosthesisTypeCode: string;
}
interface CaseToothChartPanelProps {
details: CaseToothChartDetail[];
/** Prosthesis mapping from case tasks or toothProsthesis rows. */
prosthesisRows: CaseToothChartProsthesisRow[];
scale?: number;
className?: string;
}
/** Read-only FDI chart for lab case detail — prosthesis-type glow on selected teeth. */
export function CaseToothChartPanel({
details,
prosthesisRows,
scale = 0.5,
className = '',
}: CaseToothChartPanelProps) {
const selected = useMemo(() => {
const set = new Set<FdiToothId>();
for (const detail of details) {
for (const tooth of detail.teeth) set.add(tooth as FdiToothId);
}
return set;
}, [details]);
const toothColors = useMemo(() => {
const colors: Partial<Record<FdiToothId, string>> = {};
prosthesisRows.forEach((row, index) => {
const color = prosthesisTypeColor(row.prosthesisTypeCode, index);
for (const tooth of row.teeth) {
colors[tooth as FdiToothId] = color;
}
});
return colors;
}, [prosthesisRows]);
if (selected.size === 0) return null;
return (
<FdiToothChart
selected={selected}
readOnly
scale={scale}
toothColors={toothColors}
compact
className={className}
/>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import { useEffect, useState } from 'react';
import { FileText } from 'lucide-react';
import type { LabCaseAttachmentMeta } from '@/types/cases';
interface LabCaseAttachmentPreviewProps {
caseId: string;
attachment: LabCaseAttachmentMeta;
loadBlob: (caseId: string, attachmentId: string) => Promise<Blob>;
className?: string;
}
export function LabCaseAttachmentPreview({
caseId,
attachment,
loadBlob,
className = 'aspect-square w-full max-w-[11rem]',
}: LabCaseAttachmentPreviewProps) {
const [url, setUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
let objectUrl: string | null = null;
void (async () => {
try {
const blob = await loadBlob(caseId, attachment.id);
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
setFailed(false);
} catch {
if (!cancelled) setFailed(true);
}
})();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [caseId, attachment.id, loadBlob]);
const isImage = attachment.mimeType.startsWith('image/');
const isPdf = attachment.mimeType === 'application/pdf';
return (
<div
className={`${className} rounded-[var(--radius-md)] border border-border/60 bg-background-secondary overflow-hidden`}
title={attachment.fileName}
>
{url && isImage ? (
<img src={url} alt={attachment.fileName} className="h-full w-full object-cover" />
) : url && isPdf ? (
<iframe src={url} title={attachment.fileName} className="h-full w-full border-0" />
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5 p-2 text-text-muted">
<FileText className="h-8 w-8 shrink-0 icon-flat" aria-hidden />
<span className="line-clamp-2 text-center text-[10px] leading-tight">
{failed ? 'Preview unavailable' : attachment.fileName}
</span>
</div>
)}
</div>
);
}

View File

@@ -1,17 +1,25 @@
import type { CSSProperties } from 'react';
import type { BadgeVariant } from '@/components/ui/shared/Badge';
import type { LabTaskStatus } from '@/types/cases';
export function labTaskStatusVariant(status: LabTaskStatus): BadgeVariant {
return status === 'COMPLETED' ? 'success' : 'default';
return status === 'COMPLETED' ? 'success' : 'warning';
}
export function labTaskStatusSelectClass(status: LabTaskStatus): string {
switch (status) {
case 'COMPLETED':
return 'border-success/60 text-success';
case 'IN_PROGRESS':
return 'border-primary/60 text-primary';
default:
return '';
}
/**
* Inline style for the closed status <select> so its text/border reflect the
* current value (yellow = in progress, green = completed). Uses the same badge
* token colors as the badges for consistency. Native <option> colors have
* limited cross-browser support, so only the closed control is themed.
*/
export function labTaskStatusSelectStyle(status: LabTaskStatus): CSSProperties {
const color =
status === 'COMPLETED'
? 'var(--color-badge-success-fg)'
: 'var(--color-badge-warning-fg)';
const borderColor =
status === 'COMPLETED'
? 'var(--color-badge-success-border)'
: 'var(--color-badge-warning-border)';
return { color, borderColor };
}

View File

@@ -14,11 +14,14 @@ import { Button } from '@/components/ui/shared/Button';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { ToastStack } from '@/components/ui/shared/Toast';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
import {
formatToothList,
prosthesisTypeBadgeStyle,
} from '@/components/ui/treatment/prosthesisTypeDisplay';
import { treatmentsApi } from '@/lib/api/treatments';
import type { CounterpartItemDto } from '@/lib/api/organization';
import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
@@ -186,6 +189,39 @@ export function ConnectionCaseHistoryContent({
document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' });
}
const loadClinicAttachmentBlob = useCallback(
(_caseId: string, attachmentId: string) => treatmentsApi.getAttachmentFileBlob(attachmentId),
[],
);
const latestCaseAttachment = useMemo(() => {
if (!selectedCase?.attachments.length) return null;
return [...selectedCase.attachments].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0];
}, [selectedCase?.attachments]);
const caseProsthesisRows = useMemo(() => {
if (!selectedCase) return [];
if (selectedCase.toothProsthesis.length > 0) {
const byCode = new Map<string, string[]>();
for (const row of selectedCase.toothProsthesis) {
const key = row.prosthesisTypeCode;
const teeth = byCode.get(key) ?? [];
if (!teeth.includes(row.tooth)) teeth.push(row.tooth);
byCode.set(key, teeth);
}
return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({
prosthesisTypeCode,
teeth,
}));
}
return selectedCase.tasksByTooth.map((g) => ({
prosthesisTypeCode: g.prosthesisTypeCode,
teeth: g.teeth,
}));
}, [selectedCase]);
return (
<div className="space-y-6">
<div>
@@ -348,6 +384,27 @@ export function ConnectionCaseHistoryContent({
</div>
</header>
<div className="flex flex-wrap items-start gap-4">
<CaseToothChartPanel
details={selectedCase.details}
prosthesisRows={caseProsthesisRows}
scale={0.5}
className="min-w-0 flex-1"
/>
{latestCaseAttachment && selectedCaseId ? (
<div className="shrink-0 space-y-1">
<p className="text-xs font-medium text-text-secondary">
{tCases('latestAttachment')}
</p>
<LabCaseAttachmentPreview
caseId={selectedCaseId}
attachment={latestCaseAttachment}
loadBlob={loadClinicAttachmentBlob}
/>
</div>
) : null}
</div>
{selectedCase.details.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-text-primary">
@@ -383,12 +440,13 @@ export function ConnectionCaseHistoryContent({
className="rounded-md border border-border p-3 space-y-2"
>
<div className="flex flex-wrap items-center gap-2">
<span
className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium border"
<Badge
truncate
title={group.prosthesisTypeLabel}
style={prosthesisTypeBadgeStyle(group.prosthesisTypeCode, groupIndex)}
>
{group.prosthesisTypeLabel}
</span>
</Badge>
<span className="text-sm font-medium text-text-primary">
{tCases('toothGroupTitle', {
teeth: formatToothList(group.teeth),

View File

@@ -11,6 +11,15 @@ interface BadgeProps {
* Set false only when the pill should shrink to the label.
*/
fixedWidth?: boolean;
/**
* Inline style overriding the variant colors — e.g. dynamic prosthesis-type
* pastels via `prosthesisTypeBadgeStyle(code)`. Wins over variant classes.
*/
style?: React.CSSProperties;
/** Native tooltip, useful when the label may be truncated. */
title?: string;
/** Clip an over-long label with an ellipsis instead of wrapping/overflowing. */
truncate?: boolean;
}
const variantStyles: Record<BadgeVariant, string> = {
@@ -29,16 +38,22 @@ export function Badge({
variant = 'default',
className,
fixedWidth = true,
style,
title,
truncate = false,
}: BadgeProps) {
const layoutClass = fixedWidth
? `${FIXED_LAYOUT_CLASS} justify-center text-center`
: 'min-h-[1.75rem] px-2.5 py-1 justify-center';
const wrapClass = truncate ? '' : 'whitespace-nowrap';
return (
<span
className={`inline-flex items-center box-border rounded-md border text-xs font-medium leading-none whitespace-nowrap ${variantStyles[variant]} ${layoutClass} ${className ?? ''}`}
className={`inline-flex items-center box-border rounded-md border text-xs font-medium leading-none ${wrapClass} ${variantStyles[variant]} ${layoutClass} ${className ?? ''}`}
style={style}
title={title}
>
{children}
{truncate ? <span className="w-full truncate text-center">{children}</span> : children}
</span>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useId } from 'react';
import { useId, type CSSProperties, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } from '@/components/treatment/fdiToothMeta';
import { ToothGlyph } from '@/components/ui/treatment/ToothGlyph';
@@ -21,14 +21,37 @@ function quadrantMirrored(fdi: FdiToothId): boolean {
interface FdiToothChartProps {
selected: ReadonlySet<FdiToothId>;
onToggle: (fdi: FdiToothId) => void;
onToggle?: (fdi: FdiToothId) => void;
disabled?: boolean;
/** Non-interactive display (Cases / connection history). */
readOnly?: boolean;
/** Visual scale via CSS zoom (0.5 = half size). */
scale?: number;
/** Per-tooth accent color for selected glow (prosthesis / treatment-type palettes). */
toothColors?: Partial<Record<FdiToothId, string>>;
/** Extra control rendered in the chart header (e.g. whole-plan checkbox). */
headerControl?: ReactNode;
/** Compact card for embedded case detail panels. */
compact?: boolean;
className?: string;
}
export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartProps) {
export function FdiToothChart({
selected,
onToggle,
disabled,
readOnly = false,
scale = 1,
toothColors,
headerControl,
compact = false,
className = '',
}: FdiToothChartProps) {
const t = useTranslations('treatment');
const uid = useId().replace(/:/g, '');
const archPeak = 10;
const interactive = !readOnly && Boolean(onToggle);
const isDisabled = disabled || readOnly;
const TOOTH_TWEAKS: Record<FdiToothId, { glyph: string; offset: number; rotate: number }> = {
'18': { glyph: 'w-[1.98rem] h-[4.65rem]', offset: 7, rotate: -11 },
@@ -104,6 +127,20 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
return normalized * 6;
};
const toothAccent = (fdi: FdiToothId) => toothColors?.[fdi];
const numberColorClass = (fdi: FdiToothId, isSel: boolean) => {
if (!isSel) return 'text-text-muted';
const accent = toothAccent(fdi);
return accent ? '' : 'text-primary';
};
const numberStyle = (fdi: FdiToothId, isSel: boolean): CSSProperties | undefined => {
if (!isSel) return undefined;
const accent = toothAccent(fdi);
return accent ? { color: accent } : undefined;
};
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
<div className="flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit">
{teeth.map((fdi, i) => {
@@ -115,37 +152,54 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper);
const rotate = tweak ? tweak.rotate : archRotate(i, teeth.length);
const alignItems = upper ? 'items-end' : 'items-start';
const accent = toothAccent(fdi);
const glyph = (
<ToothGlyph
fdi={fdi}
kind={kind}
gradientId={gid}
selected={isSel}
upper={upper}
mirrored={quadrantMirrored(fdi)}
upsideDown={upper}
className={tweak?.glyph ?? size.glyph}
accentColor={isSel ? accent : undefined}
/>
);
return (
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
<button
type="button"
disabled={disabled}
onClick={() => onToggle(fdi)}
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className={`
rounded-[var(--radius-sm)] p-0.5 transition-transform
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
`}
aria-pressed={isSel}
aria-label={
isSel
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}`
: t('toothAria', { fdi })
}
>
<ToothGlyph
fdi={fdi}
kind={kind}
gradientId={gid}
selected={isSel}
upper={upper}
mirrored={quadrantMirrored(fdi)}
upsideDown={upper}
className={tweak?.glyph ?? size.glyph}
/>
</button>
{interactive ? (
<button
type="button"
disabled={isDisabled}
onClick={() => onToggle?.(fdi)}
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className={`
rounded-[var(--radius-sm)] p-0.5 transition-transform
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
`}
aria-pressed={isSel}
aria-label={
isSel
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}`
: t('toothAria', { fdi })
}
>
{glyph}
</button>
) : (
<div
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className="rounded-[var(--radius-sm)] p-0.5"
aria-hidden={!isSel}
>
{glyph}
</div>
)}
</div>
</div>
);
@@ -153,20 +207,12 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
</div>
);
return (
<div className="surface-card p-3 space-y-3">
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold text-text-primary">{t('toothChartTitle')}</h3>
<p className="text-[11px] text-text-muted mt-0.5">
{t('toothChartHint')}
</p>
</div>
<p className="text-[11px] text-text-secondary tabular-nums sm:text-right">
{t('selectedLabel')} {selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
</p>
</div>
const cardClass = compact
? 'rounded-lg border border-border/60 bg-background-secondary/30 p-2 space-y-2'
: 'surface-card p-3 space-y-3';
const chartBody = (
<>
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">{t('upperArch')}</p>
<div className="overflow-x-auto py-1 -mx-1 px-1">
@@ -184,9 +230,8 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
return (
<span
key={`u-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${
isSel ? 'text-primary' : 'text-text-muted'
}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${numberColorClass(fdi, isSel)}`}
style={numberStyle(fdi, isSel)}
>
{fdi}
</span>
@@ -204,9 +249,8 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
return (
<span
key={`l-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${
isSel ? 'text-primary' : 'text-text-muted'
}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${numberColorClass(fdi, isSel)}`}
style={numberStyle(fdi, isSel)}
>
{fdi}
</span>
@@ -222,6 +266,36 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
</div>
<p className="text-[11px] uppercase tracking-wide text-text-muted mt-1 text-center">{t('lowerArch')}</p>
</>
);
return (
<div className={`${cardClass} ${className}`}>
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-text-primary">
{compact ? t('toothChartTitleCompact') : t('toothChartTitle')}
</h3>
{!compact && (
<p className="text-[11px] text-text-muted mt-0.5">{t('toothChartHint')}</p>
)}
</div>
<div className="flex flex-col items-start gap-1.5 sm:items-end shrink-0">
{headerControl}
<p className="text-[11px] text-text-secondary tabular-nums sm:text-right">
{t('selectedLabel')}{' '}
{selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
</p>
</div>
</div>
{scale !== 1 ? (
<div style={{ zoom: scale }} className="origin-top-left w-fit">
{chartBody}
</div>
) : (
chartBody
)}
</div>
);
}

View File

@@ -274,6 +274,16 @@ export function LabCasesDispatchPanel({
);
}
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
if (!activeLabCase || sent) return;
const set = new Set(activeLabCase.attachmentIds);
if (checked) set.add(attachmentId);
else set.delete(attachmentId);
updateActiveLabCase({ attachmentIds: [...set] });
}
const activeDetailAttachments = activeDetail?.attachmentMetas ?? [];
const includedInActiveShipment = activeLabCase
? [activeDetail]
: [];
@@ -393,6 +403,26 @@ export function LabCasesDispatchPanel({
)}
</div>
{!sent && activeDetailAttachments.length > 0 ? (
<div>
<p className="text-xs font-medium text-text-secondary mb-2">
{t('labShipmentAttachments')}
</p>
<p className="text-[11px] text-text-muted mb-2">{t('labShipmentAttachmentsHint')}</p>
<div className="flex flex-col gap-2">
{activeDetailAttachments.map((att) => (
<Checkbox
key={att.id}
checked={activeLabCase.attachmentIds.includes(att.id)}
disabled={disabled}
onChange={(next) => toggleAttachmentInActiveLabCase(att.id, next)}
label={`${att.fileName} (${(att.sizeBytes / 1024).toFixed(1)} KB)`}
/>
))}
</div>
</div>
) : null}
{activeLabCase.id ? (
<LabCaseCommentsPanel
caseId={activeLabCase.id}

View File

@@ -14,6 +14,24 @@ interface ToothGlyphProps {
mirrored?: boolean;
upsideDown?: boolean;
className?: string;
/** When set, selected tooth glow + fill use this color instead of primary. */
accentColor?: string;
}
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const normalized = hex.replace('#', '');
if (normalized.length !== 6) return null;
const r = parseInt(normalized.slice(0, 2), 16);
const g = parseInt(normalized.slice(2, 4), 16);
const b = parseInt(normalized.slice(4, 6), 16);
if ([r, g, b].some((n) => Number.isNaN(n))) return null;
return { r, g, b };
}
function rgbaFromHex(hex: string, alpha: number): string {
const rgb = hexToRgb(hex);
if (!rgb) return `rgba(9, 169, 188, ${alpha})`;
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
function filledSilhouette(
@@ -39,8 +57,13 @@ function detailOnly(model: ToothPathModel, stroke: string, strokeW: number): Rea
return <path d={d} fill="none" stroke={stroke} strokeWidth={strokeW * widthScale} strokeLinecap="round" />;
}
function renderClinicalFill(model: ToothPathModel, selected: boolean, gradientId: string): ReactNode {
const stroke = selected ? 'var(--color-primary)' : '#1e293b';
function renderClinicalFill(
model: ToothPathModel,
selected: boolean,
gradientId: string,
accentColor?: string,
): ReactNode {
const stroke = selected ? (accentColor ?? 'var(--color-primary)') : '#1e293b';
const strokeW = selected ? 2.4 : 1.35;
const fill = `url(#${gradientId})`;
return (
@@ -50,6 +73,7 @@ function renderClinicalFill(model: ToothPathModel, selected: boolean, gradientId
</>
);
}
/** FDI chart tooth: clinical gradient + roots only. */
export const ToothGlyph = memo(function ToothGlyph({
fdi,
@@ -60,9 +84,14 @@ export const ToothGlyph = memo(function ToothGlyph({
mirrored,
upsideDown,
className = 'w-8 h-[4.85rem]',
accentColor,
}: ToothGlyphProps) {
const model: ToothPathModel | null = getToothPathModel(fdi, kind, upper);
const filter = selected ? 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))' : undefined;
const filter = selected
? accentColor
? `drop-shadow(0 0 6px ${rgbaFromHex(accentColor, 0.75)})`
: 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))'
: undefined;
if (!model) {
return (
@@ -74,7 +103,19 @@ export const ToothGlyph = memo(function ToothGlyph({
);
}
const body = renderClinicalFill(model, selected, gradientId);
const body = renderClinicalFill(model, selected, gradientId, accentColor);
const selectedStops = accentColor
? {
inner: '#ffffff',
mid: accentColor,
outer: accentColor,
}
: {
inner: '#cffafe',
mid: '#5eead4',
outer: '#0e7490',
};
return (
<svg
@@ -85,9 +126,9 @@ export const ToothGlyph = memo(function ToothGlyph({
>
<defs>
<radialGradient id={gradientId} cx="45%" cy="35%" r="65%">
<stop offset="0%" stopColor={selected ? '#cffafe' : '#ffffff'} />
<stop offset="55%" stopColor={selected ? '#5eead4' : '#e0f2fe'} />
<stop offset="100%" stopColor={selected ? '#0e7490' : '#93c5fd'} />
<stop offset="0%" stopColor={selected ? selectedStops.inner : '#ffffff'} />
<stop offset="55%" stopColor={selected ? selectedStops.mid : '#e0f2fe'} />
<stop offset="100%" stopColor={selected ? selectedStops.outer : '#93c5fd'} />
</radialGradient>
</defs>
<g

View File

@@ -9,7 +9,7 @@ import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPan
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
import { ToastStack } from '@/components/ui/shared/Toast';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { treatmentTypeLabelFromCatalog, treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
import {
addCalendarDays,
compareLocalDayStart,
@@ -133,6 +133,7 @@ function newLabCaseDraft(): LabCaseDraft {
destinationOrganizationId: null,
detailClientIds: [],
toothProsthesis: [],
attachmentIds: [],
sentAt: null,
sends: [],
};
@@ -180,6 +181,7 @@ function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
})),
attachmentIds: (lc.attachments ?? []).map((a) => a.id),
sentAt: lc.sentAt ?? null,
sends: lc.sends ?? [],
};
@@ -296,6 +298,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
const [organizationSearch, setOrganizationSearch] = useState('');
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false);
const isDetailLocked = useCallback(
(detail: TreatmentDetailDraft) =>
@@ -388,6 +391,35 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const wholePlanTeethSet = useMemo(() => {
const set = new Set<FdiToothId>();
for (const detail of details) {
for (const tooth of detail.teeth) set.add(tooth);
}
return set;
}, [details]);
const wholePlanToothColors = useMemo(() => {
const colors: Partial<Record<FdiToothId, string>> = {};
for (let i = 0; i < details.length; i++) {
const detail = details[i];
const catalogIndex = treatmentCatalog.findIndex((e) => e.code === detail.treatmentType);
const color = treatmentTypeColor(detail.treatmentType, catalogIndex >= 0 ? catalogIndex : i);
for (const tooth of detail.teeth) {
if (!(tooth in colors)) colors[tooth] = color;
}
}
return colors;
}, [details, treatmentCatalog]);
const chartSelectedTeeth = showWholeTreatmentPlan ? wholePlanTeethSet : selectedTeethSet;
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
// Reset whole-plan overview when switching details.
useEffect(() => {
setShowWholeTreatmentPlan(false);
}, [activeDetailId]);
// Sync active lab shipment when the selected treatment detail changes.
useEffect(() => {
const match = labCaseDrafts.find((lc) => lc.detailClientIds.includes(activeDetailId));
@@ -817,6 +849,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
};
})
.filter((row): row is { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string } => row !== null),
attachmentIds: lc.attachmentIds,
}));
if (payload.length === 0) {
@@ -1040,9 +1073,24 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<div className="space-y-3 min-w-0 w-full">
<FdiToothChart
selected={selectedTeethSet}
selected={chartSelectedTeeth}
toothColors={chartToothColors}
readOnly={showWholeTreatmentPlan}
headerControl={
details.length > 1 ? (
<label className="flex items-center gap-2 text-[11px] text-text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={showWholeTreatmentPlan}
onChange={(e) => setShowWholeTreatmentPlan(e.target.checked)}
className="rounded border-border"
/>
{t('toothChartWholePlan')}
</label>
) : undefined
}
onToggle={(fdi) => {
if (!canEditTreatmentForDay || isDetailLocked(activeDetail)) return;
if (!canEditTreatmentForDay || isDetailLocked(activeDetail) || showWholeTreatmentPlan) return;
setDetails((prev) =>
prev.map((d) => {
if (d.clientId !== activeDetailId) return d;