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>
839 lines
41 KiB
TypeScript
839 lines
41 KiB
TypeScript
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>
|
||
);
|
||
};
|