import React, { useState, useEffect } from 'react'; import { Plus, Edit, Trash2, X, FileText, Stethoscope, Award, BookOpen, RefreshCw, Database, Loader2, Mail, Phone, ClipboardList, GripVertical, Share2, Check, Clock, ArrowRight, Activity } from 'lucide-react'; import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'; import { HybridBackend } from '../services/backend.ts'; import { Dentista, Especialidade, Plano, Procedimento, ProcedimentoValorPlano, DentistaHorario } from '../types.ts'; import { useHybridBackend } from '../hooks/useHybridBackend.ts'; import { useToast } from '../contexts/ToastContext.tsx'; import { PageHeader } from '../components/PageHeader.tsx'; import { GoogleConnectButton } from '../components/GoogleConnectButton.tsx'; const AdminSection: React.FC<{ title: string; buttonLabel: string; onButtonClick: () => void; children: React.ReactNode }> = ({ title, buttonLabel, onButtonClick, children }) => (
{children}
); const DentistHoursModal: React.FC<{ dentist: Dentista; clinicaId: string; onClose: () => void }> = ({ dentist, clinicaId, onClose }) => { const { data: horarios, refresh } = useHybridBackend(() => HybridBackend.getHorariosByDentista(dentist.id, clinicaId)); const toast = useToast(); const dias = ['DOMINGO', 'SEGUNDA', 'TERÇA', 'QUARTA', 'QUINTA', 'SEXTA', 'SÁBADO']; const handleAdd = async (e: React.FormEvent) => { e.preventDefault(); const fd = new FormData(e.target as HTMLFormElement); await HybridBackend.saveHorario({ dentista_id: dentist.id, clinica_id: clinicaId, dia_semana: parseInt(fd.get('dia') as string), hora_inicio: fd.get('inicio') as string, hora_fim: fd.get('fim') as string, ativo: true }); toast.success("HORÁRIO ADICIONADO!"); refresh(); }; return (

ESCALA DE ATENDIMENTO

{dentist.nome}

{horarios?.map(h => (
{dias[h.dia_semana].substring(0, 3)}
{h.hora_inicio.substring(0, 5)} {h.hora_fim.substring(0, 5)}
))}
); }; // --- DENTISTAS --- export const DentistasView: React.FC = () => { const activeWorkspace = HybridBackend.getActiveWorkspace(); const { data: dentistas, isLoading, error, refresh } = useHybridBackend(() => HybridBackend.getDentistas(activeWorkspace?.id)); const [isModalOpen, setIsModalOpen] = useState(false); const [editingDentista, setEditingDentista] = useState | null>(null); const [isHoursModalOpen, setIsHoursModalOpen] = useState(false); const [selectedDentist, setSelectedDentist] = useState(null); const [connectedAccounts, setConnectedAccounts] = useState([]); const toast = useToast(); const fetchGoogleStatus = async () => { try { const response = await fetch('/api/auth/google/status'); const data = await response.json(); setConnectedAccounts(data); } catch (error) { console.error("Erro ao buscar status do Google:", error); } }; useEffect(() => { fetchGoogleStatus(); }, []); useEffect(() => { if (!isModalOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setIsModalOpen(false); }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [isModalOpen]); const handleSave = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.target as HTMLFormElement); const data = Object.fromEntries(formData); try { if (editingDentista && editingDentista.id) { await HybridBackend.updateDentista({ ...editingDentista, ...data } as Dentista); toast.success("DENTISTA ATUALIZADO!"); } else { await HybridBackend.saveDentista({ ...data, ativo: true } as any); toast.success("DENTISTA CADASTRADO!"); } setIsModalOpen(false); refresh(); } catch { toast.error("ERRO AO SALVAR DENTISTA."); } }; const handleDelete = async (id: string) => { if (window.confirm("TEM CERTEZA QUE DESEJA EXCLUIR ESTE DENTISTA?")) { await HybridBackend.deleteDentista(id); toast.success("Dentista excluído."); refresh(); } } const [copiedId, setCopiedId] = useState(null); const handleInviteDentist = async (d: Dentista) => { try { const activeW = HybridBackend.getActiveWorkspace(); const res = await fetch('/api/dentistas/magic-link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clinicaId: activeW.id, dentistName: d.nome }) }); const { link } = await res.json(); await navigator.clipboard.writeText(link); setCopiedId(d.id); toast.success("LINK MÁGICO COPIADO!"); setTimeout(() => setCopiedId(null), 2000); } catch { toast.error("ERRO AO GERAR LINK."); } }; return ( { setEditingDentista({}); setIsModalOpen(true); }}> {isLoading && } {error &&

Erro: {error.message}

}
{dentistas?.map(d => (

{d.nome}

{d.especialidade}

{d.email}

{d.telefone}

a.owner_id === d.id)} onStatusChange={fetchGoogleStatus} />
))}
{isModalOpen && (

{editingDentista?.id ? 'EDITAR' : 'NOVO'} DENTISTA

)} {isHoursModalOpen && selectedDentist && activeWorkspace && ( { setIsHoursModalOpen(false); setSelectedDentist(null); }} /> )}
); }; // --- ESPECIALIDADES --- export const EspecialidadesView: React.FC = () => { const { data: especialidades, isLoading, error, refresh } = useHybridBackend(HybridBackend.getEspecialidades); const [isModalOpen, setIsModalOpen] = useState(false); const [editingEsp, setEditingEsp] = useState | null>(null); const toast = useToast(); useEffect(() => { if (!isModalOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setIsModalOpen(false); }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [isModalOpen]); const handleSave = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.target as HTMLFormElement); const data = Object.fromEntries(formData); try { if (editingEsp && editingEsp.id) { await HybridBackend.updateEspecialidade({ ...editingEsp, ...data } as Especialidade); toast.success("ESPECIALIDADE ATUALIZADA!"); } else { await HybridBackend.saveEspecialidade(data as any); toast.success("ESPECIALIDADE SALVA!"); } setIsModalOpen(false); refresh(); } catch { toast.error("ERRO AO SALVAR ESPECIALIDADE."); } }; const handleDelete = async (id: string) => { if (window.confirm("TEM CERTEZA QUE DESEJA EXCLUIR ESTA ESPECIALIDADE?")) { await HybridBackend.deleteEspecialidade(id); toast.success("Especialidade excluída."); refresh(); } } const onDragEnd = async (result: any) => { if (!result.destination || !especialidades) return; const items = Array.from(especialidades); const [reorderedItem] = items.splice(result.source.index, 1); items.splice(result.destination.index, 0, reorderedItem); const updatedOrders = items.map((item, index) => ({ id: item.id, ordem: index })); try { await HybridBackend.reorderEspecialidades(updatedOrders); toast.success("ORDEM ATUALIZADA!"); refresh(); } catch { toast.error("ERRO AO REORDENAR."); } }; return ( { setEditingEsp({}); setIsModalOpen(true); }}>
{(provided) => ( {isLoading && } {error && } {especialidades?.map((esp, index) => ( {(provided, snapshot) => ( )} ))} {provided.placeholder} )}
NOME DESCRIÇÃO AÇÕES
{error.message}
{esp.nome} {esp.descricao}
{isModalOpen && (

{editingEsp?.id ? 'EDITAR' : 'NOVA'} ESPECIALIDADE

)}
); }; // --- PROCEDIMENTOS --- export const ProcedimentosView: React.FC = () => { const { data: procedimentos, isLoading, error, refresh } = useHybridBackend(HybridBackend.getProcedimentos); const { data: especialidades } = useHybridBackend(HybridBackend.getEspecialidades); const { data: planos } = useHybridBackend(HybridBackend.getPlanos); const [isModalOpen, setIsModalOpen] = useState(false); const [editingProcedimento, setEditingProcedimento] = useState | null>(null); const [planValues, setPlanValues] = useState[]>([]); const toast = useToast(); useEffect(() => { if (!isModalOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setIsModalOpen(false); }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [isModalOpen]); useEffect(() => { if (editingProcedimento) { setPlanValues(editingProcedimento.valoresPlanos || []); // Set initial category if new if (!editingProcedimento.id && !editingProcedimento.categoria) { setEditingProcedimento(prev => ({ ...prev, categoria: 'Particular' })); } } }, [editingProcedimento]); const suggestInternalCode = () => { const nextId = (procedimentos?.length || 0) + 1; setEditingProcedimento(prev => ({ ...prev, codigoInterno: nextId.toString() })); }; const handleSave = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.target as HTMLFormElement); const data: any = Object.fromEntries(formData.entries()); const esp = especialidades?.find(es => es.id === data.especialidadeId); const payload: Partial = { id: editingProcedimento?.id, nome: data.nome, codigo: data.codigo, codigoInterno: data.codigoInterno, categoria: data.categoria, descricao: data.descricao, descricao_mobile: data.descricao_mobile || undefined, especialidadeId: data.especialidadeId, especialidadeNome: esp?.nome || '', valorParticular: parseFloat(data.valorParticular), tipo_regiao: editingProcedimento?.tipo_regiao || 'GERAL', exige_face: editingProcedimento?.tipo_regiao === 'DENTE' ? !!editingProcedimento?.exige_face : false, valoresPlanos: planValues.map(pv => ({ planoId: pv.planoId!, planoNome: planos?.find(p => p.id === pv.planoId)?.nome || '', valor: parseFloat(pv.valor as any), codigoPlano: pv.codigoPlano || '' })), }; try { if (payload.id) { await HybridBackend.updateProcedimento(payload as Procedimento); toast.success("PROCEDIMENTO ATUALIZADO!"); } else { await HybridBackend.saveProcedimento(payload); toast.success("PROCEDIMENTO SALVO!"); } setIsModalOpen(false); refresh(); } catch { toast.error("ERRO AO SALVAR PROCEDIMENTO."); } }; const handleDelete = async (id: string) => { if (window.confirm("TEM CERTEZA QUE DESEJA EXCLUIR ESTE PROCEDIMENTO?")) { await HybridBackend.deleteProcedimento(id); toast.success("Procedimento excluído."); refresh(); } }; const handleAddPlanValue = () => setPlanValues(prev => [...prev, { planoId: '', valor: 0, codigoPlano: '' }]); const handleRemovePlanValue = (index: number) => setPlanValues(prev => prev.filter((_, i) => i !== index)); const handlePlanValueChange = (index: number, field: 'planoId' | 'valor' | 'codigoPlano', value: string) => { const newValues = [...planValues]; newValues[index] = { ...newValues[index], [field]: value }; setPlanValues(newValues); }; const onDragEnd = async (result: any) => { if (!result.destination || !procedimentos) return; const items = Array.from(procedimentos); const [reorderedItem] = items.splice(result.source.index, 1); items.splice(result.destination.index, 0, reorderedItem); const updatedOrders = items.map((item, index) => ({ id: item.id, ordem: index })); try { await HybridBackend.reorderProcedimentos(updatedOrders); toast.success("ORDEM DOS PROCEDIMENTOS ATUALIZADA!"); refresh(); } catch { toast.error("ERRO AO REORDENAR PROCEDIMENTOS."); } }; return ( { setEditingProcedimento({}); setIsModalOpen(true); }}>
{(provided) => ( {isLoading && } {error && } {procedimentos?.map((proc, index) => ( {(provided, snapshot) => ( )} ))} {provided.placeholder} )}
NOME CÓDIGO (TUSS/INT) CATEGORIA ESPECIALIDADE VALOR PARTICULAR AÇÕES
{error.message}
{proc.nome}
TUSS: {proc.codigo || '---'} INT: {proc.codigoInterno || '---'}
{proc.categoria} {proc.especialidadeNome} R$ {Number(proc.valorParticular).toFixed(2)}
{isModalOpen && (

{editingProcedimento?.id ? 'EDITAR' : 'NOVO'} PROCEDIMENTO

setEditingProcedimento(prev => ({ ...prev, codigoInterno: e.target.value }))} className={`w-full border border-gray-300 rounded-lg p-2 uppercase-input ${editingProcedimento?.categoria === 'Particular' ? 'bg-gray-50 text-blue-600 font-bold' : ''}`} required={editingProcedimento?.categoria !== 'Particular'} />

REGRA DO ODONTOGRAMA (GTO)

{[ { id: 'GERAL', label: 'PROCED. GERAL', desc: 'SEM ODONTOGRAMA' }, { id: 'DENTE', label: 'DENTE ESPECÍFICO', desc: 'LIBERA NÚMEROS' }, { id: 'ARCO', label: 'ARCO COMPLETO', desc: 'LIBERA AI / AS' } ].map((opt) => ( ))}
{(editingProcedimento?.tipo_regiao === 'DENTE') && (
)}

VALORES PARA PLANOS / CONVÊNIOS

{planValues.map((pv, index) => (
handlePlanValueChange(index, 'valor', e.target.value)} className="w-32 border border-gray-300 rounded-lg p-2" /> handlePlanValueChange(index, 'codigoPlano', e.target.value)} className="w-32 border border-gray-300 rounded-lg p-2 uppercase-input" />
))}
)}
); }; // --- PLANOS --- export const PlanosView: React.FC = () => { const { data: planos, isLoading, error, refresh } = useHybridBackend(HybridBackend.getPlanos); const [isModalOpen, setIsModalOpen] = useState(false); const [editingPlano, setEditingPlano] = useState | null>(null); const toast = useToast(); useEffect(() => { if (!isModalOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setIsModalOpen(false); }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [isModalOpen]); const handleSave = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.target as HTMLFormElement); const data = Object.fromEntries(formData); try { if (editingPlano && editingPlano.id) { await HybridBackend.updatePlano({ ...editingPlano, ...data } as Plano); toast.success("PLANO ATUALIZADO!"); } else { await HybridBackend.savePlano(data as any); toast.success("PLANO SALVO!"); } setIsModalOpen(false); refresh(); } catch { toast.error("ERRO AO SALVAR PLANO."); } }; const handleDelete = async (id: string) => { if (window.confirm("TEM CERTEZA QUE DESEJA EXCLUIR ESTE PLANO?")) { await HybridBackend.deletePlano(id); toast.success("Plano excluído."); refresh(); } } return ( { setEditingPlano({}); setIsModalOpen(true); }}> {isLoading && } {error &&

Erro: {error.message}

}
{planos?.map(p => (

{p.nome}

{p.tipo}

DESCONTO PADRÃO: {p.descontoPadrao}%

))}
{isModalOpen && (

{editingPlano?.id ? 'EDITAR' : 'NOVO'} PLANO

)}
); }; // --- SYNC --- const SYNC_TABS = ['pacientes', 'agendamentos', 'financeiro', 'leads', 'guias']; export const SyncView: React.FC = () => { const [logs, setLogs] = useState([]); const [operation, setOperation] = useState<'push' | 'pull' | 'import-legacy' | null>(null); const [status, setStatus] = useState(null); const toast = useToast(); const authHeaders = { 'Authorization': `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}`, 'Content-Type': 'application/json' }; const loadStatus = async () => { try { const res = await fetch('/api/sync/status', { headers: authHeaders }); if (res.ok) setStatus(await res.json()); } catch {} }; useEffect(() => { loadStatus(); }, []); const runSync = async (dir: 'push' | 'pull' | 'import-legacy') => { setOperation(dir); const labels: Record = { push: 'BACKUP PostgreSQL → Google Sheets', pull: 'RESTAURAR Google Sheets → PostgreSQL', 'import-legacy': 'IMPORTAR PACIENTES (aba PACIENTES-CONSULTT-CLINIC)...' }; setLogs([`>>> ${labels[dir]}...`]); try { const res = await fetch(`/api/sync/${dir}`, { method: 'POST', headers: authHeaders }); const data = await res.json(); setLogs(data.logs || []); if (data.success) { const msgs: Record = { push: 'BACKUP REALIZADO!', pull: 'DADOS RESTAURADOS!', 'import-legacy': `${data.imported ?? 0} PACIENTES IMPORTADOS!` }; toast.success(msgs[dir]); loadStatus(); } else { toast.error('ERRO NA SINCRONIZAÇÃO. VERIFIQUE OS LOGS.'); } } catch (e: any) { setLogs(prev => [...prev, `[ERRO] ${e.message}`]); toast.error('FALHA NA CONEXÃO.'); } finally { setOperation(null); } }; const fmtDate = (iso: string | undefined) => iso ? new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'; return (
{/* Status por tabela */}

ESTADO DAS TABELAS

{SYNC_TABS.map(tab => { const s = status?.syncStatus?.[tab]; const count = status?.counts?.[tab]; return (
{tab}

No banco

{count ?? '—'}

Último backup

{fmtDate(s?.lastPush)}

Registros no backup

{s?.count ?? '—'}

); })}
{status?.spreadsheetId && (
PLANILHA: {status.spreadsheetId}
)}
{/* Terminal */}

LOG DE OPERAÇÃO

{logs.length > 0 && ( )}
{logs.length === 0 ? AGUARDANDO OPERAÇÃO... : logs.map((log, i) => (
{log}
)) } {operation &&
}

BACKUP AUTOMÁTICO: cada alteração de paciente, agendamento, financeiro ou lead é enviada para a planilha automaticamente (com delay de 5s para agrupar alterações). Use "Restaurar" para recuperar dados da planilha caso o banco seja reiniciado.

); };