Files
scoreodonto.com/frontend/views/OrthoView.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

1194 lines
65 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 } from 'react';
import {
Plus, Activity, ArrowRight, ArrowLeft, X, AlertTriangle, Info, ShieldAlert,
Camera, Upload, CheckCircle, Clock, Stethoscope, FileText, Heart, Sparkles,
ChevronDown, ChevronUp, User, Calendar, Percent, Scissors, RotateCcw,
Wrench, ShieldCheck, Droplets, FileDown, ClipboardCheck, AlertCircle,
Brush, Crosshair
} from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { TratamentoOrto, EvolucaoOrto, TipoEventoOrto, CooperacaoOrto, Paciente } from '../types.ts';
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
import { OrthoPhotoWorkspace } from './OrthoPhotoWorkspace.tsx';
import { TutoriaSection } from './TutoriaSection.tsx';
/* ================================================================
HELPER: calcula idade a partir de data de nascimento
================================================================ */
function calcAge(dob?: string): number | null {
if (!dob) return null;
const birth = new Date(dob);
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const m = today.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
return age;
}
function tipoPacienteLabel(age: number | null): string {
if (age === null) return '';
if (age < 10) return 'INFANTIL';
if (age <= 17) return 'ADOLESCENTE';
return 'ADULTO';
}
const COOPERATION_COLORS: Record<string, string> = {
'Boa': 'bg-green-100 text-green-700',
'Média': 'bg-amber-100 text-amber-700',
'Ruim': 'bg-red-100 text-red-700',
};
const FLAG_STYLES: Record<string, { bg: string; text: string; icon: React.ReactNode }> = {
'danger': { bg: 'bg-red-50 border-red-200', text: 'text-red-700', icon: <ShieldAlert size={14} /> },
'warning': { bg: 'bg-amber-50 border-amber-200', text: 'text-amber-700', icon: <AlertTriangle size={14} /> },
'info': { bg: 'bg-blue-50 border-blue-200', text: 'text-blue-600', icon: <Info size={14} /> },
};
const PROCEDURE_BUTTONS = [
{ label: 'TROCA FIO', icon: <RotateCcw size={14} /> },
{ label: 'ELÁSTICO', icon: <Sparkles size={14} /> },
{ label: 'IPR', icon: <Scissors size={14} /> },
{ label: 'RECOLAGEM', icon: <Wrench size={14} /> },
{ label: 'AJUSTE TORQUE', icon: <RotateCcw size={14} /> },
{ label: 'PROFILAXIA', icon: <Droplets size={14} /> },
];
/* ================================================================
SECTION 1 PATIENT HEADER (Dashboard fixo)
================================================================ */
const PatientHeader: React.FC<{ t: TratamentoOrto }> = ({ t }) => {
const age = calcAge(t.dataNascimento);
const tipoLabel = tipoPacienteLabel(age);
return (
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div className="flex items-center gap-4">
<div className="w-14 h-14 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center font-bold text-lg flex-shrink-0">
{t.pacienteNome.substring(0, 2)}
</div>
<div>
<h2 className="text-xl font-bold text-gray-900 uppercase">{t.pacienteNome} {age !== null && <span className="text-gray-500 font-normal"> {age} ANOS</span>}</h2>
<div className="flex flex-wrap gap-2 mt-1">
{tipoLabel && <span className="text-[10px] font-bold bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full uppercase">{tipoLabel}</span>}
<span className="text-[10px] font-bold bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full uppercase">{t.classificacao || t.diagnostico?.classeAngle}</span>
<span className="text-[10px] font-bold bg-teal-100 text-teal-600 px-2 py-0.5 rounded-full uppercase">FASE: {t.faseAtual}</span>
<span className="text-[10px] font-bold bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full uppercase">FIO {t.fioAtual}</span>
{t.cooperacao && <span className={`text-[10px] font-bold px-2 py-0.5 rounded-full uppercase ${COOPERATION_COLORS[t.cooperacao] || ''}`}>COOPERAÇÃO {t.cooperacao.toUpperCase()}</span>}
</div>
</div>
</div>
<div className="flex flex-wrap gap-4 text-xs uppercase font-bold text-gray-500">
<div className="flex items-center gap-1"><Calendar size={14} /> ÚLTIMA: {t.ultimaConsulta ? new Date(t.ultimaConsulta).toLocaleDateString() : '—'}</div>
<div className="flex items-center gap-1"><Calendar size={14} className="text-teal-500" /> PRÓX: {t.proximaConsulta ? new Date(t.proximaConsulta).toLocaleDateString() : '—'}</div>
<div className="flex items-center gap-1"><Clock size={14} /> {t.mesesRestantes ?? '?'} MESES RESTANTES</div>
</div>
</div>
{/* Progress bar */}
{t.percentualConcluido != null && (
<div className="mt-4">
<div className="flex justify-between text-[10px] font-bold text-gray-500 uppercase mb-1">
<span>PROGRESSO DO TRATAMENTO</span>
<span>{t.percentualConcluido}%</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2.5">
<div className="bg-teal-500 h-2.5 rounded-full transition-all duration-500" style={{ width: `${t.percentualConcluido}%` }}></div>
</div>
</div>
)}
{/* Flags */}
{t.flags && t.flags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{t.flags.map((f, i) => {
const s = FLAG_STYLES[f.tipo] || FLAG_STYLES.info;
return (
<div key={i} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-xs font-bold uppercase ${s.bg} ${s.text}`}>
{s.icon} {f.mensagem}
</div>
);
})}
</div>
)}
{t.responsavel && <p className="mt-2 text-[10px] text-gray-400 font-bold uppercase">RESPONSÁVEL: {t.responsavel}</p>}
</div>
);
};
/* ================================================================
SECTION 2 TRIAGEM CLÍNICA
================================================================ */
const ClinicalTriage: React.FC<{ t: TratamentoOrto }> = ({ t }) => {
const [open, setOpen] = useState(false);
const tr = t.triagem;
if (!tr) return null;
const age = calcAge(t.dataNascimento);
const isAdult = age !== null && age >= 18;
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
<button onClick={() => setOpen(!open)} className="w-full px-5 py-3 flex justify-between items-center bg-gray-50 hover:bg-gray-100 transition-colors">
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2"><Stethoscope size={16} className="text-purple-500" /> TRIAGEM CLÍNICA</h3>
{open ? <ChevronUp size={18} className="text-gray-400" /> : <ChevronDown size={18} className="text-gray-400" />}
</button>
{open && (
<div className="p-5 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm animate-in fade-in">
<Field label="QUEIXA PRINCIPAL" value={tr.queixaPrincipal} />
<Field label="HISTÓRICO MÉDICO" value={tr.historicoMedico} />
<Field label="MEDICAÇÃO" value={tr.medicacao} />
<Field label="ALERGIAS" value={tr.alergias} />
<Field label="RESPIRAÇÃO BUCAL" value={tr.respiracaoBucal ? 'SIM' : 'NÃO'} />
<Field label="HÁBITOS" value={tr.habitos} />
{isAdult && (
<>
<div className="col-span-full border-t pt-3 mt-1">
<span className="text-[10px] font-bold text-purple-500 uppercase">CAMPOS ADULTO</span>
</div>
<Field label="PERIODONTO" value={tr.periodonto} />
<Field label="IMPLANTES" value={tr.implantes} />
<Field label="RECESSÕES" value={tr.recessoes} />
<Field label="MOBILIDADE" value={tr.mobilidade} />
</>
)}
</div>
)}
</div>
);
};
/* ================================================================
SECTION 3 DIAGNÓSTICO
================================================================ */
const Diagnosis: React.FC<{ t: TratamentoOrto }> = ({ t }) => {
const [open, setOpen] = useState(false);
const d = t.diagnostico;
if (!d) return null;
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
<button onClick={() => setOpen(!open)} className="w-full px-5 py-3 flex justify-between items-center bg-gray-50 hover:bg-gray-100 transition-colors">
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2"><FileText size={16} className="text-teal-500" /> DIAGNÓSTICO ORTODÔNTICO</h3>
{open ? <ChevronUp size={18} className="text-gray-400" /> : <ChevronDown size={18} className="text-gray-400" />}
</button>
{open && (
<div className="p-5 grid grid-cols-2 md:grid-cols-3 gap-4 text-sm animate-in fade-in">
<Field label="CLASSE ANGLE" value={d.classeAngle} highlight />
<Field label="PERFIL" value={d.perfil} />
<Field label="APINHAMENTO" value={d.apinhamento} highlight={d.apinhamento === 'Severo'} />
<Field label="MORDIDA" value={d.mordida} />
<Field label="LINHA MÉDIA" value={d.linhaMedia} />
</div>
)}
</div>
);
};
/* ================================================================
SECTION 4 PLANO DE TRATAMENTO
================================================================ */
const TreatmentPlan: React.FC<{ t: TratamentoOrto }> = ({ t }) => {
const [open, setOpen] = useState(true);
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
<button onClick={() => setOpen(!open)} className="w-full px-5 py-3 flex justify-between items-center bg-gray-50 hover:bg-gray-100 transition-colors">
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2"><Heart size={16} className="text-pink-500" /> PLANO DE TRATAMENTO</h3>
{open ? <ChevronUp size={18} className="text-gray-400" /> : <ChevronDown size={18} className="text-gray-400" />}
</button>
{open && (
<div className="p-5 space-y-3 text-sm animate-in fade-in">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Field label="APARELHO" value={t.tipoAparelho} highlight />
<Field label="EXTRAÇÕES" value={t.extracoes} />
<Field label="ELÁSTICOS" value={t.elasticos} />
<Field label="IPR PLANEJADO" value={t.iprPlanejado} />
<Field label="TEMPO ESTIMADO" value={t.tempoEstimado} />
{t.valor != null && <Field label="VALOR" value={`R$ ${t.valor.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}`} />}
{t.formaPagamento && <Field label="PAGAMENTO" value={t.formaPagamento} />}
</div>
{t.sequenciaFios && (
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100 mt-2">
<span className="text-[10px] font-bold text-gray-400 uppercase">SEQUÊNCIA DE FIOS</span>
<p className="font-bold text-gray-800 mt-1 tracking-wide">{t.sequenciaFios}</p>
</div>
)}
</div>
)}
</div>
);
};
/* ================================================================
SECTION 5 TIMELINE INTELIGENTE
================================================================ */
const Timeline: React.FC<{ evolucoes: EvolucaoOrto[] }> = ({ evolucoes }) => {
const sorted = [...evolucoes].sort((a, b) => new Date(b.data).getTime() - new Date(a.data).getTime());
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-5">
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2 mb-4">
<Activity size={16} className="text-teal-500" /> LINHA DO TEMPO {evolucoes.length} EVENTO{evolucoes.length !== 1 && 'S'}
</h3>
<div className="space-y-4 relative before:absolute before:left-[7px] before:top-2 before:bottom-2 before:w-0.5 before:bg-gray-200">
{sorted.map((ev) => (
<div key={ev.id} className="relative pl-7">
<div className={`absolute left-0 top-1.5 w-3.5 h-3.5 rounded-full z-10 border-2 ${ev.tipo === 'Falta' ? 'bg-red-500 border-red-300' : ev.tipo === 'Colagem' ? 'bg-green-500 border-green-300' : 'bg-white border-teal-500'}`}></div>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
<div className="flex flex-wrap justify-between items-start gap-2 mb-1">
<div className="flex items-center gap-2">
<span className="font-bold text-gray-900 text-sm">{new Date(ev.data).toLocaleDateString()}</span>
<span className="text-[9px] font-bold bg-gray-200 text-gray-600 px-2 py-0.5 rounded-full uppercase">{ev.tipo}</span>
</div>
<span className="text-[10px] text-teal-600 flex items-center gap-1 font-bold uppercase">
PRÓX: {new Date(ev.proximoRetorno).toLocaleDateString()} <ArrowRight size={10} />
</span>
</div>
<p className="text-xs text-gray-700 uppercase font-medium">{ev.procedimento}</p>
<div className="flex flex-wrap gap-2 mt-2">
{ev.fio && <span className="text-[9px] font-bold bg-teal-50 text-teal-600 px-2 py-0.5 rounded-full uppercase">FIO: {ev.fio}</span>}
{ev.elastico && <span className="text-[9px] font-bold bg-purple-50 text-purple-600 px-2 py-0.5 rounded-full uppercase">ELÁSTICO: {ev.elastico}</span>}
{ev.procedimentos?.map((p, i) => <span key={i} className="text-[9px] font-bold bg-green-50 text-green-600 px-2 py-0.5 rounded-full uppercase">{p}</span>)}
{ev.higiene != null && (
<span className={`text-[9px] font-bold px-2 py-0.5 rounded-full uppercase ${ev.higiene >= 7 ? 'bg-green-50 text-green-600' : ev.higiene >= 5 ? 'bg-amber-50 text-amber-600' : 'bg-red-50 text-red-600'}`}>
HIGIENE: {ev.higiene}/10
</span>
)}
{ev.cooperacao && <span className={`text-[9px] font-bold px-2 py-0.5 rounded-full uppercase ${COOPERATION_COLORS[ev.cooperacao] || ''}`}>COOP: {ev.cooperacao.toUpperCase()}</span>}
</div>
{ev.observacoes && <p className="text-[10px] text-gray-500 mt-2 italic uppercase">{ev.observacoes}</p>}
</div>
</div>
))}
</div>
</div>
);
};
/* ================================================================
SECTION 6 NOVA EVOLUÇÃO
================================================================ */
const NewEvolution: React.FC<{ tratamentoId: string; currentFio?: string; onSaved: () => void }> = ({ tratamentoId, currentFio, onSaved }) => {
const toast = useToast();
const [tipo, setTipo] = useState<TipoEventoOrto>('Manutenção');
const [fio, setFio] = useState(currentFio || '');
const [elastico, setElastico] = useState('');
const [selectedProcs, setSelectedProcs] = useState<string[]>([]);
const [higiene, setHigiene] = useState(7);
const [cooperacao, setCooperacao] = useState<CooperacaoOrto>('Boa');
const [observacoes, setObservacoes] = useState('');
const [proximoRetorno, setProximoRetorno] = useState('');
const toggleProc = (label: string) => {
setSelectedProcs(prev => prev.includes(label) ? prev.filter(p => p !== label) : [...prev, label]);
};
const handleSave = async () => {
const procSummary = [tipo, fio && `FIO ${fio}`, elastico && `ELÁSTICO ${elastico}`, ...selectedProcs].filter(Boolean).join(' + ');
try {
await HybridBackend.saveEvolucaoOrto(tratamentoId, {
tipo,
procedimento: procSummary.toUpperCase(),
proximoRetorno,
fio: fio || undefined,
elastico: elastico || undefined,
procedimentos: selectedProcs.length > 0 ? selectedProcs : undefined,
higiene,
cooperacao,
observacoes: observacoes.toUpperCase() || undefined,
});
toast.success('EVOLUÇÃO SALVA COM SUCESSO!');
// Reset
setTipo('Manutenção');
setElastico('');
setSelectedProcs([]);
setHigiene(7);
setCooperacao('Boa');
setObservacoes('');
setProximoRetorno('');
onSaved();
} catch {
toast.error('ERRO AO SALVAR EVOLUÇÃO.');
}
};
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-5">
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2 mb-4">
<Plus size={16} className="text-green-500" /> NOVA EVOLUÇÃO
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
<div>
<label className="text-[10px] font-bold text-gray-400 uppercase">TIPO EVENTO</label>
<select value={tipo} onChange={e => setTipo(e.target.value as TipoEventoOrto)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select mt-1">
<option>Manutenção</option>
<option>Troca de fio</option>
<option>Colagem</option>
<option>Emergência</option>
<option>Falta</option>
<option>Finalização</option>
</select>
</div>
<div>
<label className="text-[10px] font-bold text-gray-400 uppercase">FIO</label>
<select value={fio} onChange={e => setFio(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select mt-1">
<option value=""> MANTER ATUAL </option>
<option>0.12</option>
<option>0.14</option>
<option>0.16</option>
<option>0.18</option>
<option>0.20x0.25</option>
<option>0.19x0.25</option>
<option>0.21x0.25</option>
</select>
</div>
<div>
<label className="text-[10px] font-bold text-gray-400 uppercase">ELÁSTICO</label>
<select value={elastico} onChange={e => setElastico(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select mt-1">
<option value=""> NENHUM </option>
<option>CLASSE II</option>
<option>CLASSE III</option>
<option>VERTICAL</option>
<option>CRUZADO</option>
</select>
</div>
</div>
{/* Procedure quick buttons */}
<div className="mt-4">
<label className="text-[10px] font-bold text-gray-400 uppercase">PROCEDIMENTOS REALIZADOS</label>
<div className="flex flex-wrap gap-2 mt-2">
{PROCEDURE_BUTTONS.map(pb => (
<button
key={pb.label}
type="button"
onClick={() => toggleProc(pb.label)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-bold uppercase border transition-all ${selectedProcs.includes(pb.label) ? 'bg-green-600 text-white border-green-600 shadow-sm' : 'bg-white text-gray-600 border-gray-300 hover:border-green-400 hover:bg-green-50'}`}
>
{pb.icon} {pb.label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4 text-sm">
<div>
<label className="text-[10px] font-bold text-gray-400 uppercase">HIGIENE (110)</label>
<input type="range" min={1} max={10} value={higiene} onChange={e => setHigiene(Number(e.target.value))} className="w-full mt-2" />
<div className="text-center font-bold text-lg mt-1">{higiene}<span className="text-gray-400 text-xs">/10</span></div>
</div>
<div>
<label className="text-[10px] font-bold text-gray-400 uppercase">COOPERAÇÃO</label>
<select value={cooperacao} onChange={e => setCooperacao(e.target.value as CooperacaoOrto)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select mt-1">
<option>Boa</option>
<option>Média</option>
<option>Ruim</option>
</select>
</div>
<div>
<label className="text-[10px] font-bold text-gray-400 uppercase">PRÓXIMO RETORNO</label>
<input type="date" value={proximoRetorno} onChange={e => setProximoRetorno(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white mt-1" />
</div>
</div>
<div className="mt-4">
<label className="text-[10px] font-bold text-gray-400 uppercase">OBSERVAÇÕES CLÍNICAS</label>
<textarea value={observacoes} onChange={e => setObservacoes(e.target.value.toUpperCase())} placeholder="ANOTAÇÕES LIVRES..." className="w-full border border-gray-300 rounded-lg p-3 mt-1 h-20 uppercase-textarea text-sm" />
</div>
<button onClick={handleSave} className="mt-4 w-full bg-green-600 hover:bg-green-700 text-white py-3 rounded-lg font-bold uppercase text-sm transition-colors shadow-sm flex items-center justify-center gap-2">
<CheckCircle size={16} /> SALVAR EVOLUÇÃO
</button>
</div>
);
};
/* ================================================================
SECTION 7 COMPARATIVO FOTOGRÁFICO
================================================================ */
const PhotoCompare: React.FC<{ t: TratamentoOrto }> = ({ t }) => {
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-5">
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2 mb-4">
<Camera size={16} className="text-emerald-500" /> COMPARATIVO FOTOGRÁFICO
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="border-2 border-dashed border-gray-200 rounded-xl p-8 flex flex-col items-center justify-center text-center bg-gray-50 min-h-[160px]">
<Camera size={32} className="text-gray-300 mb-2" />
<span className="text-xs font-bold text-gray-400 uppercase">INÍCIO</span>
{t.fotos?.find(f => f.tipo === 'inicio')?.data && (
<span className="text-[10px] text-gray-400 mt-1">{new Date(t.fotos.find(f => f.tipo === 'inicio')!.data).toLocaleDateString()}</span>
)}
</div>
<div className="border-2 border-dashed border-gray-200 rounded-xl p-8 flex flex-col items-center justify-center text-center bg-gray-50 min-h-[160px]">
<Camera size={32} className="text-gray-300 mb-2" />
<span className="text-xs font-bold text-gray-400 uppercase">ATUAL</span>
{t.fotos?.find(f => f.tipo === 'atual')?.data && (
<span className="text-[10px] text-gray-400 mt-1">{new Date(t.fotos.find(f => f.tipo === 'atual')!.data).toLocaleDateString()}</span>
)}
</div>
</div>
<button className="mt-4 w-full border-2 border-dashed border-gray-300 hover:border-teal-400 hover:bg-teal-50 py-3 rounded-lg text-xs font-bold text-gray-500 hover:text-teal-600 uppercase transition-colors flex items-center justify-center gap-2">
<Upload size={16} /> UPLOAD FOTOS
</button>
</div>
);
};
/* ================================================================
SECTION 8 FINALIZAÇÃO
================================================================ */
const Finalization: React.FC<{ t: TratamentoOrto; onFinalize: () => void }> = ({ t, onFinalize }) => {
const toast = useToast();
const [checks, setChecks] = useState({ alinhamento: false, oclusao: false, raizes: false, estetica: false });
const [contencaoTipo, setContencaoTipo] = useState('CONTENÇÃO FIXA INFERIOR');
const [superior, setSuperior] = useState(false);
const [inferior, setInferior] = useState(true);
const [tempoUso, setTempoUso] = useState('PERMANENTE');
const allChecked = Object.values(checks).every(Boolean);
const handleFinalize = async () => {
if (!allChecked) {
toast.error('PREENCHA TODOS OS CRITÉRIOS DO CHECKLIST.');
return;
}
try {
await HybridBackend.finalizarTratamentoOrto(t.id, { tipo: contencaoTipo, superior, inferior, tempoUso });
toast.success('TRATAMENTO FINALIZADO COM SUCESSO!');
onFinalize();
} catch {
toast.error('ERRO AO FINALIZAR TRATAMENTO.');
}
};
if (t.status === 'Finalizado') {
return (
<div className="bg-green-50 border border-green-200 rounded-xl p-5 text-center">
<CheckCircle size={32} className="text-green-500 mx-auto mb-2" />
<h3 className="font-bold text-green-700 uppercase">TRATAMENTO FINALIZADO</h3>
{t.contencaoTipo && <p className="text-xs text-green-600 mt-1 uppercase">CONTENÇÃO: {t.contencaoTipo}</p>}
</div>
);
}
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-5">
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2 mb-4">
<ShieldCheck size={16} className="text-green-500" /> FINALIZAÇÃO E CONTENÇÃO
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Checklist */}
<div>
<span className="text-[10px] font-bold text-gray-400 uppercase">CHECKLIST OBRIGATÓRIO</span>
<div className="space-y-2 mt-2">
{Object.entries({ alinhamento: 'ALINHAMENTO OK', oclusao: 'OCLUSÃO OK', raizes: 'RAÍZES PARALELAS', estetica: 'ESTÉTICA ACEITA' }).map(([key, label]) => (
<label key={key} className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={checks[key as keyof typeof checks]} onChange={() => setChecks(prev => ({ ...prev, [key]: !prev[key as keyof typeof checks] }))} className="w-4 h-4 rounded border-gray-300 text-green-600" />
<span className="font-bold uppercase text-gray-700">{label}</span>
</label>
))}
</div>
</div>
{/* Contenção */}
<div className="space-y-3">
<div>
<label className="text-[10px] font-bold text-gray-400 uppercase">TIPO CONTENÇÃO</label>
<select value={contencaoTipo} onChange={e => setContencaoTipo(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select mt-1 text-sm">
<option>CONTENÇÃO FIXA INFERIOR</option>
<option>CONTENÇÃO FIXA SUPERIOR E INFERIOR</option>
<option>PLACA DE CONTENÇÃO REMOVÍVEL</option>
<option>ESSIX (ALINHADOR)</option>
</select>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={superior} onChange={() => setSuperior(!superior)} className="w-4 h-4 rounded" />
<span className="font-bold uppercase text-gray-700">SUPERIOR</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={inferior} onChange={() => setInferior(!inferior)} className="w-4 h-4 rounded" />
<span className="font-bold uppercase text-gray-700">INFERIOR</span>
</label>
</div>
<div>
<label className="text-[10px] font-bold text-gray-400 uppercase">TEMPO DE USO</label>
<select value={tempoUso} onChange={e => setTempoUso(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select mt-1 text-sm">
<option>PERMANENTE</option>
<option>6 MESES</option>
<option>12 MESES</option>
<option>24 MESES</option>
</select>
</div>
</div>
</div>
<button onClick={handleFinalize} disabled={!allChecked} className={`mt-5 w-full py-3 rounded-lg font-bold uppercase text-sm transition-colors flex items-center justify-center gap-2 ${allChecked ? 'bg-green-600 hover:bg-green-700 text-white shadow-sm' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}>
<CheckCircle size={16} /> FINALIZAR TRATAMENTO
</button>
</div>
);
};
/* ================================================================
SECTION 9 CONTENÇÃO — TERMO DE CIÊNCIA + VALOR
================================================================ */
const ContencaoTermo: React.FC<{ t: TratamentoOrto }> = ({ t }) => {
const toast = useToast();
const [valorContencao, setValorContencao] = useState(350);
const [aceite, setAceite] = useState(false);
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-5">
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2 mb-4">
<ClipboardCheck size={16} className="text-teal-500" /> CONTENÇÃO TERMO E VALOR
</h3>
{/* Termo de ciência */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-4">
<div className="flex items-start gap-2">
<AlertCircle size={18} className="text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-xs font-bold text-amber-700 uppercase mb-1">TERMO DE CIÊNCIA</p>
<p className="text-xs text-amber-800 leading-relaxed">
O PACIENTE DECLARA ESTAR CIENTE DE QUE A <strong>CONTENÇÃO ORTODÔNTICA</strong> (FIXA E/OU REMOVÍVEL)
É UM PROCEDIMENTO <strong>SEPARADO DO TRATAMENTO ORTODÔNTICO</strong> E <strong>NÃO ESTÁ INCLUÍDA NO VALOR
DA MANUTENÇÃO MENSAL</strong>. O CUSTO DA CONTENÇÃO SERÁ COBRADO À PARTE, CONFORME VALORES
ABAIXO INFORMADOS.
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-[10px] font-bold text-gray-400 uppercase">VALOR DA CONTENÇÃO (R$)</label>
<input
type="number"
value={valorContencao}
onChange={e => setValorContencao(Number(e.target.value))}
className="w-full border border-gray-300 rounded-lg p-2.5 bg-white mt-1 text-sm font-bold"
min={0}
step={50}
/>
<p className="text-[10px] text-gray-400 mt-1">VALOR COBRADO SEPARADAMENTE DA MENSALIDADE</p>
</div>
<div className="flex flex-col justify-end">
<label className="flex items-start gap-2 cursor-pointer bg-gray-50 border border-gray-200 rounded-lg p-3">
<input
type="checkbox"
checked={aceite}
onChange={() => setAceite(!aceite)}
className="w-4 h-4 rounded border-gray-300 text-teal-600 mt-0.5"
/>
<span className="text-xs font-bold uppercase text-gray-700 leading-relaxed">
PACIENTE CIENTE E DE ACORDO COM A COBRANÇA SEPARADA DA CONTENÇÃO
</span>
</label>
</div>
</div>
{aceite && (
<div className="mt-3 bg-green-50 border border-green-200 rounded-lg p-3 flex items-center gap-2">
<CheckCircle size={16} className="text-green-500" />
<span className="text-xs font-bold text-green-700 uppercase">TERMO ACEITO R$ {valorContencao.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</span>
</div>
)}
</div>
);
};
/* ================================================================
SECTION 10 SOLICITAÇÕES CLÍNICAS (LIMPEZA, CÁRIE, EXTRAÇÃO, RASPAGEM)
================================================================ */
const ClinicalRequests: React.FC<{ t: TratamentoOrto }> = ({ t }) => {
const toast = useToast();
const [requests, setRequests] = useState<{ tipo: string; dente?: string; obs?: string }[]>([]);
const [novoTipo, setNovoTipo] = useState('LIMPEZA / PROFILAXIA');
const [novoDente, setNovoDente] = useState('');
const [novoObs, setNovoObs] = useState('');
const REQUEST_TYPES = [
'LIMPEZA / PROFILAXIA',
'DENTE COM CÁRIE',
'DENTE PARA EXTRAIR',
'RASPAGEM PERIODONTAL',
];
const addRequest = () => {
setRequests(prev => [
...prev,
{ tipo: novoTipo, dente: novoDente.toUpperCase() || undefined, obs: novoObs.toUpperCase() || undefined }
]);
setNovoDente('');
setNovoObs('');
toast.success('SOLICITAÇÃO ADICIONADA!');
};
const removeRequest = (index: number) => {
setRequests(prev => prev.filter((_, i) => i !== index));
};
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-5">
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2 mb-4">
<Stethoscope size={16} className="text-red-500" /> SOLICITAÇÕES CLÍNICAS
</h3>
{/* Lista de solicitações já adicionadas */}
{requests.length > 0 && (
<div className="space-y-2 mb-4">
{requests.map((req, i) => (
<div key={i} className="flex items-center justify-between bg-gray-50 border border-gray-200 rounded-lg p-3">
<div className="flex items-center gap-3">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${req.tipo.includes('CÁRIE') ? 'bg-red-500' :
req.tipo.includes('EXTRAIR') ? 'bg-teal-500' :
req.tipo.includes('RASPAGEM') ? 'bg-purple-500' : 'bg-teal-500'
}`}></span>
<div>
<span className="text-xs font-bold text-gray-800 uppercase">{req.tipo}</span>
{req.dente && <span className="text-[10px] text-gray-500 ml-2">DENTE: {req.dente}</span>}
{req.obs && <p className="text-[10px] text-gray-400 italic">{req.obs}</p>}
</div>
</div>
<button onClick={() => removeRequest(i)} className="text-gray-400 hover:text-red-500 transition-colors">
<X size={16} />
</button>
</div>
))}
</div>
)}
{/* Formulário para nova solicitação */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="text-[10px] font-bold text-gray-400 uppercase">TIPO</label>
<select value={novoTipo} onChange={e => setNovoTipo(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white mt-1 text-sm">
{REQUEST_TYPES.map(rt => <option key={rt}>{rt}</option>)}
</select>
</div>
<div>
<label className="text-[10px] font-bold text-gray-400 uppercase">DENTE (OPCIONAL)</label>
<input
type="text"
value={novoDente}
onChange={e => setNovoDente(e.target.value)}
placeholder="EX: 36, 14"
className="w-full border border-gray-300 rounded-lg p-2.5 bg-white mt-1 text-sm uppercase"
/>
</div>
<div>
<label className="text-[10px] font-bold text-gray-400 uppercase">OBSERVAÇÃO</label>
<input
type="text"
value={novoObs}
onChange={e => setNovoObs(e.target.value)}
placeholder="DETALHES..."
className="w-full border border-gray-300 rounded-lg p-2.5 bg-white mt-1 text-sm uppercase"
/>
</div>
</div>
<button onClick={addRequest} className="mt-3 w-full border-2 border-dashed border-gray-300 hover:border-teal-400 hover:bg-teal-50 py-2.5 rounded-lg text-xs font-bold text-gray-500 hover:text-teal-600 uppercase transition-colors flex items-center justify-center gap-2">
<Plus size={14} /> ADICIONAR SOLICITAÇÃO
</button>
</div>
);
};
/* ================================================================
SECTION 11 RELATÓRIO DE MANUTENÇÕES (GERAR + EXPORTAR)
================================================================ */
const MaintenanceReport: React.FC<{ t: TratamentoOrto }> = ({ t }) => {
const toast = useToast();
const generateReport = () => {
const sorted = [...t.evolucoes].sort((a, b) => new Date(a.data).getTime() - new Date(b.data).getTime());
const lines: string[] = [
'════════════════════════════════════════════════════════',
' RELATÓRIO DE MANUTENÇÕES ORTODÔNTICAS',
'════════════════════════════════════════════════════════',
'',
`PACIENTE: ${t.pacienteNome}`,
`APARELHO: ${t.tipoAparelho}`,
`INÍCIO: ${new Date(t.dataInicio).toLocaleDateString()}`,
`STATUS: ${t.status}`,
`FASE ATUAL: ${t.faseAtual || '—'}`,
`FIO ATUAL: ${t.fioAtual || '—'}`,
`PROGRESSO: ${t.percentualConcluido ?? '—'}%`,
'',
'────────────────────────────────────────────────────────',
` TOTAL DE ATENDIMENTOS: ${sorted.length}`,
'────────────────────────────────────────────────────────',
'',
];
sorted.forEach((ev, i) => {
lines.push(` #${i + 1} | ${new Date(ev.data).toLocaleDateString()} | ${ev.tipo}`);
lines.push(` ${ev.procedimento}`);
if (ev.fio) lines.push(` FIO: ${ev.fio}`);
if (ev.elastico) lines.push(` ELÁSTICO: ${ev.elastico}`);
if (ev.procedimentos?.length) lines.push(` PROC: ${ev.procedimentos.join(', ')}`);
if (ev.higiene != null) lines.push(` HIGIENE: ${ev.higiene}/10`);
if (ev.cooperacao) lines.push(` COOPERAÇÃO: ${ev.cooperacao}`);
if (ev.observacoes) lines.push(` OBS: ${ev.observacoes}`);
lines.push(` PRÓXIMO RETORNO: ${new Date(ev.proximoRetorno).toLocaleDateString()}`);
lines.push('');
});
lines.push('────────────────────────────────────────────────────────');
lines.push(` GERADO EM: ${new Date().toLocaleString()}`);
lines.push(' SCOREODONTO — GESTÃO ORTODÔNTICA');
lines.push('════════════════════════════════════════════════════════');
return lines.join('\n');
};
const handleExport = () => {
const content = generateReport();
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `RELATORIO_ORTO_${t.pacienteNome.replace(/\s+/g, '_')}_${new Date().toISOString().split('T')[0]}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('RELATÓRIO EXPORTADO COM SUCESSO!');
};
const handleCopy = () => {
const content = generateReport();
navigator.clipboard.writeText(content).then(() => {
toast.success('RELATÓRIO COPIADO PARA ÁREA DE TRANSFERÊNCIA!');
});
};
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-5">
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2 mb-4">
<FileDown size={16} className="text-teal-500" /> RELATÓRIO DE MANUTENÇÕES
</h3>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-center">
<div>
<p className="text-2xl font-bold text-gray-800">{t.evolucoes.length}</p>
<p className="text-[10px] font-bold text-gray-400 uppercase">ATENDIMENTOS</p>
</div>
<div>
<p className="text-2xl font-bold text-gray-800">{new Date(t.dataInicio).toLocaleDateString()}</p>
<p className="text-[10px] font-bold text-gray-400 uppercase">INÍCIO</p>
</div>
<div>
<p className="text-2xl font-bold text-gray-800">{t.percentualConcluido ?? '—'}%</p>
<p className="text-[10px] font-bold text-gray-400 uppercase">PROGRESSO</p>
</div>
<div>
<p className="text-2xl font-bold text-gray-800">{t.mesesRestantes ?? '—'}</p>
<p className="text-[10px] font-bold text-gray-400 uppercase">MESES REST.</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<button onClick={handleExport} className="bg-teal-600 hover:bg-teal-700 text-white py-3 rounded-lg font-bold uppercase text-sm transition-colors shadow-sm flex items-center justify-center gap-2">
<FileDown size={16} /> EXPORTAR RELATÓRIO (.TXT)
</button>
<button onClick={handleCopy} className="bg-gray-600 hover:bg-gray-700 text-white py-3 rounded-lg font-bold uppercase text-sm transition-colors shadow-sm flex items-center justify-center gap-2">
<ClipboardCheck size={16} /> COPIAR PARA ÁREA DE TRANSFERÊNCIA
</button>
</div>
</div>
);
};
/* ================================================================
REUSABLE: Campo label + value
================================================================ */
const Field: React.FC<{ label: string; value?: string | null; highlight?: boolean }> = ({ label, value, highlight }) => (
<div>
<span className="text-[10px] font-bold text-gray-400 uppercase">{label}</span>
<p className={`font-bold uppercase mt-0.5 ${highlight ? 'text-teal-600' : 'text-gray-800'}`}>{value || '—'}</p>
</div>
);
/* ================================================================
DETAIL PANEL — modal fullscreen com todas as seções
================================================================ */
const OrthoDetailPanel: React.FC<{ treatment: TratamentoOrto; onClose: () => void }> = ({ treatment, onClose }) => {
const [t, setT] = useState(treatment);
const clinicaId = HybridBackend.getActiveWorkspace()?.id;
const { refresh: refreshList } = useHybridBackend<TratamentoOrto[]>(HybridBackend.getOrtoTreatments);
const handleEvolucaoSaved = useCallback(() => {
// Re-fetch to update local state
HybridBackend.getOrtoTreatments().then(all => {
const updated = all.find(tr => tr.id === t.id);
if (updated) setT(updated);
});
}, [t.id]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-0">
<div className="bg-gray-50 shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-xl flex flex-col overflow-hidden animate-in fade-in zoom-in-50 duration-200">
{/* Top bar */}
<div className="px-5 py-3 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-white">
<button onClick={onClose} className="flex items-center gap-2 text-gray-500 hover:text-gray-800 text-xs font-bold uppercase transition-colors">
<ArrowLeft size={16} /> VOLTAR À LISTA
</button>
<div className="flex items-center gap-2">
<span className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase ${t.status === 'Finalizado' ? 'bg-green-100 text-green-700' : t.status === 'Contenção' ? 'bg-blue-100 text-blue-700' : 'bg-amber-100 text-amber-700'}`}>
{t.status}
</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={20} /></button>
</div>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto p-4 lg:p-6 space-y-5">
<PatientHeader t={t} />
<ClinicalTriage t={t} />
<Diagnosis t={t} />
<TreatmentPlan t={t} />
<Timeline evolucoes={t.evolucoes} />
<NewEvolution tratamentoId={t.id} currentFio={t.fioAtual} onSaved={handleEvolucaoSaved} />
<OrthoPhotoWorkspace tratamentoId={t.id} clinicaId={clinicaId} pacienteNome={t.pacienteNome} />
<TutoriaSection tratamentoId={t.id} clinicaId={clinicaId} pacienteNome={t.pacienteNome} />
<ContencaoTermo t={t} />
<ClinicalRequests t={t} />
<MaintenanceReport t={t} />
<Finalization t={t} onFinalize={() => { refreshList(); onClose(); }} />
</div>
</div>
</div>
);
};
/* ================================================================
MAIN VIEW — ORTHOVIEW (preservando listagem original)
================================================================ */
/* ================================================================
NOVO TRATAMENTO — modal de criação
================================================================ */
const STATUS_OPTS = ['Em Tratamento', 'Contenção', 'Finalizado'];
const NovoTratamentoModal: React.FC<{ onClose: () => void; onCreated: () => void }> = ({ onClose, onCreated }) => {
const toast = useToast();
const [salvando, setSalvando] = useState(false);
const [f, setF] = useState<any>({
pacienteId: '', pacienteNome: '', dataNascimento: '', responsavel: '',
dataInicio: new Date().toISOString().slice(0, 10), tipoAparelho: '', status: 'Em Tratamento',
faseAtual: '', fioAtual: '', cooperacao: 'Boa', tempoEstimado: '', valor: '', formaPagamento: '',
extracoes: '', sequenciaFios: '', elasticos: '', iprPlanejado: '',
classeAngle: '', perfil: '', apinhamento: '', mordida: '', linhaMedia: '',
queixaPrincipal: '', historicoMedico: '', medicacao: '', alergias: '', habitos: '', respiracaoBucal: false,
});
const set = (k: string, v: any) => setF((p: any) => ({ ...p, [k]: v }));
const up = (v: string) => v?.toUpperCase() || undefined;
// Busca de paciente no cadastro (vincula paciente_id; digitar livre = paciente avulso)
const [resultados, setResultados] = useState<Paciente[]>([]);
const [buscando, setBuscando] = useState(false);
const [showRes, setShowRes] = useState(false);
const [criandoPaciente, setCriandoPaciente] = useState(false);
const [pacInfo, setPacInfo] = useState<{ cpf?: string; telefone?: string } | null>(null);
useEffect(() => {
const term = f.pacienteNome.trim();
if (f.pacienteId || term.length < 2) { setResultados([]); return; }
let active = true;
setBuscando(true);
const h = setTimeout(async () => {
try {
const r = await HybridBackend.getPacientes(term);
if (active) { setResultados((r.data || []).slice(0, 8)); setShowRes(true); }
} catch { /* ignore */ }
finally { if (active) setBuscando(false); }
}, 350);
return () => { active = false; clearTimeout(h); };
}, [f.pacienteNome, f.pacienteId]);
const selectPaciente = (p: Paciente) => {
setF((prev: any) => ({ ...prev, pacienteId: p.id, pacienteNome: p.nome, dataNascimento: p.dataNascimento || prev.dataNascimento }));
setPacInfo({ cpf: p.cpf, telefone: p.telefone });
setResultados([]); setShowRes(false);
};
// Cria um paciente mínimo (só nome) na base da clínica e já vincula ao tratamento.
const criarPaciente = async () => {
const nome = f.pacienteNome.trim();
if (nome.length < 2) return;
setCriandoPaciente(true);
try {
const res = await HybridBackend.savePaciente({ nome });
if (res?.success && res.data?.id) {
setF((prev: any) => ({ ...prev, pacienteId: res.data.id, pacienteNome: nome.toUpperCase() }));
setResultados([]); setShowRes(false);
toast.success('PACIENTE CADASTRADO E VINCULADO!');
} else { toast.error('ERRO AO CADASTRAR PACIENTE.'); }
} catch { toast.error('ERRO AO CADASTRAR PACIENTE.'); }
finally { setCriandoPaciente(false); }
};
const submit = async () => {
if (!f.pacienteNome.trim()) { toast.error('NOME DO PACIENTE É OBRIGATÓRIO.'); return; }
if (!f.tipoAparelho.trim()) { toast.error('TIPO DE APARELHO É OBRIGATÓRIO.'); return; }
setSalvando(true);
try {
const payload: Partial<TratamentoOrto> = {
pacienteId: f.pacienteId || undefined,
pacienteNome: f.pacienteNome.toUpperCase(),
dataNascimento: f.dataNascimento || undefined,
responsavel: up(f.responsavel),
dataInicio: f.dataInicio,
tipoAparelho: f.tipoAparelho.toUpperCase(),
status: f.status,
faseAtual: up(f.faseAtual),
fioAtual: f.fioAtual || undefined,
cooperacao: f.cooperacao || undefined,
tempoEstimado: up(f.tempoEstimado),
valor: f.valor ? Number(f.valor) : undefined,
formaPagamento: up(f.formaPagamento),
extracoes: up(f.extracoes),
sequenciaFios: f.sequenciaFios || undefined,
elasticos: up(f.elasticos),
iprPlanejado: up(f.iprPlanejado),
diagnostico: (f.classeAngle || f.perfil || f.apinhamento || f.mordida || f.linhaMedia) ? {
classeAngle: f.classeAngle || undefined, perfil: f.perfil || undefined,
apinhamento: f.apinhamento || undefined, mordida: f.mordida || undefined, linhaMedia: up(f.linhaMedia),
} : undefined,
triagem: (f.queixaPrincipal || f.historicoMedico || f.medicacao || f.alergias || f.habitos || f.respiracaoBucal) ? {
queixaPrincipal: up(f.queixaPrincipal), historicoMedico: up(f.historicoMedico), medicacao: up(f.medicacao),
alergias: up(f.alergias), habitos: up(f.habitos), respiracaoBucal: !!f.respiracaoBucal,
} : undefined,
};
const res = await HybridBackend.createTratamentoOrto(payload);
if (!res.success) { toast.error(res.message || 'ERRO AO CRIAR TRATAMENTO.'); return; }
toast.success('TRATAMENTO CRIADO!');
onCreated();
} catch { toast.error('ERRO AO CRIAR TRATAMENTO.'); }
finally { setSalvando(false); }
};
const inp = 'w-full border border-gray-300 rounded-lg p-2.5 bg-white mt-1 text-sm';
const lbl = 'text-[10px] font-bold text-gray-400 uppercase';
return (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl max-h-[92vh] flex flex-col">
<div className="px-5 py-4 border-b flex items-center justify-between flex-shrink-0">
<h3 className="font-bold text-gray-800 uppercase text-sm flex items-center gap-2"><Plus size={16} className="text-teal-600" /> NOVO TRATAMENTO ORTODÔNTICO</h3>
<button onClick={onClose} className="p-1.5 hover:bg-gray-100 rounded-lg"><X size={18} /></button>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-6">
<section>
<p className="text-[11px] font-black text-purple-500 uppercase mb-2">PACIENTE</p>
<div className="relative mb-3">
<label className={lbl}>
PACIENTE *{' '}
{f.pacienteId
? <span className="text-green-600"> VINCULADO AO CADASTRO</span>
: <span className="text-gray-300 normal-case">(busca por nome, CPF ou telefone ou digite um nome avulso)</span>}
</label>
<input
className={inp}
value={f.pacienteNome}
onChange={e => { set('pacienteNome', e.target.value); set('pacienteId', ''); setPacInfo(null); }}
onFocus={() => { if (resultados.length) setShowRes(true); }}
placeholder="BUSCAR POR NOME, CPF OU TELEFONE"
autoComplete="off"
/>
{buscando && <span className="absolute right-3 top-9 text-[9px] text-gray-400 uppercase">buscando</span>}
{!buscando && f.pacienteId && (
<button type="button" onClick={() => { set('pacienteId', ''); setPacInfo(null); }} className="absolute right-3 top-9 text-[9px] font-bold text-gray-400 hover:text-red-500 uppercase">desvincular</button>
)}
{f.pacienteId && pacInfo && (pacInfo.cpf || pacInfo.telefone) && (
<p className="text-[10px] text-gray-400 mt-1 ml-1">
{pacInfo.cpf && <>CPF: <strong className="text-gray-600">{pacInfo.cpf}</strong></>}
{pacInfo.cpf && pacInfo.telefone && ' · '}
{pacInfo.telefone && <>TEL: <strong className="text-gray-600">{pacInfo.telefone}</strong></>}
</p>
)}
{showRes && !f.pacienteId && f.pacienteNome.trim().length >= 2 && (
<div className="absolute z-10 left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-56 overflow-y-auto">
{resultados.map(p => (
<button key={p.id} type="button" onClick={() => selectPaciente(p)} className="w-full text-left px-3 py-2 hover:bg-teal-50 text-xs border-b border-gray-50">
<span className="font-bold text-gray-800 uppercase">{p.nome}</span>
{p.cpf && <span className="text-gray-400 ml-2">CPF {p.cpf}</span>}
{p.telefone && <span className="text-gray-400 ml-2">TEL {p.telefone}</span>}
{p.dataNascimento && <span className="text-gray-300 ml-2">{new Date(p.dataNascimento).toLocaleDateString()}</span>}
</button>
))}
{!buscando && resultados.length === 0 && (
<p className="px-3 py-2 text-[10px] text-gray-400 uppercase">Nenhum paciente encontrado na base.</p>
)}
<button type="button" onClick={criarPaciente} disabled={criandoPaciente}
className="w-full text-left px-3 py-2.5 hover:bg-emerald-50 text-xs text-emerald-700 font-black flex items-center gap-2 border-t border-gray-100 disabled:opacity-50">
<Plus size={13} /> {criandoPaciente ? 'CADASTRANDO…' : `Cadastrar "${f.pacienteNome.trim().toUpperCase()}" como novo paciente`}
</button>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div><label className={lbl}>DATA NASCIMENTO</label><input type="date" className={inp} value={f.dataNascimento} onChange={e => set('dataNascimento', e.target.value)} /></div>
<div><label className={lbl}>RESPONSÁVEL</label><input className={inp} value={f.responsavel} onChange={e => set('responsavel', e.target.value)} /></div>
</div>
</section>
<section>
<p className="text-[11px] font-black text-pink-500 uppercase mb-2">PLANO DE TRATAMENTO</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div><label className={lbl}>DATA INÍCIO *</label><input type="date" className={inp} value={f.dataInicio} onChange={e => set('dataInicio', e.target.value)} /></div>
<div><label className={lbl}>TIPO APARELHO *</label><input className={inp} value={f.tipoAparelho} onChange={e => set('tipoAparelho', e.target.value)} placeholder="AUTOLIGADO METÁLICO" /></div>
<div><label className={lbl}>STATUS</label><select className={inp} value={f.status} onChange={e => set('status', e.target.value)}>{STATUS_OPTS.map(s => <option key={s}>{s}</option>)}</select></div>
<div><label className={lbl}>FASE ATUAL</label><input className={inp} value={f.faseAtual} onChange={e => set('faseAtual', e.target.value)} placeholder="NIVELAMENTO" /></div>
<div><label className={lbl}>FIO ATUAL</label><input className={inp} value={f.fioAtual} onChange={e => set('fioAtual', e.target.value)} placeholder="0.16" /></div>
<div><label className={lbl}>COOPERAÇÃO</label><select className={inp} value={f.cooperacao} onChange={e => set('cooperacao', e.target.value)}><option>Boa</option><option>Média</option><option>Ruim</option></select></div>
<div><label className={lbl}>TEMPO ESTIMADO</label><input className={inp} value={f.tempoEstimado} onChange={e => set('tempoEstimado', e.target.value)} placeholder="1824 MESES" /></div>
<div><label className={lbl}>VALOR (R$)</label><input type="number" className={inp} value={f.valor} onChange={e => set('valor', e.target.value)} /></div>
<div><label className={lbl}>FORMA PAGAMENTO</label><input className={inp} value={f.formaPagamento} onChange={e => set('formaPagamento', e.target.value)} placeholder="PARCELADO 24X" /></div>
<div><label className={lbl}>EXTRAÇÕES</label><input className={inp} value={f.extracoes} onChange={e => set('extracoes', e.target.value)} /></div>
<div className="md:col-span-2"><label className={lbl}>SEQUÊNCIA DE FIOS</label><input className={inp} value={f.sequenciaFios} onChange={e => set('sequenciaFios', e.target.value)} placeholder="0.12 → 0.14 → 0.16" /></div>
<div><label className={lbl}>ELÁSTICOS</label><input className={inp} value={f.elasticos} onChange={e => set('elasticos', e.target.value)} /></div>
<div><label className={lbl}>IPR PLANEJADO</label><input className={inp} value={f.iprPlanejado} onChange={e => set('iprPlanejado', e.target.value)} /></div>
</div>
</section>
<section>
<p className="text-[11px] font-black text-teal-500 uppercase mb-2">DIAGNÓSTICO</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<div><label className={lbl}>CLASSE ANGLE</label><select className={inp} value={f.classeAngle} onChange={e => set('classeAngle', e.target.value)}><option value=""></option><option>I</option><option>II</option><option>III</option></select></div>
<div><label className={lbl}>PERFIL</label><select className={inp} value={f.perfil} onChange={e => set('perfil', e.target.value)}><option value=""></option><option>Reto</option><option>Convexo</option><option>Côncavo</option></select></div>
<div><label className={lbl}>APINHAMENTO</label><select className={inp} value={f.apinhamento} onChange={e => set('apinhamento', e.target.value)}><option value=""></option><option>Leve</option><option>Moderado</option><option>Severo</option></select></div>
<div><label className={lbl}>MORDIDA</label><select className={inp} value={f.mordida} onChange={e => set('mordida', e.target.value)}><option value=""></option><option>Normal</option><option>Aberta</option><option>Profunda</option><option>Cruzada</option></select></div>
<div><label className={lbl}>LINHA MÉDIA</label><input className={inp} value={f.linhaMedia} onChange={e => set('linhaMedia', e.target.value)} placeholder="1MM ESQ" /></div>
</div>
</section>
<section>
<p className="text-[11px] font-black text-emerald-500 uppercase mb-2">TRIAGEM</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div><label className={lbl}>QUEIXA PRINCIPAL</label><input className={inp} value={f.queixaPrincipal} onChange={e => set('queixaPrincipal', e.target.value)} /></div>
<div><label className={lbl}>HISTÓRICO MÉDICO</label><input className={inp} value={f.historicoMedico} onChange={e => set('historicoMedico', e.target.value)} /></div>
<div><label className={lbl}>MEDICAÇÃO</label><input className={inp} value={f.medicacao} onChange={e => set('medicacao', e.target.value)} /></div>
<div><label className={lbl}>ALERGIAS</label><input className={inp} value={f.alergias} onChange={e => set('alergias', e.target.value)} /></div>
<div><label className={lbl}>HÁBITOS</label><input className={inp} value={f.habitos} onChange={e => set('habitos', e.target.value)} /></div>
<label className="flex items-center gap-2 mt-6 text-sm cursor-pointer"><input type="checkbox" checked={f.respiracaoBucal} onChange={e => set('respiracaoBucal', e.target.checked)} className="w-4 h-4" /><span className="font-bold uppercase text-gray-700 text-xs">RESPIRAÇÃO BUCAL</span></label>
</div>
</section>
</div>
<div className="px-5 py-4 border-t flex gap-3 flex-shrink-0">
<button onClick={onClose} className="flex-1 py-2.5 border border-gray-200 rounded-lg text-xs font-bold uppercase hover:bg-gray-50">CANCELAR</button>
<button onClick={submit} disabled={salvando} className="flex-1 py-2.5 bg-teal-600 hover:bg-teal-700 text-white rounded-lg text-xs font-bold uppercase disabled:opacity-40 flex items-center justify-center gap-2">{salvando ? 'SALVANDO...' : <><Plus size={14} /> CRIAR TRATAMENTO</>}</button>
</div>
</div>
</div>
);
};
export const OrthoView: React.FC = () => {
const { data: treatments, refresh } = useHybridBackend<TratamentoOrto[]>(HybridBackend.getOrtoTreatments);
const [selectedTreatment, setSelectedTreatment] = useState<TratamentoOrto | null>(null);
const [showNovo, setShowNovo] = useState(false);
return (
<div className="space-y-6">
<PageHeader title="GESTÃO ORTODÔNTICA">
<button onClick={() => setShowNovo(true)} className="bg-teal-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-teal-700 text-xs font-bold uppercase transition-colors shadow-sm">
<Plus size={18} /> NOVO TRATAMENTO
</button>
</PageHeader>
{treatments && treatments.length === 0 && (
<div className="bg-white border border-dashed border-gray-300 rounded-xl p-10 text-center">
<Activity size={32} className="text-gray-300 mx-auto mb-3" />
<p className="text-sm font-bold text-gray-500 uppercase">NENHUM TRATAMENTO ORTODÔNTICO</p>
<p className="text-xs text-gray-400 mt-1">CLIQUE EM "NOVO TRATAMENTO" PARA COMEÇAR.</p>
</div>
)}
<div className="grid grid-cols-1 gap-6">
{treatments?.map(t => (
<div
key={t.id}
className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow cursor-pointer"
onClick={() => setSelectedTreatment(t)}
>
<div className="p-4 border-b border-gray-100 bg-gray-50 flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center font-bold text-sm">
{t.pacienteNome.substring(0, 2).toUpperCase()}
</div>
<div>
<h3 className="font-bold text-gray-900 uppercase">{t.pacienteNome}</h3>
<p className="text-[10px] text-gray-500 uppercase font-bold">
{t.tipoAparelho} INÍCIO: {new Date(t.dataInicio).toLocaleDateString()}
{t.faseAtual && ` • FASE: ${t.faseAtual}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{t.percentualConcluido != null && (
<div className="hidden md:flex items-center gap-2">
<div className="w-24 bg-gray-200 rounded-full h-1.5">
<div className="bg-teal-500 h-1.5 rounded-full" style={{ width: `${t.percentualConcluido}%` }}></div>
</div>
<span className="text-[10px] font-bold text-gray-500">{t.percentualConcluido}%</span>
</div>
)}
{t.cooperacao && <span className={`text-[10px] font-bold px-2 py-0.5 rounded-full uppercase hidden lg:inline-block ${COOPERATION_COLORS[t.cooperacao] || ''}`}>{t.cooperacao}</span>}
<span className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase ${t.status === 'Finalizado' ? 'bg-green-100 text-green-700' : t.status === 'Contenção' ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}`}>
{t.status}
</span>
</div>
</div>
{/* Preview timeline */}
<div className="p-4">
<h4 className="text-xs font-bold text-gray-500 mb-3 flex items-center gap-2 uppercase">
<Activity size={14} /> ÚLTIMAS EVOLUÇÕES
</h4>
<div className="space-y-2">
{t.evolucoes.slice(-2).map((ev) => (
<div key={ev.id} className="flex items-center gap-3 text-xs">
<div className="w-2 h-2 bg-teal-500 rounded-full flex-shrink-0"></div>
<span className="font-bold text-gray-700">{new Date(ev.data).toLocaleDateString()}</span>
<span className="text-gray-500 truncate uppercase">{ev.procedimento}</span>
</div>
))}
</div>
{/* Flags preview */}
{t.flags && t.flags.length > 0 && (
<div className="flex gap-2 mt-3 pt-3 border-t border-gray-100">
{t.flags.map((f, i) => {
const s = FLAG_STYLES[f.tipo] || FLAG_STYLES.info;
return (
<span key={i} className={`text-[9px] font-bold px-2 py-1 rounded-lg border uppercase ${s.bg} ${s.text}`}>
{f.mensagem}
</span>
);
})}
</div>
)}
</div>
</div>
))}
</div>
{/* Detail panel */}
{selectedTreatment && (
<OrthoDetailPanel
treatment={selectedTreatment}
onClose={() => { setSelectedTreatment(null); refresh(); }}
/>
)}
{/* Novo tratamento */}
{showNovo && (
<NovoTratamentoModal
onClose={() => setShowNovo(false)}
onCreated={() => { setShowNovo(false); refresh(); }}
/>
)}
</div>
);
};