import React, { useState, useEffect, useRef } from 'react'; import { Plus, Edit, Trash2, X, FileText, Stethoscope, Award, BookOpen, RefreshCw, Database, Loader2, Mail, Phone, ClipboardList, GripVertical, Share2, Check, Clock, ArrowRight, Activity, AlertTriangle, Users, Calendar, Banknote, UserPlus, Unlink, Copy, KeyRound, ToggleLeft, ToggleRight } 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 { useConfirm } from '../contexts/ConfirmContext.tsx'; import { PageHeader } from '../components/PageHeader.tsx'; import { GoogleConnectButton } from '../components/GoogleConnectButton.tsx'; import { EmptyState } from '../components/EmptyState.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 confirm = useConfirm(); 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 { data: especialidadesList } = useHybridBackend(HybridBackend.getEspecialidades); const [selEsp, setSelEsp] = useState([]); const [selDias, setSelDias] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); const [editingDentista, setEditingDentista] = useState | null>(null); const [novaCredencial, setNovaCredencial] = useState<{ email: string; senha: string } | null>(null); const [isHoursModalOpen, setIsHoursModalOpen] = useState(false); const [selectedDentist, setSelectedDentist] = useState(null); const [connectedAccounts, setConnectedAccounts] = useState([]); const [setores, setSetores] = useState<{ id: string; nome: string }[]>([]); const toast = useToast(); const confirm = useConfirm(); const fetchGoogleStatus = async () => { try { const clinicaId = activeWorkspace?.id || ''; const response = await fetch(`/api/auth/google/status${clinicaId ? `?clinicaId=${clinicaId}` : ''}`); setConnectedAccounts(await response.json()); } catch (error) { console.error("Erro ao buscar status do Google:", error); } }; useEffect(() => { fetchGoogleStatus(); // Setores da clínica (para lotar o dentista num setor → isolamento da agenda). (async () => { try { const r = await HybridBackend.getSetores(activeWorkspace?.id || ''); setSetores(r?.setores || []); } catch { /* clínica sem setores → seletor não aparece */ } })(); }, []); useEffect(() => { if (!isModalOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setIsModalOpen(false); }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [isModalOpen]); // Sincroniza especialidades/dias selecionados ao abrir o modal (novo ou edição). useEffect(() => { if (!isModalOpen) return; const ed: any = editingDentista || {}; setSelEsp(Array.isArray(ed.especialidades) && ed.especialidades.length ? ed.especialidades : (ed.especialidade ? [ed.especialidade] : [])); setSelDias(Array.isArray(ed.dias_atendimento) ? ed.dias_atendimento : []); }, [isModalOpen, editingDentista]); const handleSave = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.target as HTMLFormElement); const data: any = Object.fromEntries(formData); // Campos controlados (não vêm do FormData): especialidades múltiplas + dias. data.especialidades = selEsp; data.dias_atendimento = selDias; data.especialidade = selEsp[0] || ''; // principal = primeira (compat) if (selEsp.length === 0) { toast.error("SELECIONE AO MENOS UMA ESPECIALIDADE."); return; } try { if (editingDentista && editingDentista.id) { await HybridBackend.updateDentista({ ...editingDentista, ...data } as Dentista); toast.success("DENTISTA ATUALIZADO!"); } else { const res = await HybridBackend.saveDentista({ ...data, ativo: true } as any); toast.success("DENTISTA CADASTRADO!"); if (res?.tempPassword) setNovaCredencial({ email: String((data as any).email || ''), senha: res.tempPassword }); } setIsModalOpen(false); refresh(); } catch { toast.error("ERRO AO SALVAR DENTISTA."); } }; const handleDelete = async (id: string) => { if (await 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}

} {!isLoading && (dentistas?.length ?? 0) === 0 && ( )}
{dentistas?.map(d => (

{d.nome}

{d.especialidade}

{d.email}

{d.telefone}

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

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

{/* Setor do dentista → isola a agenda dele para a equipe do setor. Só aparece se a clínica usa setores. Vazio = geral (visível a todos). */} {setores.length > 0 && (
)} {/* Especialidades que o dentista atende (várias) */}
{(especialidadesList || []).map(esp => { const on = selEsp.includes(esp.nome); return ( ); })} {(!especialidadesList || especialidadesList.length === 0) && ( Cadastre especialidades primeiro (menu Especialidades). )}
{/* Dias de atendimento na clínica */}
{['DOM', 'SEG', 'TER', 'QUA', 'QUI', 'SEX', 'SÁB'].map((dia, i) => { const on = selDias.includes(i); return ( ); })}

Selecione os dias em que ele atende nesta clínica.

)} {isHoursModalOpen && selectedDentist && activeWorkspace && ( { setIsHoursModalOpen(false); setSelectedDentist(null); }} /> )} {novaCredencial && (

Conta do dentista criada

Envie estes dados ao dentista — ele troca a senha no 1º acesso e já acessa a agenda da clínica.

E-mail

{novaCredencial.email}

Senha temporária

{novaCredencial.senha}

)}
); }; // --- 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(); const confirm = useConfirm(); const seedTried = useRef(false); const [areas, setAreas] = useState<{ slug: string; nome: string; cor: string }[]>([]); useEffect(() => { HybridBackend.getAreas().then(setAreas); }, []); const areaNome = (slug?: string) => areas.find(a => a.slug === slug)?.nome || slug; const areaCor = (slug?: string) => areas.find(a => a.slug === slug)?.cor || '#6b7280'; // Biomédico com catálogo vazio: semeia biomedicina estética no 1º acesso (idempotente). useEffect(() => { if (seedTried.current || isLoading || !especialidades) return; if (HybridBackend.getCurrentRole() === 'biomedico' && especialidades.length === 0) { seedTried.current = true; HybridBackend.seedBiomedicina().then(seeded => { if (seeded) refresh(); }); } }, [isLoading, especialidades, refresh]); 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 (await confirm("TEM CERTEZA QUE DESEJA EXCLUIR ESTA ESPECIALIDADE?")) { await HybridBackend.deleteEspecialidade(id); toast.success("Especialidade excluída."); refresh(); } } const handleToggleAtivo = async (esp: Especialidade) => { const novo = esp.ativo === false; await HybridBackend.updateEspecialidade({ ...esp, ativo: novo }); toast.success(novo ? "Especialidade ativada." : "Especialidade desativada."); 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 && } {!isLoading && (especialidades?.length ?? 0) === 0 && ( )} {especialidades?.map((esp, index) => ( {(provided, snapshot) => ( )} ))} {provided.placeholder} )}
NOME DESCRIÇÃO AÇÕES
{error.message}
{esp.nome} {esp.area && {areaNome(esp.area)}} {esp.ativo === false && Inativa} {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(); const confirm = useConfirm(); const [areas, setAreas] = useState<{ slug: string; nome: string; cor: string }[]>([]); useEffect(() => { HybridBackend.getAreas().then(setAreas); }, []); const areaNome = (slug?: string) => areas.find(a => a.slug === slug)?.nome || slug; const areaCor = (slug?: string) => areas.find(a => a.slug === slug)?.cor || '#6b7280'; 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, num_faces: editingProcedimento?.num_faces || undefined, num_raizes: editingProcedimento?.num_raizes || undefined, 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 (await confirm("TEM CERTEZA QUE DESEJA EXCLUIR ESTE PROCEDIMENTO?")) { await HybridBackend.deleteProcedimento(id); toast.success("Procedimento excluído."); refresh(); } }; const handleToggleAtivo = async (proc: Procedimento) => { const novo = proc.ativo === false; await HybridBackend.updateProcedimento({ ...proc, ativo: novo }); toast.success(novo ? "Procedimento ativado." : "Procedimento desativado."); 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."); } }; const normEsp = (s: string) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); const procEspNome = normEsp(especialidades?.find(e => e.id === editingProcedimento?.especialidadeId)?.nome || ''); const isDentistica = procEspNome.includes('dentistic'); const isEndodontia = procEspNome.includes('endodont'); const hideArco = isDentistica || isEndodontia; return ( { setEditingProcedimento({}); setIsModalOpen(true); }}>
{(provided) => ( {isLoading && } {error && } {!isLoading && (procedimentos?.length ?? 0) === 0 && ( )} {procedimentos?.map((proc, index) => ( {(provided, snapshot) => ( )} ))} {provided.placeholder} )}
NOME CÓDIGO (TUSS/INT) CATEGORIA ESPECIALIDADE VALOR PARTICULAR AÇÕES
{error.message}
{proc.nome} {proc.area && {areaNome(proc.area)}} {proc.ativo === false && Inativo}
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' }, ...(!hideArco ? [{ id: 'ARCO', label: 'ARCO COMPLETO', desc: 'LIBERA AI / AS' }] : []) ].map((opt) => ( ))}
{(editingProcedimento?.tipo_regiao === 'DENTE') && (
)} {isDentistica && (
)} {isEndodontia && (
)}

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(); const confirm = useConfirm(); 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 (await 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}

} {!isLoading && (planos?.length ?? 0) === 0 && ( )}
{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']; type DupGroup = { criterion: string; key: string; records: any[] }; type DupData = Record; export const SyncView: React.FC = () => { const [logs, setLogs] = useState([]); const [operation, setOperation] = useState<'push' | 'pull' | 'import-legacy' | 'limpar' | 'limpar-ag' | 'truncate' | null>(null); const [status, setStatus] = useState(null); const [dupData, setDupData] = useState(null); const [dupTab, setDupTab] = useState('pacientes'); const [selectedDups, setSelectedDups] = useState>(new Set()); const [dupLoading, setDupLoading] = useState(false); const toast = useToast(); const confirm = useConfirm(); 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 {} }; const loadDuplicates = async () => { setDupLoading(true); try { const res = await fetch('/api/sync/duplicatas', { headers: authHeaders }); if (res.ok) setDupData(await res.json()); } catch {} finally { setDupLoading(false); } }; useEffect(() => { loadStatus(); loadDuplicates(); }, []); const handleLimparAgendamentos = async () => { const confirmed = await confirm( 'ATENÇÃO: Remove todos os agendamentos sem vínculo de clínica (importados antes da versão atual).\n\nCONFIRMAR?' ); if (!confirmed) return; setOperation('limpar-ag'); setLogs(['>>> REMOVENDO AGENDAMENTOS LEGADOS (sem clinica_id)...']); try { const res = await fetch('/api/sync/limpar-agendamentos', { method: 'DELETE', headers: authHeaders }); const data = await res.json(); if (data.success) { setLogs([`[✓] ${data.removed} AGENDAMENTOS REMOVIDOS.`]); toast.success(`${data.removed} AGENDAMENTOS REMOVIDOS!`); loadStatus(); } else { setLogs([`[ERRO] ${data.message}`]); toast.error('ERRO AO REMOVER.'); } } catch (e: any) { setLogs([`[ERRO] ${e.message}`]); toast.error('FALHA NA CONEXÃO.'); } finally { setOperation(null); } }; const handleLimparLegado = async () => { const confirmed = await confirm( 'ATENÇÃO: Isso vai remover TODOS os pacientes sem vínculo com clínica (importados antes da versão atual).\n\nEles poderão ser reimportados corretamente pela planilha.\n\nCONFIRMAR?' ); if (!confirmed) return; setOperation('limpar'); setLogs(['>>> REMOVENDO PACIENTES LEGADOS (sem clinica_id)...']); try { const res = await fetch('/api/sync/limpar-legado', { method: 'DELETE', headers: authHeaders }); const data = await res.json(); if (data.success) { setLogs([`[✓] ${data.removed} PACIENTES REMOVIDOS. Reimporte pela planilha legada.`]); toast.success(`${data.removed} PACIENTES REMOVIDOS!`); loadStatus(); } else { setLogs([`[ERRO] ${data.message}`]); toast.error('ERRO AO REMOVER.'); } } catch (e: any) { setLogs([`[ERRO] ${e.message}`]); toast.error('FALHA NA CONEXÃO.'); } finally { setOperation(null); } }; 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' }) : '—'; const TAB_ICONS: Record = { pacientes: , agendamentos: , financeiro: , leads: , guias: , }; const [truncateConfirmMode, setTruncateConfirmMode] = useState(false); const [truncateInput, setTruncateInput] = useState(''); const executeTruncateAll = async () => { setTruncateConfirmMode(false); setTruncateInput(''); setOperation('truncate'); setLogs(['>>> ZERANDO BANCO DE DADOS POSTGRESQL...']); try { const res = await fetch('/api/sync/truncate-all', { method: 'DELETE', headers: authHeaders }); const data = await res.json(); if (data.success) { setLogs(['[✓] BANCO ZERADO. Use "Restaurar Backup" para reimportar os dados da planilha com a clínica ativa.']); toast.success('BANCO ZERADO!'); loadStatus(); setDupData(null); } else { setLogs([`[ERRO] ${data.message}`]); toast.error('ERRO AO ZERAR BANCO.'); } } catch (e: any) { setLogs([`[ERRO] ${e.message}`]); toast.error('FALHA NA CONEXÃO.'); } finally { setOperation(null); } }; const toggleDupSelect = (id: string) => { setSelectedDups(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); }; const handleDeleteDups = async () => { if (selectedDups.size === 0) { toast.error('Selecione ao menos um registro.'); return; } if (!await confirm(`Remover ${selectedDups.size} registro(s) selecionado(s) permanentemente?`)) return; try { const res = await fetch('/api/sync/duplicatas', { method: 'DELETE', headers: authHeaders, body: JSON.stringify({ ids: Array.from(selectedDups), tabela: dupTab }) }); const data = await res.json(); if (data.success) { toast.success(`${data.removed} REGISTRO(S) REMOVIDO(S)!`); setSelectedDups(new Set()); await Promise.all([loadDuplicates(), loadStatus()]); } else { toast.error(data.error || 'ERRO AO REMOVER.'); } } catch { toast.error('FALHA NA CONEXÃO.'); } }; const handleResetStatus = async () => { if (!await confirm('Limpar histórico de sincronização (sheets_sync_status)?\n\nOs dados no banco e na planilha não serão afetados.')) return; try { const res = await fetch('/api/sync/reset-status', { method: 'DELETE', headers: authHeaders }); if ((await res.json()).success) { toast.success('STATUS DE SYNC RESETADO.'); loadStatus(); } } catch { toast.error('FALHA NA CONEXÃO.'); } }; const [editingSheetId, setEditingSheetId] = useState(false); const [sheetIdInput, setSheetIdInput] = useState(''); const handleSaveSheetId = async () => { if (!sheetIdInput.trim()) return; try { const res = await fetch('/api/sync/spreadsheet-id', { method: 'PUT', headers: authHeaders, body: JSON.stringify({ spreadsheetId: sheetIdInput.trim() }) }); if ((await res.json()).success) { toast.success('ID DA PLANILHA ATUALIZADO!'); setEditingSheetId(false); setSheetIdInput(''); loadStatus(); } } catch { toast.error('FALHA NA CONEXÃO.'); } }; const handleClearSheetId = async () => { if (!await confirm('Remover o ID da planilha configurado?\n\nO sistema usará o ID padrão do ambiente.')) return; try { await fetch('/api/sync/spreadsheet-id', { method: 'DELETE', headers: authHeaders }); toast.success('ID REMOVIDO. Usando padrão.'); loadStatus(); } catch { toast.error('FALHA NA CONEXÃO.'); } }; return (
{/* Header */}

Backup & Sincronização

PostgreSQL ↔ Google Sheets · Atualização automática

{/* Ações principais — Backup / Restaurar */}
{/* Importação legada */} {/* Zona de Perigo */}
Zona de Perigo — Operações irreversíveis
{truncateConfirmMode ? (

Digite CONFIRMAR para zerar:

setTruncateInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && truncateInput === 'CONFIRMAR') executeTruncateAll(); if (e.key === 'Escape') { setTruncateConfirmMode(false); setTruncateInput(''); } }} placeholder="CONFIRMAR" className="text-xs font-mono border border-red-200 rounded-lg px-3 py-1.5 outline-none focus:ring-2 focus:ring-red-300 bg-white" />
) : ( )}
{/* Revisor de Duplicatas */} {(() => { const hasDups = dupData && SYNC_TABS.some(t => (dupData[t]?.length ?? 0) > 0); const tabsWithDups = SYNC_TABS.filter(t => (dupData?.[t]?.length ?? 0) > 0); const currentGroups: DupGroup[] = dupData?.[dupTab] ?? []; const totalDups = tabsWithDups.reduce((acc, t) => acc + (dupData?.[t]?.reduce((a, g) => a + g.records.length, 0) ?? 0), 0); if (dupLoading) return (
Verificando duplicatas...
); if (!hasDups) return (
Nenhuma duplicata detectada
); return (
{/* Header */}
Revisor de Duplicatas — {totalDups} registros suspeitos
{selectedDups.size > 0 && ( )}
{/* Tabs das tabelas com dups */}
{tabsWithDups.map(t => ( ))}
{/* Grupos de duplicatas */}
{currentGroups.length === 0 ? (

Sem duplicatas nesta tabela.

) : currentGroups.map((group, gi) => (
Critério: {group.criterion} · {group.key} {group.records.length} registros
{group.records.map((rec: any) => { const isSelected = selectedDups.has(rec.id); const label = rec.nome || rec.pacientenome || rec.beneficiarionome || rec.numeroguiaprestador || rec.id; const sub1 = rec.cpf || rec.start_time || rec.valor || rec.status || ''; const sub2 = rec.telefone || rec.whatsapp || rec.descricao || rec.datavencimento || ''; return ( ); })}
))}

Marque os registros a remover · O que ficar desmarcado é mantido · A planilha não é afetada

); })()} {/* Status das tabelas */}

Estado das Tabelas

{editingSheetId ? (
setSheetIdInput(e.target.value)} placeholder="Cole o ID da planilha..." className="text-[10px] font-mono border border-blue-200 rounded-lg px-2 py-1 w-64 outline-none focus:ring-1 focus:ring-blue-300" />
) : ( <> {status?.spreadsheetId && ( {status.spreadsheetId} )} )}
{SYNC_TABS.map(tab => { const s = status?.syncStatus?.[tab]; const count = status?.counts?.[tab]; return (
{TAB_ICONS[tab]}
{tab}

Banco

{count ?? '—'}

Backup

{fmtDate(s?.lastPush)}

Planilha

{s?.count ?? '—'}

); })}
{/* Terminal / Log */}
Log de Operação
{logs.length > 0 && ( )}
{logs.length === 0 ? $ aguardando operação... : logs.map((log, i) => (
>>') ? 'border-blue-400 text-blue-300' : 'border-gray-700 text-gray-400'}`}> {log}
)) } {operation &&
}

Backup automático: alterações em pacientes, agendamentos, financeiro e leads são enviadas com delay de 5s.

); };