Files
clube67_newwhats.local/base-scoreodonto/scoreodonto/views/OrthoView.tsx
T
VPS 4 Deploy Agent 5ec6bd6354
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
chore(ops): migrate clube67 to newwhats.clube67.com directory
2026-05-18 03:26:41 +02:00

958 lines
49 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 } from '../types.ts';
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { PageHeader } from '../components/PageHeader.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-blue-100 text-blue-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-blue-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-blue-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-blue-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-blue-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-blue-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-blue-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-blue-50 text-blue-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-indigo-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-blue-400 hover:bg-blue-50 py-3 rounded-lg text-xs font-bold text-gray-500 hover:text-blue-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-orange-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-orange-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-orange-500' :
req.tipo.includes('RASPAGEM') ? 'bg-purple-500' : 'bg-blue-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-blue-400 hover:bg-blue-50 py-2.5 rounded-lg text-xs font-bold text-gray-500 hover:text-blue-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-blue-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 { 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-2 lg:p-0">
<div className="bg-gray-50 rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[94vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
{/* 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} />
<PhotoCompare t={t} />
<ContencaoTermo t={t} />
<ClinicalRequests t={t} />
<MaintenanceReport t={t} />
<Finalization t={t} onFinalize={() => { refreshList(); onClose(); }} />
</div>
</div>
</div>
);
};
/* ================================================================
MAIN VIEW — ORTHOVIEW (preservando listagem original)
================================================================ */
export const OrthoView: React.FC = () => {
const { data: treatments, refresh } = useHybridBackend<TratamentoOrto[]>(HybridBackend.getOrtoTreatments);
const [selectedTreatment, setSelectedTreatment] = useState<TratamentoOrto | null>(null);
return (
<div className="space-y-6">
<PageHeader title="GESTÃO ORTODÔNTICA">
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 text-xs font-bold uppercase transition-colors shadow-sm">
<Plus size={18} /> NOVO TRATAMENTO
</button>
</PageHeader>
<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-blue-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-blue-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(); }}
/>
)}
</div>
);
};