feat: migrate ScoreOdonto CRM to Docker, PostgreSQL, and DragonflyDB
This commit is contained in:
@@ -0,0 +1,958 @@
|
||||
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 (1–10)</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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user