Files
scoreodonto.com/frontend/views/OrthoPhotoWorkspace.tsx
T
VPS 4 Builder 3e64514b08 feat(financeiro+salas): motor financeiro V1/V2, glosas, GTO→financeiro, contratos de convênio e redesenho de Salas
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>
2026-06-16 00:28:07 +02:00

839 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
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 <PhotoAnnotator fotoId={foto.id} fotoUrl={foto.url} slotLabel={slotLabel} onClose={() => setAnnotating(false)} />;
}
return (
<div className="fixed inset-0 bg-black/90 z-[70] flex flex-col">
<div className="flex items-center justify-between px-4 py-3 flex-shrink-0">
<span className="text-white font-black text-xs uppercase">{slotLabel}</span>
<div className="flex items-center gap-2">
<button
onClick={() => setAnnotating(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-black uppercase transition-colors"
>
<Pen size={13} /> ANOTAR
</button>
<button
onClick={async () => { if (await confirm('EXCLUIR ESTA FOTO?')) { onDelete(); onClose(); } }}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded-lg text-[11px] font-black uppercase transition-colors"
>
<Trash2 size={13} /> EXCLUIR
</button>
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-lg transition-colors text-white">
<X size={20} />
</button>
</div>
</div>
<div className="flex-1 flex items-center justify-center p-4 min-h-0">
<img
src={foto.url}
alt={slotLabel}
className="max-w-full max-h-full object-contain rounded-xl select-none"
draggable={false}
/>
</div>
</div>
);
};
// ─── 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 (
<div className="fixed inset-0 bg-black/90 z-[70] flex flex-col">
<div className="flex items-center justify-between px-4 py-3 flex-shrink-0 border-b border-white/10">
<span className="text-white font-black text-sm uppercase flex items-center gap-2">
<SplitSquareHorizontal size={16} /> COMPARADOR {slotLabel}
</span>
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-lg text-white transition-colors"><X size={20} /></button>
</div>
{/* Controles */}
<div className="flex flex-wrap gap-3 px-4 py-3 flex-shrink-0 bg-black/30 items-end">
<div className="flex-1 min-w-[120px]">
<p className="text-[10px] text-white/50 font-bold uppercase mb-1">FOTO (SLOT)</p>
<select value={slot} onChange={e => setSlot(e.target.value)} className="w-full bg-white/10 text-white text-[11px] font-bold rounded-lg px-2 py-1.5 border border-white/20 uppercase">
{ORTO_SLOTS.map(s => (
<option key={s.key} value={s.key} className="bg-gray-900">{s.label} {slotsDisp.some(d => d.key === s.key) ? '✓' : ''}</option>
))}
</select>
</div>
<div className="flex-1 min-w-[110px]">
<p className="text-[10px] text-white/50 font-bold uppercase mb-1">SESSÃO A</p>
<select value={tipoA} onChange={e => setTipoA(e.target.value)} className="w-full bg-white/10 text-white text-[11px] font-bold rounded-lg px-2 py-1.5 border border-white/20 uppercase">
{comFotos.map(s => <option key={s.tipo} value={s.tipo} className="bg-gray-900">{sessionLabel(s)}</option>)}
</select>
</div>
<div className="flex-1 min-w-[110px]">
<p className="text-[10px] text-white/50 font-bold uppercase mb-1">SESSÃO B</p>
<select value={tipoB} onChange={e => setTipoB(e.target.value)} className="w-full bg-white/10 text-white text-[11px] font-bold rounded-lg px-2 py-1.5 border border-white/20 uppercase">
{comFotos.map(s => <option key={s.tipo} value={s.tipo} className="bg-gray-900">{sessionLabel(s)}</option>)}
</select>
</div>
<div className="flex gap-1">
{MODOS.map(m => (
<button key={m.id} onClick={() => setModo(m.id)}
className={`px-2.5 py-1.5 rounded-lg text-[10px] font-black uppercase border transition-colors ${modo === m.id ? 'bg-white text-gray-900 border-white' : 'bg-white/10 text-white/70 border-white/20 hover:bg-white/20'}`}>
{m.label}
</button>
))}
</div>
</div>
{/* Visualização */}
<div className="flex-1 p-4 min-h-0">
{modo === 'evolucao' ? (
<div className="h-full overflow-x-auto overflow-y-hidden">
<div className="flex gap-3 h-full pb-2" style={{ minWidth: 'min-content' }}>
{comFotos.length === 0 ? (
<div className="w-full flex flex-col items-center justify-center text-white/30 gap-2"><Camera size={36} /><span className="text-xs font-bold uppercase">SEM FOTOS</span></div>
) : comFotos.map(s => {
const foto = s.fotos.find(ff => ff.slot === slot);
return (
<div key={s.tipo} className="flex-shrink-0 w-52 bg-black/40 rounded-xl flex flex-col overflow-hidden border border-white/10">
<p className="text-[10px] font-black text-white/60 uppercase px-3 py-1.5 bg-black/20 text-center flex-shrink-0">{sessionLabel(s)}</p>
<div className="flex-1 flex items-center justify-center p-2 min-h-0">
{foto ? <img src={foto.url} alt="" className="max-w-full max-h-full object-contain rounded-lg" />
: <div className="flex flex-col items-center gap-1 text-white/30"><Camera size={22} /><span className="text-[9px] font-bold uppercase">SEM FOTO</span></div>}
</div>
</div>
);
})}
</div>
</div>
) : !fotoA && !fotoB ? (
<div className="h-full flex flex-col items-center justify-center text-white/30 gap-2">
<Camera size={36} /><span className="text-xs font-bold uppercase">SEM FOTO DESTE SLOT NAS SESSÕES ESCOLHIDAS</span>
</div>
) : modo === 'lado' ? (
<div className="h-full grid grid-cols-2 gap-2">
{[{ foto: fotoA, lbl: labelOf(tipoA) }, { foto: fotoB, lbl: labelOf(tipoB) }].map((c, i) => (
<div key={i} className="bg-black/40 rounded-xl flex flex-col overflow-hidden border border-white/10">
<p className="text-[10px] font-black text-white/60 uppercase px-3 py-1.5 flex-shrink-0 bg-black/20">{c.lbl}</p>
<div className="flex-1 flex items-center justify-center p-2 min-h-0">
{c.foto ? <img src={c.foto.url} alt="" className="max-w-full max-h-full object-contain rounded-lg" />
: <div className="flex flex-col items-center gap-2 text-white/30"><Camera size={28} /><span className="text-[10px] font-bold uppercase">SEM FOTO</span></div>}
</div>
</div>
))}
</div>
) : modo === 'slider' ? (
<div className="h-full flex flex-col gap-2">
<div className="relative flex-1 min-h-0 mx-auto max-w-3xl w-full overflow-hidden rounded-xl border border-white/10 bg-black/40 select-none">
{fotoA && <img src={fotoA.url} alt="" className="absolute inset-0 w-full h-full object-contain" />}
{fotoB && <div className="absolute inset-0 overflow-hidden" style={{ clipPath: `inset(0 0 0 ${pos}%)` }}>
<img src={fotoB.url} alt="" className="absolute inset-0 w-full h-full object-contain" />
</div>}
<div className="absolute top-0 bottom-0 w-0.5 bg-white/80 pointer-events-none" style={{ left: `${pos}%` }} />
<span className="absolute top-2 left-2 bg-black/60 text-white text-[9px] font-black uppercase px-2 py-0.5 rounded">{labelOf(tipoA)}</span>
<span className="absolute top-2 right-2 bg-black/60 text-white text-[9px] font-black uppercase px-2 py-0.5 rounded">{labelOf(tipoB)}</span>
</div>
<input type="range" min={0} max={100} value={pos} onChange={e => setPos(Number(e.target.value))} className="w-full max-w-3xl mx-auto" />
</div>
) : (
<div className="h-full flex flex-col gap-2">
<div className="relative flex-1 min-h-0 mx-auto max-w-3xl w-full overflow-hidden rounded-xl border border-white/10 bg-black/40">
{fotoA && <img src={fotoA.url} alt="" className="absolute inset-0 w-full h-full object-contain" />}
{fotoB && <img src={fotoB.url} alt="" className="absolute inset-0 w-full h-full object-contain" style={{ opacity: op / 100 }} />}
</div>
<div className="flex items-center gap-3 max-w-3xl mx-auto w-full">
<span className="text-[9px] font-black text-white/60 uppercase">{labelOf(tipoA)}</span>
<input type="range" min={0} max={100} value={op} onChange={e => setOp(Number(e.target.value))} className="flex-1" />
<span className="text-[9px] font-black text-white/60 uppercase">{labelOf(tipoB)}</span>
</div>
</div>
)}
</div>
</div>
);
};
// ─── Grade de slots ───────────────────────────────────────────────
const SlotGrid: React.FC<{
sessao: OrtoSessao;
onUpload: (slot: string, file: File) => Promise<void>;
onView: (foto: OrtoFoto, slotLabel: string) => void;
onRequestCapture: (slot: string) => void;
uploading: Record<string, boolean>;
}> = ({ sessao, onUpload, onView, onRequestCapture, uploading }) => {
const fotosMap = Object.fromEntries(sessao.fotos.map(f => [f.slot, f]));
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({});
const cats = ['EXTRAORAL', 'INTRAORAL', 'COMPLEMENTAR'];
return (
<div className="space-y-5">
{cats.map(cat => (
<div key={cat}>
<p className={`text-[10px] font-black uppercase px-2 py-1 rounded-lg border w-fit mb-3 ${CAT_COLORS[cat]}`}>{cat}</p>
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 gap-3">
{ORTO_SLOTS.filter(s => s.cat === cat).map(slot => {
const foto = fotosMap[slot.key];
const isUploading = uploading[slot.key];
return (
<div key={slot.key} className="flex flex-col gap-1">
{/* Slot box */}
<div
onClick={() => 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 ? (
<div className="absolute inset-0 flex items-center justify-center bg-white/80">
<Loader2 size={22} className="animate-spin text-teal-500" />
</div>
) : foto ? (
<>
<img src={foto.url} alt={slot.label} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-all flex items-center justify-center">
<ZoomIn size={22} className="text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<div className="absolute top-1.5 right-1.5">
<CheckCircle size={16} className="text-white drop-shadow" />
</div>
</>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 text-gray-400 group-hover:text-teal-500 transition-colors">
<Camera size={20} />
<Upload size={13} />
</div>
)}
</div>
{/* Label */}
<p className="text-[9px] font-black text-gray-500 uppercase text-center leading-tight">{slot.label}</p>
{/* Input oculto */}
<input
ref={el => { 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 = '';
}}
/>
</div>
);
})}
</div>
</div>
))}
</div>
);
};
// ─── 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 (
<div className="flex items-center gap-3">
<div className="flex-1 bg-gray-100 rounded-full h-2">
<div className={`${color} h-2 rounded-full transition-all duration-500`} style={{ width: `${pct}%` }} />
</div>
<span className="text-[11px] font-black text-gray-500 uppercase w-24 text-right">
{preenchidos}/{total} {pct}%
</span>
</div>
);
};
// ─── 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 => `
<section class="sessao">
<h2>${g.label}</h2>
<div class="grid">
${g.fotos.map(f => `<figure><img src="${origin}${f.url}" /><figcaption>${slotLabel(f.slot)}</figcaption></figure>`).join('')}
</div>
</section>`).join('');
w.document.write(`<!doctype html><html lang="pt-BR"><head><meta charset="utf-8" /><title>${titulo}</title>
<style>
*{box-sizing:border-box;font-family:Arial,Helvetica,sans-serif;}
body{margin:24px;color:#1f2937;}
header{display:flex;justify-content:space-between;align-items:baseline;border-bottom:2px solid #0d9488;padding-bottom:8px;margin-bottom:16px;}
header h1{font-size:18px;margin:0;text-transform:uppercase;letter-spacing:.5px;}
header span{font-size:11px;color:#6b7280;}
.sessao{margin-bottom:22px;break-inside:avoid;}
.sessao h2{font-size:13px;text-transform:uppercase;color:#0d9488;margin:0 0 8px;border-left:4px solid #0d9488;padding-left:8px;}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;}
figure{margin:0;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;break-inside:avoid;}
figure img{width:100%;height:150px;object-fit:cover;display:block;}
figcaption{font-size:9px;font-weight:bold;text-transform:uppercase;color:#6b7280;text-align:center;padding:4px;}
@media print{ body{margin:0;} @page{margin:14mm;} }
</style></head>
<body>
<header><h1>${titulo}</h1><span>Documentação ortodôntica · ${new Date().toLocaleDateString('pt-BR')}</span></header>
${cards || '<p>Nenhuma foto selecionada.</p>'}
<script>
window.addEventListener('load', function(){
var imgs = Array.prototype.slice.call(document.images);
Promise.all(imgs.map(function(i){ return i.complete ? 1 : new Promise(function(r){ i.onload=i.onerror=r; }); }))
.then(function(){ setTimeout(function(){ window.print(); }, 300); });
});
</script>
</body></html>`);
w.document.close();
}
// Carrega imagem (mesma origem) e devolve dataURL JPEG via canvas.
function carregarDataUrl(url: string): Promise<string | null> {
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<Set<string>>(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 (
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 flex-shrink-0">
<div className="flex items-center gap-2"><FileDown size={18} className="text-emerald-500" /><h3 className="font-black text-sm uppercase text-gray-800">EXPORTAR DOCUMENTAÇÃO</h3></div>
<button onClick={onClose} className="p-1.5 hover:bg-gray-100 rounded-lg"><X size={18} /></button>
</div>
<div className="px-5 py-3 border-b border-gray-100 flex items-center justify-between">
<button onClick={toggleTodas} className="text-[11px] font-black uppercase text-emerald-600 hover:text-emerald-800">
{todasMarcadas ? 'DESMARCAR TUDO' : 'SELECIONAR TUDO (COMPLETA)'}
</button>
<span className="text-[10px] font-black text-gray-400 uppercase">{sel.size}/{todas.length} FOTOS</span>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-4 min-h-0">
{todas.length === 0 ? (
<p className="text-center text-gray-400 font-bold uppercase text-xs py-8">NENHUMA FOTO PARA EXPORTAR.</p>
) : ordenadas.filter(s => s.fotos.length).map(s => (
<div key={s.id}>
<p className="text-[10px] font-black text-emerald-600 uppercase mb-2">{sessionLabel(s)}</p>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{s.fotos.map(f => {
const on = sel.has(f.id);
return (
<button key={f.id} onClick={() => toggle(f.id)} className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${on ? 'border-emerald-500' : 'border-transparent opacity-50'}`}>
<img src={f.url} alt={f.slot} className="w-full h-full object-cover" />
<span className={`absolute top-1 right-1 w-4 h-4 rounded-full flex items-center justify-center ${on ? 'bg-emerald-600' : 'bg-black/40'}`}>
{on && <CheckCircle size={12} className="text-white" />}
</span>
<span className="absolute bottom-0 inset-x-0 bg-black/50 text-white text-[7px] font-bold uppercase text-center py-0.5 truncate">{slotLabel(f.slot)}</span>
</button>
);
})}
</div>
</div>
))}
</div>
<div className="px-5 pb-5 pt-3 border-t border-gray-100 flex gap-2 flex-shrink-0">
<button onClick={onClose} disabled={gerando} className="px-4 py-2.5 border border-gray-200 rounded-xl text-[11px] font-black uppercase hover:bg-gray-50 disabled:opacity-40">CANCELAR</button>
<button onClick={imprimir} disabled={sel.size === 0 || gerando} className="px-4 py-2.5 border border-gray-200 rounded-xl text-[11px] font-black uppercase text-gray-600 hover:bg-gray-50 disabled:opacity-40">IMPRIMIR</button>
<button onClick={exportarPdf} disabled={sel.size === 0 || gerando} className="flex-1 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-[11px] font-black uppercase disabled:opacity-40 flex items-center justify-center gap-2">
{gerando ? <><Loader2 size={13} className="animate-spin" /> GERANDO PDF</> : <><FileDown size={13} /> EXPORTAR PDF ({sel.size})</>}
</button>
</div>
</div>
</div>
);
};
// ─── Componente principal ─────────────────────────────────────────
interface Props {
tratamentoId: string;
clinicaId?: string;
pacienteNome?: string;
}
export const OrthoPhotoWorkspace: React.FC<Props> = ({ tratamentoId, clinicaId, pacienteNome }) => {
const toast = useToast();
const confirm = useConfirm();
const [sessoes, setSessoes] = useState<Record<string, OrtoSessao>>({});
const [tipoAtivo, setTipoAtivo] = useState('');
const [novoMes, setNovoMes] = useState(currentYm());
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState<Record<string, boolean>>({});
const [viewFoto, setViewFoto] = useState<{ foto: OrtoFoto; label: string } | null>(null);
const [comparing, setComparing] = useState(false);
const [showExport, setShowExport] = useState(false);
const [captureSlot, setCaptureSlot] = useState<string | null>(null);
const loadSessoes = useCallback(async () => {
try {
const res = await HybridBackend.getOrtoSessoes(tratamentoId);
if (res.success) {
const map: Record<string, OrtoSessao> = {};
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<OrtoSessao | null> => {
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 (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-gray-100 bg-gray-50 flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Camera size={16} className="text-emerald-500" />
<h3 className="font-black text-gray-700 uppercase text-sm">DOCUMENTAÇÃO FOTOGRÁFICA</h3>
{totalFotos > 0 && (
<span className="text-[10px] font-black px-2 py-0.5 bg-emerald-100 text-emerald-600 rounded-full uppercase">{totalFotos} FOTOS</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowExport(true)}
disabled={totalFotos === 0}
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-200 rounded-lg text-[10px] font-black text-gray-600 hover:bg-gray-100 uppercase transition-colors disabled:opacity-40"
>
<FileDown size={13} /> EXPORTAR
</button>
<button
onClick={() => setComparing(true)}
disabled={totalFotos === 0}
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-200 rounded-lg text-[10px] font-black text-gray-600 hover:bg-gray-100 uppercase transition-colors disabled:opacity-40"
>
<SplitSquareHorizontal size={13} /> COMPARAR
</button>
<button onClick={loadSessoes} className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors text-gray-400">
<RefreshCw size={14} />
</button>
</div>
</div>
{/* Adicionar sessão (orto é mensal → uma sessão por mês) */}
<div className="px-5 py-3 border-b border-gray-100 bg-white flex flex-wrap items-center gap-2">
<span className="text-[10px] font-black text-gray-400 uppercase mr-1">ADICIONAR SESSÃO:</span>
<input
type="month"
value={novoMes}
onChange={e => 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"
/>
<button
onClick={adicionarMes}
className="flex items-center gap-1 px-2.5 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[10px] font-black uppercase transition-colors"
>
<CalendarPlus size={12} /> MÊS
</button>
{especiaisDisponiveis.map(sp => (
<button
key={sp.tipo}
onClick={() => criarSessao(sp.tipo)}
className="flex items-center gap-1 px-2.5 py-1.5 border border-gray-200 rounded-lg text-[10px] font-black text-gray-600 hover:bg-gray-100 uppercase transition-colors"
>
<Plus size={12} /> {sp.label}
</button>
))}
</div>
{/* Session tabs */}
{sessoesList.length > 0 && (
<div className="flex overflow-x-auto border-b border-gray-100 bg-white">
{sessoesList.map(s => {
const count = s.fotos.length;
const isActive = s.tipo === tipoAtivo;
return (
<button
key={s.tipo}
onClick={() => setTipoAtivo(s.tipo)}
className={`flex-shrink-0 px-4 py-2.5 text-[10px] font-black uppercase transition-all border-b-2 flex items-center gap-1.5 ${
isActive
? 'border-teal-500 text-teal-600 bg-teal-50/50'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
{sessionLabel(s)}
{count > 0 && (
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded-full ${isActive ? 'bg-teal-500 text-white' : 'bg-gray-200 text-gray-600'}`}>
{count}
</span>
)}
</button>
);
})}
</div>
)}
{/* Conteúdo */}
{loading ? (
<div className="flex items-center justify-center h-40">
<Loader2 size={28} className="animate-spin text-gray-300" />
</div>
) : sessoesList.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400 gap-2">
<Camera size={30} />
<p className="text-xs font-black uppercase">NENHUMA SESSÃO DE FOTOS AINDA</p>
<p className="text-[10px] text-center max-w-xs">ADICIONE A SESSÃO DO MÊS (OU A INICIAL) ACIMA PARA COMEÇAR A DOCUMENTAÇÃO.</p>
</div>
) : (
<>
{/* Completude da sessão ativa */}
<div className="px-5 py-3 border-b border-gray-100 bg-gray-50/50">
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-black text-gray-400 uppercase">COMPLETUDE {sessionLabel(sessaoAtiva)}</p>
</div>
<Completude sessao={sessaoAtiva} />
</div>
{/* Grade */}
<div className="p-5">
<SlotGrid
sessao={sessaoAtiva}
onUpload={handleUpload}
onView={(foto, label) => setViewFoto({ foto, label })}
onRequestCapture={setCaptureSlot}
uploading={uploading}
/>
</div>
{/* Instrução */}
<div className="px-5 pb-4">
<p className="text-[10px] text-gray-400 font-bold uppercase text-center">
CLIQUE NUM SLOT VAZIO PARA ABRIR A CÂMERA (OU GALERIA) · CLIQUE NA FOTO PARA VISUALIZAR
</p>
</div>
</>
)}
{/* Viewer */}
{viewFoto && (
<PhotoViewer
foto={viewFoto.foto}
slotLabel={viewFoto.label}
onClose={() => setViewFoto(null)}
onDelete={() => handleDelete(viewFoto.foto.id)}
/>
)}
{/* Comparador */}
{comparing && (
<Comparator sessoes={sessoesList} sessaoInicial={tipoAtivo} onClose={() => setComparing(false)} />
)}
{/* Exportação */}
{captureSlot && (
<CameraCapture
title={ORTO_SLOTS.find(s => s.key === captureSlot)?.label || 'FOTO'}
onCapture={(file) => handleUpload(captureSlot, file)}
onClose={() => setCaptureSlot(null)}
/>
)}
{showExport && (
<ExportModal sessoes={sessoesList} pacienteNome={pacienteNome} onClose={() => setShowExport(false)} />
)}
</div>
);
};