improvement: attachment selection for lab dispatch added. attachment preview added to cases feature.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user