3e64514b08
Financeiro V1 (Épicos 1–4): - FKs de origem no lançamento (paciente/profissional/procedimento/agendamento/contrato/realizado), soft delete e cron "Atrasado" - prontuário de procedimentos realizados (fotos antes/depois) + regra sem-comissão (garantia/reabertura do mesmo profissional) - normalizeFinanceiro: corrige CHECK capitalizado vs enforceUppercase (lançamentos não salvavam pela UI) - baixa de pagamento (pago_em), despesas (fornecedor/centro de custo/recorrente), competência por data de conclusão - relatório financeiro (período, paciente, profissional, forma, convênio, glosa) - orçamento Assinado -> contas a receber; contrato vigente -> cobrança recorrente; renovação automática de contrato Financeiro V2 (Comissão/Repasse): - regras de % (padrão/procedimento/override por profissional) + custo de laboratório - base selecionável (recebido/produção), fechamento persistido, pagar (gera despesa) + comprovante + estorno Glosas de convênio: por item de GTO, recurso/protocolo/prazo, recuperar/estornar, impacto no relatório GTO -> Financeiro: finalizar gera RECEITA (convênio a receber); LancarGTO passa a persistir; convênios reais (planos) Contratos: modelo "Atendimento a Convênios / Repasse (Dentista Credenciado)" + variáveis de convênio Salas (redesenho): perfil "Dono de Sala"; locação sempre ativa; menus MINHAS/ALUGAR SALAS; sala como workspace isolado (seletor agrupado Clínicas x Salas, tenantGuard por dono); financeiro de locação (reserva confirmada -> recebível); Painel da Sala (KPIs, agenda visual, recebíveis, relatório por sala) Docs: BACKLOG-FINANCEIRO-V1, AUDITORIA-FUNCIONAL-SCOREODONTO, CHECKLIST-DEPLOY-PRODUCAO Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
493 lines
22 KiB
TypeScript
493 lines
22 KiB
TypeScript
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
X, Save, RotateCcw, Trash2, Loader2, CheckCircle2,
|
|
Minus, ArrowUpRight, Circle, Square, Type, Pen,
|
|
MoveRight, Scissors, SeparatorVertical, Ruler
|
|
} from 'lucide-react';
|
|
import { HybridBackend } from '../services/backend.ts';
|
|
import { useToast } from '../contexts/ToastContext.tsx';
|
|
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
|
|
|
// ─── Tipos ────────────────────────────────────────────────────────
|
|
|
|
type ToolType = 'seta' | 'linha' | 'circulo' | 'retangulo' | 'texto' | 'livre'
|
|
| 'vetor' | 'extracao' | 'linha_media' | 'escala';
|
|
|
|
interface Point { x: number; y: number; }
|
|
|
|
interface Anotacao {
|
|
id: string;
|
|
tipo: ToolType;
|
|
cor: string;
|
|
espessura: number;
|
|
// Para shapes geométricos
|
|
inicio?: Point;
|
|
fim?: Point;
|
|
// Para texto
|
|
posicao?: Point;
|
|
texto?: string;
|
|
// Para caminho livre
|
|
pontos?: Point[];
|
|
// Para escala de calibração (distância real da linha em mm)
|
|
mm?: number;
|
|
}
|
|
|
|
// ─── Constantes ───────────────────────────────────────────────────
|
|
|
|
const CORES = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#ffffff'];
|
|
const FERRAMENTAS: { id: ToolType; icon: React.ReactNode; label: string }[] = [
|
|
{ id: 'seta', icon: <ArrowUpRight size={16} />, label: 'SETA' },
|
|
{ id: 'linha', icon: <Minus size={16} />, label: 'LINHA' },
|
|
{ id: 'circulo', icon: <Circle size={16} />, label: 'CÍRCULO' },
|
|
{ id: 'retangulo', icon: <Square size={16} />, label: 'RETÂNGULO' },
|
|
{ id: 'texto', icon: <Type size={16} />, label: 'TEXTO' },
|
|
{ id: 'livre', icon: <Pen size={16} />, label: 'LIVRE' },
|
|
];
|
|
|
|
// Planejamento visual: vetor de movimentação, marca de extração, linha média.
|
|
const FERRAMENTAS_PLAN: { id: ToolType; icon: React.ReactNode; label: string }[] = [
|
|
{ id: 'vetor', icon: <MoveRight size={16} />, label: 'VETOR' },
|
|
{ id: 'extracao', icon: <Scissors size={16} />, label: 'EXTRAÇÃO' },
|
|
{ id: 'linha_media', icon: <SeparatorVertical size={16} />, label: 'LINHA MÉDIA' },
|
|
{ id: 'escala', icon: <Ruler size={16} />, label: 'ESCALA' },
|
|
];
|
|
|
|
let _idCounter = 0;
|
|
function uid() { return `a_${Date.now()}_${++_idCounter}`; }
|
|
|
|
// ─── Helpers de desenho SVG ───────────────────────────────────────
|
|
|
|
function renderShape(a: Anotacao, isPreview = false): React.ReactNode {
|
|
const stroke = a.cor;
|
|
const sw = a.espessura;
|
|
const opacity = isPreview ? 0.7 : 1;
|
|
|
|
if ((a.tipo === 'seta' || a.tipo === 'linha') && a.inicio && a.fim) {
|
|
const { inicio: s, fim: e } = a;
|
|
const dx = e.x - s.x, dy = e.y - s.y;
|
|
const len = Math.sqrt(dx * dx + dy * dy);
|
|
if (len < 2) return null;
|
|
const ux = dx / len, uy = dy / len;
|
|
const hw = sw * 4 + 4;
|
|
const p1 = { x: e.x - ux * hw - uy * hw * 0.5, y: e.y - uy * hw + ux * hw * 0.5 };
|
|
const p2 = { x: e.x - ux * hw + uy * hw * 0.5, y: e.y - uy * hw - ux * hw * 0.5 };
|
|
return (
|
|
<g key={a.id} opacity={opacity}>
|
|
<line x1={s.x} y1={s.y} x2={e.x} y2={e.y} stroke={stroke} strokeWidth={sw} strokeLinecap="round" />
|
|
{a.tipo === 'seta' && (
|
|
<polygon points={`${e.x},${e.y} ${p1.x},${p1.y} ${p2.x},${p2.y}`} fill={stroke} />
|
|
)}
|
|
</g>
|
|
);
|
|
}
|
|
if (a.tipo === 'circulo' && a.inicio && a.fim) {
|
|
const cx = (a.inicio.x + a.fim.x) / 2;
|
|
const cy = (a.inicio.y + a.fim.y) / 2;
|
|
const rx = Math.abs(a.fim.x - a.inicio.x) / 2;
|
|
const ry = Math.abs(a.fim.y - a.inicio.y) / 2;
|
|
return <ellipse key={a.id} cx={cx} cy={cy} rx={rx} ry={ry} stroke={stroke} strokeWidth={sw} fill="none" opacity={opacity} />;
|
|
}
|
|
if (a.tipo === 'retangulo' && a.inicio && a.fim) {
|
|
const x = Math.min(a.inicio.x, a.fim.x);
|
|
const y = Math.min(a.inicio.y, a.fim.y);
|
|
const w = Math.abs(a.fim.x - a.inicio.x);
|
|
const h = Math.abs(a.fim.y - a.inicio.y);
|
|
return <rect key={a.id} x={x} y={y} width={w} height={h} stroke={stroke} strokeWidth={sw} fill="none" opacity={opacity} />;
|
|
}
|
|
if (a.tipo === 'texto' && a.posicao && a.texto) {
|
|
return (
|
|
<text key={a.id} x={a.posicao.x} y={a.posicao.y} fill={stroke}
|
|
fontSize={14 + sw * 2} fontWeight="bold" fontFamily="sans-serif"
|
|
stroke="rgba(0,0,0,0.5)" strokeWidth={0.5} opacity={opacity}>
|
|
{a.texto}
|
|
</text>
|
|
);
|
|
}
|
|
if (a.tipo === 'livre' && a.pontos && a.pontos.length > 1) {
|
|
const d = a.pontos.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
|
|
return <path key={a.id} d={d} stroke={stroke} strokeWidth={sw} fill="none" strokeLinecap="round" strokeLinejoin="round" opacity={opacity} />;
|
|
}
|
|
// Vetor de movimentação: haste com barra de origem + cabeça larga (notação ortodôntica).
|
|
if (a.tipo === 'vetor' && a.inicio && a.fim) {
|
|
const { inicio: s, fim: e } = a;
|
|
const dx = e.x - s.x, dy = e.y - s.y;
|
|
const len = Math.sqrt(dx * dx + dy * dy);
|
|
if (len < 2) return null;
|
|
const ux = dx / len, uy = dy / len;
|
|
const hw = sw * 5 + 5;
|
|
const p1 = { x: e.x - ux * hw - uy * hw * 0.6, y: e.y - uy * hw + ux * hw * 0.6 };
|
|
const p2 = { x: e.x - ux * hw + uy * hw * 0.6, y: e.y - uy * hw - ux * hw * 0.6 };
|
|
const tb = sw * 3 + 3;
|
|
const t1 = { x: s.x - uy * tb, y: s.y + ux * tb };
|
|
const t2 = { x: s.x + uy * tb, y: s.y - ux * tb };
|
|
return (
|
|
<g key={a.id} opacity={opacity}>
|
|
<line x1={s.x} y1={s.y} x2={e.x} y2={e.y} stroke={stroke} strokeWidth={sw * 1.6} strokeLinecap="round" />
|
|
<line x1={t1.x} y1={t1.y} x2={t2.x} y2={t2.y} stroke={stroke} strokeWidth={sw * 1.6} strokeLinecap="round" />
|
|
<polygon points={`${e.x},${e.y} ${p1.x},${p1.y} ${p2.x},${p2.y}`} fill={stroke} />
|
|
</g>
|
|
);
|
|
}
|
|
// Marca de extração: X.
|
|
if (a.tipo === 'extracao' && a.posicao) {
|
|
const { x, y } = a.posicao;
|
|
const r = sw * 2 + 3;
|
|
return (
|
|
<g key={a.id} opacity={opacity}>
|
|
<line x1={x - r} y1={y - r} x2={x + r} y2={y + r} stroke={stroke} strokeWidth={sw * 1.5} strokeLinecap="round" />
|
|
<line x1={x - r} y1={y + r} x2={x + r} y2={y - r} stroke={stroke} strokeWidth={sw * 1.5} strokeLinecap="round" />
|
|
</g>
|
|
);
|
|
}
|
|
// Linha média: referência vertical tracejada.
|
|
if (a.tipo === 'linha_media' && a.posicao) {
|
|
return <line key={a.id} x1={a.posicao.x} y1={0} x2={a.posicao.x} y2={100} stroke={stroke} strokeWidth={sw} strokeDasharray="2 2" opacity={opacity} />;
|
|
}
|
|
// Escala de calibração: linha tracejada com ticks perpendiculares nas pontas.
|
|
if (a.tipo === 'escala' && a.inicio && a.fim) {
|
|
const { inicio: s, fim: e } = a;
|
|
const dx = e.x - s.x, dy = e.y - s.y;
|
|
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
const ux = dx / len, uy = dy / len, tk = 2;
|
|
return (
|
|
<g key={a.id} opacity={opacity}>
|
|
<line x1={s.x} y1={s.y} x2={e.x} y2={e.y} stroke={stroke} strokeWidth={sw} strokeDasharray="1.5 1.5" />
|
|
<line x1={s.x - uy * tk} y1={s.y + ux * tk} x2={s.x + uy * tk} y2={s.y - ux * tk} stroke={stroke} strokeWidth={sw} />
|
|
<line x1={e.x - uy * tk} y1={e.y + ux * tk} x2={e.x + uy * tk} y2={e.y - ux * tk} stroke={stroke} strokeWidth={sw} />
|
|
</g>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ─── Componente principal ─────────────────────────────────────────
|
|
|
|
interface Props {
|
|
fotoId: string;
|
|
fotoUrl: string;
|
|
slotLabel: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export const PhotoAnnotator: React.FC<Props> = ({ fotoId, fotoUrl, slotLabel, onClose }) => {
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
const svgRef = useRef<SVGSVGElement>(null);
|
|
const imgRef = useRef<HTMLImageElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [anotacoes, setAnotacoes] = useState<Anotacao[]>([]);
|
|
const [preview, setPreview] = useState<Anotacao | null>(null);
|
|
const [ferramenta, setFerramenta] = useState<ToolType>('seta');
|
|
const [cor, setCor] = useState('#ef4444');
|
|
const [espessura, setEspessura] = useState(2);
|
|
const [drawing, setDrawing] = useState(false);
|
|
const [startPt, setStartPt] = useState<Point | null>(null);
|
|
const [livrePoints, setLivrePoints] = useState<Point[]>([]);
|
|
const [imgDim, setImgDim] = useState({ w: 0, h: 0, offX: 0, offY: 0 });
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [textInput, setTextInput] = useState('');
|
|
const [textPos, setTextPos] = useState<Point | null>(null);
|
|
|
|
// Carrega anotações existentes
|
|
useEffect(() => {
|
|
HybridBackend.getOrtoAnotacoes(fotoId).then(r => {
|
|
if (r.success) setAnotacoes(r.anotacoes.map((a: any) => ({ ...a.dados, id: a.id })));
|
|
}).finally(() => setLoading(false));
|
|
}, [fotoId]);
|
|
|
|
// Fecha com ESC
|
|
useEffect(() => {
|
|
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
window.addEventListener('keydown', h);
|
|
return () => window.removeEventListener('keydown', h);
|
|
}, [onClose]);
|
|
|
|
// Calcula dimensões da imagem renderizada no container
|
|
const updateImgDim = useCallback(() => {
|
|
const img = imgRef.current;
|
|
const container = containerRef.current;
|
|
if (!img || !container) return;
|
|
const cr = container.getBoundingClientRect();
|
|
const ir = img.getBoundingClientRect();
|
|
setImgDim({ w: ir.width, h: ir.height, offX: ir.left - cr.left, offY: ir.top - cr.top });
|
|
}, []);
|
|
|
|
const getPoint = (e: React.MouseEvent | React.TouchEvent): Point => {
|
|
const container = containerRef.current!;
|
|
const rect = container.getBoundingClientRect();
|
|
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
|
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
|
return {
|
|
x: ((clientX - rect.left - imgDim.offX) / imgDim.w) * 100,
|
|
y: ((clientY - rect.top - imgDim.offY) / imgDim.h) * 100,
|
|
};
|
|
};
|
|
|
|
const onMouseDown = (e: React.MouseEvent) => {
|
|
if (ferramenta === 'texto') {
|
|
const pt = getPoint(e);
|
|
setTextPos(pt);
|
|
setTextInput('');
|
|
return;
|
|
}
|
|
// Ferramentas de planejamento por clique único
|
|
if (ferramenta === 'extracao' || ferramenta === 'linha_media') {
|
|
const pt = getPoint(e);
|
|
setAnotacoes(prev => [...prev, { id: uid(), tipo: ferramenta, cor, espessura, posicao: pt }]);
|
|
return;
|
|
}
|
|
setDrawing(true);
|
|
const pt = getPoint(e);
|
|
setStartPt(pt);
|
|
if (ferramenta === 'livre') setLivrePoints([pt]);
|
|
};
|
|
|
|
const onMouseMove = (e: React.MouseEvent) => {
|
|
if (!drawing || !startPt) return;
|
|
const pt = getPoint(e);
|
|
if (ferramenta === 'livre') {
|
|
setLivrePoints(prev => [...prev, pt]);
|
|
setPreview({ id: 'prev', tipo: 'livre', cor, espessura, pontos: [...livrePoints, pt] });
|
|
} else {
|
|
setPreview({ id: 'prev', tipo: ferramenta, cor, espessura, inicio: startPt, fim: pt });
|
|
}
|
|
};
|
|
|
|
const onMouseUp = (e: React.MouseEvent) => {
|
|
if (!drawing || !startPt) return;
|
|
setDrawing(false);
|
|
const pt = getPoint(e);
|
|
// Calibração de escala: pergunta a distância real e guarda uma única referência.
|
|
if (ferramenta === 'escala') {
|
|
setPreview(null); setStartPt(null); setLivrePoints([]);
|
|
const v = window.prompt('Distância REAL desta linha, em milímetros (mm):', '10');
|
|
const mm = v ? parseFloat(v.replace(',', '.')) : NaN;
|
|
if (!isNaN(mm) && mm > 0) {
|
|
setAnotacoes(prev => [...prev.filter(a => a.tipo !== 'escala'), { id: uid(), tipo: 'escala', cor, espessura, inicio: startPt, fim: pt, mm }]);
|
|
}
|
|
return;
|
|
}
|
|
const nova: Anotacao = ferramenta === 'livre'
|
|
? { id: uid(), tipo: 'livre', cor, espessura, pontos: livrePoints }
|
|
: { id: uid(), tipo: ferramenta, cor, espessura, inicio: startPt, fim: pt };
|
|
setAnotacoes(prev => [...prev, nova]);
|
|
setPreview(null);
|
|
setStartPt(null);
|
|
setLivrePoints([]);
|
|
};
|
|
|
|
const confirmText = () => {
|
|
if (!textPos || !textInput.trim()) { setTextPos(null); return; }
|
|
setAnotacoes(prev => [...prev, { id: uid(), tipo: 'texto', cor, espessura, posicao: textPos, texto: textInput.toUpperCase() }]);
|
|
setTextPos(null);
|
|
setTextInput('');
|
|
};
|
|
|
|
const undo = () => setAnotacoes(prev => prev.slice(0, -1));
|
|
const clear = async () => { if (await confirm('LIMPAR TODAS AS ANOTAÇÕES?')) setAnotacoes([]); };
|
|
|
|
const save = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const res = await HybridBackend.saveOrtoAnotacoes(fotoId, anotacoes);
|
|
if (!res.success) { toast.error('ERRO AO SALVAR.'); return; }
|
|
toast.success('ANOTAÇÕES SALVAS!');
|
|
} catch { toast.error('ERRO AO SALVAR.'); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const svgStyle: React.CSSProperties = {
|
|
position: 'absolute',
|
|
left: imgDim.offX, top: imgDim.offY,
|
|
width: imgDim.w, height: imgDim.h,
|
|
cursor: ferramenta === 'texto' ? 'text' : 'crosshair',
|
|
userSelect: 'none',
|
|
};
|
|
|
|
// Calibração: mm por pixel exibido (usa a linha de escala, se houver).
|
|
const escalaAnot = anotacoes.find(a => a.tipo === 'escala');
|
|
let mmPerPx = 0;
|
|
if (escalaAnot?.inicio && escalaAnot?.fim && escalaAnot.mm && imgDim.w > 0) {
|
|
const dpx = Math.hypot((escalaAnot.fim.x - escalaAnot.inicio.x) / 100 * imgDim.w, (escalaAnot.fim.y - escalaAnot.inicio.y) / 100 * imgDim.h);
|
|
if (dpx > 0) mmPerPx = escalaAnot.mm / dpx;
|
|
}
|
|
const midOf = (p?: Point, q?: Point) => (p && q ? { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 } : null);
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/95 z-[80] flex flex-col">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 px-4 py-3 flex-shrink-0 border-b border-white/10">
|
|
<span className="text-white font-black text-xs uppercase">{slotLabel} — EDITOR DE ANOTAÇÕES</span>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<button onClick={undo} disabled={!anotacoes.length}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 text-white rounded-lg text-[10px] font-black uppercase disabled:opacity-30 transition-colors">
|
|
<RotateCcw size={13} /> DESFAZER
|
|
</button>
|
|
<button onClick={clear} disabled={!anotacoes.length}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600/80 hover:bg-red-600 text-white rounded-lg text-[10px] font-black uppercase disabled:opacity-30 transition-colors">
|
|
<Trash2 size={13} /> LIMPAR
|
|
</button>
|
|
<button onClick={save} disabled={saving}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded-lg text-[10px] font-black uppercase disabled:opacity-40 transition-colors">
|
|
{saving ? <Loader2 size={13} className="animate-spin" /> : <Save size={13} />} SALVAR
|
|
</button>
|
|
<button onClick={onClose} className="p-1.5 hover:bg-white/10 rounded-lg text-white transition-colors">
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Toolbar */}
|
|
<div className="flex items-center gap-3 px-4 py-2.5 flex-shrink-0 bg-black/40 border-b border-white/10 flex-wrap">
|
|
{/* Ferramentas */}
|
|
<div className="flex items-center gap-1">
|
|
{FERRAMENTAS.map(f => (
|
|
<button key={f.id} onClick={() => setFerramenta(f.id)} title={f.label}
|
|
className={`p-2 rounded-lg transition-all text-[10px] font-black flex items-center gap-1 ${ferramenta === f.id ? 'bg-emerald-600 text-white' : 'bg-white/10 text-white/70 hover:bg-white/20'}`}>
|
|
{f.icon}
|
|
<span className="hidden sm:inline">{f.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="w-px h-6 bg-white/20" />
|
|
|
|
{/* Planejamento visual */}
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[8px] font-black text-emerald-300 uppercase mr-0.5 hidden md:inline">PLANEJ.</span>
|
|
{FERRAMENTAS_PLAN.map(f => (
|
|
<button key={f.id} onClick={() => setFerramenta(f.id)} title={f.label}
|
|
className={`p-2 rounded-lg transition-all text-[10px] font-black flex items-center gap-1 ${ferramenta === f.id ? 'bg-emerald-600 text-white' : 'bg-white/10 text-white/70 hover:bg-white/20'}`}>
|
|
{f.icon}
|
|
<span className="hidden lg:inline">{f.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="w-px h-6 bg-white/20" />
|
|
|
|
{/* Cores */}
|
|
<div className="flex items-center gap-1.5">
|
|
{CORES.map(c => (
|
|
<button key={c} onClick={() => setCor(c)}
|
|
className={`w-6 h-6 rounded-full border-2 transition-all ${cor === c ? 'border-white scale-125' : 'border-transparent'}`}
|
|
style={{ backgroundColor: c }} />
|
|
))}
|
|
</div>
|
|
|
|
<div className="w-px h-6 bg-white/20" />
|
|
|
|
{/* Espessura */}
|
|
<div className="flex items-center gap-1">
|
|
{[1, 2, 4].map((e, i) => (
|
|
<button key={e} onClick={() => setEspessura(e)}
|
|
className={`px-2.5 py-1 rounded-lg text-[10px] font-black text-white transition-all ${espessura === e ? 'bg-emerald-600' : 'bg-white/10 hover:bg-white/20'}`}>
|
|
{['P', 'M', 'G'][i]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<span className="ml-auto text-[10px] text-white/40 font-bold uppercase hidden md:block">
|
|
{anotacoes.length} ANOTAÇÃO(ÕES)
|
|
</span>
|
|
</div>
|
|
|
|
{/* Canvas area */}
|
|
<div
|
|
ref={containerRef}
|
|
className="flex-1 relative overflow-hidden flex items-center justify-center select-none"
|
|
onMouseDown={onMouseDown}
|
|
onMouseMove={onMouseMove}
|
|
onMouseUp={onMouseUp}
|
|
>
|
|
{loading ? (
|
|
<Loader2 size={32} className="animate-spin text-white/40" />
|
|
) : (
|
|
<>
|
|
<img
|
|
ref={imgRef}
|
|
src={fotoUrl}
|
|
alt={slotLabel}
|
|
className="max-w-full max-h-full object-contain pointer-events-none select-none"
|
|
draggable={false}
|
|
onLoad={updateImgDim}
|
|
/>
|
|
{/* SVG overlay — viewBox em % para escalar com imagem */}
|
|
{imgDim.w > 0 && (
|
|
<svg
|
|
ref={svgRef}
|
|
style={svgStyle}
|
|
viewBox="0 0 100 100"
|
|
preserveAspectRatio="none"
|
|
>
|
|
{anotacoes.map(a => renderShape(a))}
|
|
{preview && renderShape(preview, true)}
|
|
</svg>
|
|
)}
|
|
|
|
{/* Rótulos de medida (mm) — fora do SVG p/ não distorcer com o viewBox */}
|
|
{imgDim.w > 0 && anotacoes.map(a => {
|
|
if (a.tipo === 'escala' && a.mm) {
|
|
const m = midOf(a.inicio, a.fim); if (!m) return null;
|
|
return <div key={'lb' + a.id} className="absolute pointer-events-none text-[10px] font-black px-1.5 py-0.5 rounded bg-emerald-600 text-white"
|
|
style={{ left: imgDim.offX + (m.x / 100) * imgDim.w, top: imgDim.offY + (m.y / 100) * imgDim.h, transform: 'translate(-50%,-150%)' }}>📏 {a.mm} mm</div>;
|
|
}
|
|
if (a.tipo === 'vetor' && mmPerPx > 0 && a.inicio && a.fim) {
|
|
const m = midOf(a.inicio, a.fim)!;
|
|
const dpx = Math.hypot((a.fim.x - a.inicio.x) / 100 * imgDim.w, (a.fim.y - a.inicio.y) / 100 * imgDim.h);
|
|
return <div key={'lb' + a.id} className="absolute pointer-events-none text-[10px] font-black px-1.5 py-0.5 rounded bg-black/75 text-white"
|
|
style={{ left: imgDim.offX + (m.x / 100) * imgDim.w, top: imgDim.offY + (m.y / 100) * imgDim.h, transform: 'translate(-50%,-150%)' }}>{(dpx * mmPerPx).toFixed(1)} mm</div>;
|
|
}
|
|
return null;
|
|
})}
|
|
|
|
{/* Input de texto flutuante */}
|
|
{textPos && (
|
|
<div
|
|
className="absolute z-10 flex flex-col gap-2"
|
|
style={{ left: imgDim.offX + (textPos.x / 100) * imgDim.w, top: imgDim.offY + (textPos.y / 100) * imgDim.h }}
|
|
onClick={e => e.stopPropagation()}
|
|
onMouseDown={e => e.stopPropagation()}
|
|
>
|
|
<input
|
|
autoFocus
|
|
type="text"
|
|
value={textInput}
|
|
onChange={e => setTextInput(e.target.value)}
|
|
onKeyDown={e => { if (e.key === 'Enter') confirmText(); if (e.key === 'Escape') setTextPos(null); }}
|
|
placeholder="TEXTO..."
|
|
className="bg-black/80 text-white font-bold text-sm px-3 py-2 rounded-lg border border-white/30 focus:outline-none focus:border-white w-48 uppercase"
|
|
style={{ color: cor }}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<button onClick={confirmText} className="flex-1 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded-lg text-[10px] font-black uppercase flex items-center justify-center gap-1">
|
|
<CheckCircle2 size={12} /> OK
|
|
</button>
|
|
<button onClick={() => setTextPos(null)} className="flex-1 py-1.5 bg-white/10 hover:bg-white/20 text-white rounded-lg text-[10px] font-black uppercase">
|
|
ESC
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Instrução */}
|
|
<div className="px-4 py-2 text-center flex-shrink-0">
|
|
<p className="text-[10px] text-white/30 font-bold uppercase">
|
|
{ferramenta === 'texto' ? 'CLIQUE NA IMAGEM PARA COLOCAR O TEXTO' :
|
|
ferramenta === 'livre' ? 'CLIQUE E ARRASTE PARA DESENHAR LIVREMENTE' :
|
|
ferramenta === 'extracao' ? 'CLIQUE NO DENTE A EXTRAIR (MARCA X)' :
|
|
ferramenta === 'linha_media' ? 'CLIQUE PARA POSICIONAR A LINHA MÉDIA DE REFERÊNCIA' :
|
|
ferramenta === 'vetor' ? `CLIQUE E ARRASTE: POSIÇÃO ATUAL → DESTINO${mmPerPx > 0 ? ' (MOSTRA MM)' : ' · DEFINA A ESCALA P/ VER MM'}` :
|
|
ferramenta === 'escala' ? 'ARRASTE SOBRE UMA DISTÂNCIA CONHECIDA E INFORME O VALOR EM MM' :
|
|
'CLIQUE E ARRASTE PARA DESENHAR · ESC PARA FECHAR'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|