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

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