import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Camera, Upload, X, ZoomIn, Trash2, ChevronLeft, ChevronRight, CheckCircle, Circle, SplitSquareHorizontal, Loader2, RefreshCw, Pen, FileDown, Plus, CalendarPlus } from 'lucide-react'; import { jsPDF } from 'jspdf'; import { HybridBackend } from '../services/backend.ts'; import { useToast } from '../contexts/ToastContext.tsx'; import { useConfirm } from '../contexts/ConfirmContext.tsx'; import { PhotoAnnotator } from './PhotoAnnotator.tsx'; import { CameraCapture } from '../components/CameraCapture.tsx'; // ─── Constantes ─────────────────────────────────────────────────── export const ORTO_SLOTS = [ { key: 'extrafrontal_sorrindo', label: 'FRONTAL SORRINDO', cat: 'EXTRAORAL' }, { key: 'extrafrontal_repouso', label: 'FRONTAL REPOUSO', cat: 'EXTRAORAL' }, { key: 'extraperfil_direito', label: 'PERFIL DIREITO', cat: 'EXTRAORAL' }, { key: 'extraperfil_esquerdo', label: 'PERFIL ESQUERDO', cat: 'EXTRAORAL' }, { key: 'extraperfil_sorrindo', label: 'PERFIL SORRINDO', cat: 'EXTRAORAL' }, { key: 'intrafrontal_oclusao', label: 'FRONTAL OCLUSÃO', cat: 'INTRAORAL' }, { key: 'intralateral_direita', label: 'LATERAL DIREITA', cat: 'INTRAORAL' }, { key: 'intralateral_esquerda', label: 'LATERAL ESQUERDA', cat: 'INTRAORAL' }, { key: 'intraoclusal_superior', label: 'OCLUSAL SUPERIOR', cat: 'INTRAORAL' }, { key: 'intraoclusal_inferior', label: 'OCLUSAL INFERIOR', cat: 'INTRAORAL' }, { key: 'complinha_media', label: 'LINHA MÉDIA', cat: 'COMPLEMENTAR' }, { key: 'compoverjet', label: 'OVERJET', cat: 'COMPLEMENTAR' }, { key: 'compoverbite', label: 'OVERBITE', cat: 'COMPLEMENTAR' }, { key: 'compmordida_cruzada', label: 'MORDIDA CRUZADA', cat: 'COMPLEMENTAR' }, { key: 'compmordida_aberta', label: 'MORDIDA ABERTA', cat: 'COMPLEMENTAR' }, ] as const; // Sessões especiais (marcos do tratamento). Entre elas ficam as sessões MENSAIS // (orto é mensal → uma sessão de fotos por mês para acompanhar a evolução). const SPECIAL_SESSIONS: { tipo: string; label: string }[] = [ { tipo: 'inicial', label: 'INICIAL' }, { tipo: 'finalizacao', label: 'FINALIZAÇÃO' }, { tipo: 'pos_contencao', label: 'PÓS-CONTENÇÃO' }, ]; const MESES_PT = ['JAN', 'FEV', 'MAR', 'ABR', 'MAI', 'JUN', 'JUL', 'AGO', 'SET', 'OUT', 'NOV', 'DEZ']; // ym = 'YYYY-MM' const monthTipo = (ym: string) => `mes_${ym}`; const monthLabel = (ym: string) => { const [y, m] = ym.split('-'); return `${MESES_PT[Number(m) - 1] || m}/${y}`; }; const currentYm = () => new Date().toISOString().slice(0, 7); function sessionLabel(s: { tipo: string; rotulo?: string }): string { if (s.rotulo) return s.rotulo; const sp = SPECIAL_SESSIONS.find(x => x.tipo === s.tipo); if (sp) return sp.label; if (s.tipo.startsWith('mes_')) return monthLabel(s.tipo.slice(4)); return s.tipo.toUpperCase(); } // Ordena: INICIAL primeiro, depois meses por data, depois FINALIZAÇÃO / PÓS-CONTENÇÃO. function sessionSortKey(s: { tipo: string; data_ref?: string }): string { if (s.tipo === 'inicial') return '0'; if (s.tipo === 'finalizacao') return '8'; if (s.tipo === 'pos_contencao') return '9'; if (s.tipo.startsWith('mes_')) return `1_${s.tipo.slice(4)}`; return `5_${s.data_ref || s.tipo}`; } const CAT_COLORS: Record = { EXTRAORAL: 'text-teal-600 bg-teal-50 border-teal-200', INTRAORAL: 'text-purple-600 bg-purple-50 border-purple-200', COMPLEMENTAR: 'text-emerald-600 bg-emerald-50 border-emerald-200', }; interface OrtoFoto { id: string; slot: string; url: string; original_name?: string; } interface OrtoSessao { id: string; tipo: string; rotulo?: string; data_ref?: string; fotos: OrtoFoto[]; } // ─── Viewer Modal ───────────────────────────────────────────────── const PhotoViewer: React.FC<{ foto: OrtoFoto; slotLabel: string; onClose: () => void; onDelete: () => void; }> = ({ foto, slotLabel, onClose, onDelete }) => { const confirm = useConfirm(); const [annotating, setAnnotating] = useState(false); useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === 'Escape' && !annotating) onClose(); }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, [onClose, annotating]); if (annotating) { return setAnnotating(false)} />; } return (
{slotLabel}
{slotLabel}
); }; // ─── Comparador lado a lado ─────────────────────────────────────── // Comparador cross-sessão: mesmo slot em duas sessões (meses) diferentes. // Orto é mensal, então o uso natural é comparar a evolução: ex. INICIAL × JUN/2026. const Comparator: React.FC<{ sessoes: OrtoSessao[]; sessaoInicial?: string; onClose: () => void; }> = ({ sessoes, sessaoInicial, onClose }) => { const comFotos = sessoes.filter(s => s.fotos.length); const slotsDisp = ORTO_SLOTS.filter(sl => comFotos.some(s => s.fotos.some(f => f.slot === sl.key))); const [slot, setSlot] = useState((slotsDisp[0] || ORTO_SLOTS[0]).key as string); const [tipoA, setTipoA] = useState(sessaoInicial && comFotos.some(s => s.tipo === sessaoInicial) ? sessaoInicial : (comFotos[0]?.tipo || '')); const [tipoB, setTipoB] = useState(comFotos[comFotos.length - 1]?.tipo || ''); const [modo, setModo] = useState<'lado' | 'slider' | 'sobre' | 'evolucao'>('lado'); const [pos, setPos] = useState(50); const [op, setOp] = useState(50); const fotoOf = (tipo: string) => comFotos.find(s => s.tipo === tipo)?.fotos.find(f => f.slot === slot); const fotoA = fotoOf(tipoA); const fotoB = fotoOf(tipoB); const labelOf = (tipo: string) => { const s = sessoes.find(x => x.tipo === tipo); return s ? sessionLabel(s) : tipo; }; const slotLabel = ORTO_SLOTS.find(s => s.key === slot)?.label || slot; const MODOS: { id: 'lado' | 'slider' | 'sobre' | 'evolucao'; label: string }[] = [ { id: 'lado', label: 'LADO A LADO' }, { id: 'slider', label: 'SLIDER' }, { id: 'sobre', label: 'SOBREPOSIÇÃO' }, { id: 'evolucao', label: 'EVOLUÇÃO' }, ]; return (
COMPARADOR — {slotLabel}
{/* Controles */}

FOTO (SLOT)

SESSÃO A

SESSÃO B

{MODOS.map(m => ( ))}
{/* Visualização */}
{modo === 'evolucao' ? (
{comFotos.length === 0 ? (
SEM FOTOS
) : comFotos.map(s => { const foto = s.fotos.find(ff => ff.slot === slot); return (

{sessionLabel(s)}

{foto ? :
SEM FOTO
}
); })}
) : !fotoA && !fotoB ? (
SEM FOTO DESTE SLOT NAS SESSÕES ESCOLHIDAS
) : modo === 'lado' ? (
{[{ foto: fotoA, lbl: labelOf(tipoA) }, { foto: fotoB, lbl: labelOf(tipoB) }].map((c, i) => (

{c.lbl}

{c.foto ? :
SEM FOTO
}
))}
) : modo === 'slider' ? (
{fotoA && } {fotoB &&
}
{labelOf(tipoA)} {labelOf(tipoB)}
setPos(Number(e.target.value))} className="w-full max-w-3xl mx-auto" />
) : (
{fotoA && } {fotoB && }
{labelOf(tipoA)} setOp(Number(e.target.value))} className="flex-1" /> {labelOf(tipoB)}
)}
); }; // ─── Grade de slots ─────────────────────────────────────────────── const SlotGrid: React.FC<{ sessao: OrtoSessao; onUpload: (slot: string, file: File) => Promise; onView: (foto: OrtoFoto, slotLabel: string) => void; onRequestCapture: (slot: string) => void; uploading: Record; }> = ({ sessao, onUpload, onView, onRequestCapture, uploading }) => { const fotosMap = Object.fromEntries(sessao.fotos.map(f => [f.slot, f])); const inputRefs = useRef>({}); const cats = ['EXTRAORAL', 'INTRAORAL', 'COMPLEMENTAR']; return (
{cats.map(cat => (

{cat}

{ORTO_SLOTS.filter(s => s.cat === cat).map(slot => { const foto = fotosMap[slot.key]; const isUploading = uploading[slot.key]; return (
{/* Slot box */}
foto ? onView(foto, slot.label) : onRequestCapture(slot.key)} className={` relative aspect-[4/3] rounded-xl border-2 overflow-hidden cursor-pointer transition-all duration-200 group ${foto ? 'border-transparent shadow-md hover:shadow-lg' : 'border-dashed border-gray-300 hover:border-teal-400 bg-gray-50 hover:bg-teal-50' } `} > {isUploading ? (
) : foto ? ( <> {slot.label}
) : (
)}
{/* Label */}

{slot.label}

{/* Input oculto */} { inputRefs.current[slot.key] = el; }} type="file" accept="image/jpeg,image/jpg,image/png,image/webp" className="hidden" onChange={async e => { const file = e.target.files?.[0]; if (file) await onUpload(slot.key, file); e.target.value = ''; }} />
); })}
))}
); }; // ─── Barra de completude ────────────────────────────────────────── const Completude: React.FC<{ sessao: OrtoSessao }> = ({ sessao }) => { const total = ORTO_SLOTS.length; const preenchidos = new Set(sessao.fotos.map(f => f.slot)).size; const pct = Math.round((preenchidos / total) * 100); const color = pct === 100 ? 'bg-emerald-500' : pct >= 60 ? 'bg-teal-500' : pct >= 30 ? 'bg-amber-500' : 'bg-gray-300'; return (
{preenchidos}/{total} — {pct}%
); }; // ─── Exportação da documentação ─────────────────────────────────── interface ExportFoto { id: string; url: string; slot: string; sessaoLabel: string; } // Abre uma janela de impressão (o navegador salva como PDF) com as fotos // agrupadas por sessão. Sem dependência extra. function abrirDocumentoImpressao(titulo: string, fotos: ExportFoto[]) { const w = window.open('', '_blank'); if (!w) return; const origin = window.location.origin; const slotLabel = (slot: string) => ORTO_SLOTS.find(s => s.key === slot)?.label || slot.toUpperCase(); const grupos: { label: string; fotos: ExportFoto[] }[] = []; for (const f of fotos) { let g = grupos.find(x => x.label === f.sessaoLabel); if (!g) { g = { label: f.sessaoLabel, fotos: [] }; grupos.push(g); } g.fotos.push(f); } const cards = grupos.map(g => `

${g.label}

${g.fotos.map(f => `
${slotLabel(f.slot)}
`).join('')}
`).join(''); w.document.write(`${titulo}

${titulo}

Documentação ortodôntica · ${new Date().toLocaleDateString('pt-BR')}
${cards || '

Nenhuma foto selecionada.

'} `); w.document.close(); } // Carrega imagem (mesma origem) e devolve dataURL JPEG via canvas. function carregarDataUrl(url: string): Promise { return new Promise(resolve => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { try { const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext('2d'); if (!ctx) return resolve(null); ctx.drawImage(img, 0, 0); resolve(canvas.toDataURL('image/jpeg', 0.85)); } catch { resolve(null); } }; img.onerror = () => resolve(null); img.src = url; }); } // Gera um PDF nativo (jsPDF) com as fotos agrupadas por sessão, 3 por linha. async function exportarPDF(titulo: string, fotos: ExportFoto[]) { const doc = new jsPDF({ unit: 'mm', format: 'a4' }); const pageW = 210, pageH = 297, margin = 12, gap = 6, cols = 3; const colW = (pageW - margin * 2 - gap * (cols - 1)) / cols; const imgH = colW * 0.78; const cellH = imgH + 7; const slotLabel = (slot: string) => ORTO_SLOTS.find(s => s.key === slot)?.label || slot; doc.setFontSize(14); doc.setTextColor(31); doc.text(titulo, margin, margin + 4); doc.setFontSize(8); doc.setTextColor(120); doc.text('Documentação ortodôntica · ' + new Date().toLocaleDateString('pt-BR'), margin, margin + 9); doc.setDrawColor(79, 70, 229); doc.line(margin, margin + 12, pageW - margin, margin + 12); let y = margin + 18; const grupos: { label: string; fotos: ExportFoto[] }[] = []; for (const f of fotos) { let g = grupos.find(x => x.label === f.sessaoLabel); if (!g) { g = { label: f.sessaoLabel, fotos: [] }; grupos.push(g); } g.fotos.push(f); } for (const g of grupos) { if (y + 10 + cellH > pageH - margin) { doc.addPage(); y = margin; } doc.setFontSize(10); doc.setTextColor(79, 70, 229); doc.text(g.label.toUpperCase(), margin, y); y += 4; let col = 0; for (const f of g.fotos) { if (y + cellH > pageH - margin) { doc.addPage(); y = margin; col = 0; } const x = margin + col * (colW + gap); const data = await carregarDataUrl(window.location.origin + f.url); if (data) { try { doc.addImage(data, 'JPEG', x, y, colW, imgH); } catch { doc.setDrawColor(220); doc.rect(x, y, colW, imgH); } } else { doc.setDrawColor(220); doc.rect(x, y, colW, imgH); } doc.setFontSize(6.5); doc.setTextColor(110); doc.text(slotLabel(f.slot).toUpperCase(), x + colW / 2, y + imgH + 4, { align: 'center' }); col++; if (col === cols) { col = 0; y += cellH; } } if (col !== 0) y += cellH; y += 4; } doc.save(titulo.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/g, '') + '.pdf'); } const ExportModal: React.FC<{ sessoes: OrtoSessao[]; pacienteNome?: string; onClose: () => void; }> = ({ sessoes, pacienteNome, onClose }) => { const ordenadas = [...sessoes].sort((a, b) => sessionSortKey(a).localeCompare(sessionSortKey(b))); const todas: ExportFoto[] = ordenadas.flatMap(s => s.fotos.map(f => ({ id: f.id, url: f.url, slot: f.slot, sessaoLabel: sessionLabel(s) }))); const [sel, setSel] = useState>(new Set(todas.map(f => f.id))); const [gerando, setGerando] = useState(false); const slotLabel = (slot: string) => ORTO_SLOTS.find(s => s.key === slot)?.label || slot; const toggle = (id: string) => setSel(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; }); const todasMarcadas = todas.length > 0 && sel.size === todas.length; const toggleTodas = () => setSel(todasMarcadas ? new Set() : new Set(todas.map(f => f.id))); const titulo = `DOCUMENTAÇÃO ORTODÔNTICA${pacienteNome ? ' — ' + pacienteNome.toUpperCase() : ''}`; const selecionadas = () => todas.filter(f => sel.has(f.id)); const exportarPdf = async () => { const fotos = selecionadas(); if (!fotos.length) return; setGerando(true); try { await exportarPDF(titulo, fotos); onClose(); } finally { setGerando(false); } }; const imprimir = () => { const fotos = selecionadas(); if (!fotos.length) return; abrirDocumentoImpressao(titulo, fotos); onClose(); }; return (

EXPORTAR DOCUMENTAÇÃO

{sel.size}/{todas.length} FOTOS
{todas.length === 0 ? (

NENHUMA FOTO PARA EXPORTAR.

) : ordenadas.filter(s => s.fotos.length).map(s => (

{sessionLabel(s)}

{s.fotos.map(f => { const on = sel.has(f.id); return ( ); })}
))}
); }; // ─── Componente principal ───────────────────────────────────────── interface Props { tratamentoId: string; clinicaId?: string; pacienteNome?: string; } export const OrthoPhotoWorkspace: React.FC = ({ tratamentoId, clinicaId, pacienteNome }) => { const toast = useToast(); const confirm = useConfirm(); const [sessoes, setSessoes] = useState>({}); const [tipoAtivo, setTipoAtivo] = useState(''); const [novoMes, setNovoMes] = useState(currentYm()); const [loading, setLoading] = useState(true); const [uploading, setUploading] = useState>({}); const [viewFoto, setViewFoto] = useState<{ foto: OrtoFoto; label: string } | null>(null); const [comparing, setComparing] = useState(false); const [showExport, setShowExport] = useState(false); const [captureSlot, setCaptureSlot] = useState(null); const loadSessoes = useCallback(async () => { try { const res = await HybridBackend.getOrtoSessoes(tratamentoId); if (res.success) { const map: Record = {}; for (const s of res.sessoes) map[s.tipo] = s; setSessoes(map); // seleciona a primeira sessão (ou mantém a ativa se ainda existir) setTipoAtivo(prev => (prev && map[prev]) ? prev : (Object.values(map).sort((a, b) => sessionSortKey(a).localeCompare(sessionSortKey(b)))[0]?.tipo || '')); } } catch { toast.error('ERRO AO CARREGAR SESSÕES.'); } finally { setLoading(false); } }, [tratamentoId]); useEffect(() => { loadSessoes(); }, [loadSessoes]); // Garante que a sessão existe no backend (cria se necessário). Para sessões // mensais (tipo `mes_YYYY-MM`) deriva rótulo e data de referência. const ensureSessao = useCallback(async (tipo: string, rotulo?: string, dataRef?: string): Promise => { if (sessoes[tipo]) return sessoes[tipo]; let rot = rotulo, dr = dataRef; if (!rot && tipo.startsWith('mes_')) { const ym = tipo.slice(4); rot = monthLabel(ym); dr = `${ym}-01`; } try { const res = await HybridBackend.getOrtoSessao(tratamentoId, tipo, clinicaId, rot, dr); if (res.success) { setSessoes(prev => ({ ...prev, [tipo]: res.sessao })); return res.sessao; } } catch {} return null; }, [sessoes, tratamentoId, clinicaId]); const criarSessao = useCallback(async (tipo: string, rotulo?: string, dataRef?: string) => { const s = await ensureSessao(tipo, rotulo, dataRef); if (s) setTipoAtivo(s.tipo); else toast.error('ERRO AO CRIAR SESSÃO.'); }, [ensureSessao, toast]); const adicionarMes = useCallback(() => { const ym = novoMes || currentYm(); criarSessao(monthTipo(ym), monthLabel(ym), `${ym}-01`); }, [novoMes, criarSessao]); const handleUpload = useCallback(async (slot: string, file: File) => { setUploading(prev => ({ ...prev, [slot]: true })); try { let sessao = await ensureSessao(tipoAtivo); if (!sessao) { toast.error('ERRO AO CRIAR SESSÃO.'); return; } const res = await HybridBackend.uploadOrtoFoto(sessao.id, slot, file); if (!res.success) { toast.error('ERRO NO UPLOAD.'); return; } toast.success('FOTO SALVA!'); // Atualiza state local setSessoes(prev => { const s = prev[tipoAtivo]; if (!s) return prev; const fotos = s.fotos.filter(f => f.slot !== slot); fotos.push(res.foto!); return { ...prev, [tipoAtivo]: { ...s, fotos } }; }); } catch { toast.error('ERRO NO UPLOAD.'); } finally { setUploading(prev => ({ ...prev, [slot]: false })); } }, [tipoAtivo, ensureSessao, toast]); const handleDelete = useCallback(async (fotoId: string) => { const res = await HybridBackend.deleteOrtoFoto(fotoId); if (!res.success) { toast.error('ERRO AO EXCLUIR.'); return; } toast.success('FOTO EXCLUÍDA!'); setSessoes(prev => { const updated = { ...prev }; for (const tipo in updated) { updated[tipo] = { ...updated[tipo], fotos: updated[tipo].fotos.filter(f => f.id !== fotoId) }; } return updated; }); }, [toast]); const sessoesList = Object.values(sessoes).sort((a, b) => sessionSortKey(a).localeCompare(sessionSortKey(b))); const sessaoAtiva = sessoes[tipoAtivo] || { id: '', tipo: tipoAtivo, fotos: [] }; const totalFotos = sessoesList.reduce((acc, s) => acc + s.fotos.length, 0); const especiaisDisponiveis = SPECIAL_SESSIONS.filter(sp => !sessoes[sp.tipo]); return (
{/* Header */}

DOCUMENTAÇÃO FOTOGRÁFICA

{totalFotos > 0 && ( {totalFotos} FOTOS )}
{/* Adicionar sessão (orto é mensal → uma sessão por mês) */}
ADICIONAR SESSÃO: setNovoMes(e.target.value)} className="border border-gray-200 rounded-lg px-2 py-1 text-[11px] font-bold text-gray-700 focus:outline-none focus:border-emerald-400" /> {especiaisDisponiveis.map(sp => ( ))}
{/* Session tabs */} {sessoesList.length > 0 && (
{sessoesList.map(s => { const count = s.fotos.length; const isActive = s.tipo === tipoAtivo; return ( ); })}
)} {/* Conteúdo */} {loading ? (
) : sessoesList.length === 0 ? (

NENHUMA SESSÃO DE FOTOS AINDA

ADICIONE A SESSÃO DO MÊS (OU A INICIAL) ACIMA PARA COMEÇAR A DOCUMENTAÇÃO.

) : ( <> {/* Completude da sessão ativa */}

COMPLETUDE — {sessionLabel(sessaoAtiva)}

{/* Grade */}
setViewFoto({ foto, label })} onRequestCapture={setCaptureSlot} uploading={uploading} />
{/* Instrução */}

CLIQUE NUM SLOT VAZIO PARA ABRIR A CÂMERA (OU GALERIA) · CLIQUE NA FOTO PARA VISUALIZAR

)} {/* Viewer */} {viewFoto && ( setViewFoto(null)} onDelete={() => handleDelete(viewFoto.foto.id)} /> )} {/* Comparador */} {comparing && ( setComparing(false)} /> )} {/* Exportação */} {captureSlot && ( s.key === captureSlot)?.label || 'FOTO'} onCapture={(file) => handleUpload(captureSlot, file)} onClose={() => setCaptureSlot(null)} /> )} {showExport && ( setShowExport(false)} /> )}
); };