3042ddca38
- /wa-inbox, /wa-sessions e /wa-secretaria rodam sem a sidebar do scoreodonto (isStandaloneView); cada tela tem seu "Voltar" (Secretária ganhou botão na ThinNav). - SessionsView: banner "Você já possui acesso ao WhatsApp" quando há sessão ativa. - avatares: usa a URL do CDN (contactAvatarUrl/instance.avatar) direto; Avatar exibe sem depender de version; self-heal no onError re-busca a foto atual. - deps: framer-motion, immer. Dev override com HMR (docker-compose.dev.yml). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1660 lines
108 KiB
TypeScript
1660 lines
108 KiB
TypeScript
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 }) => (
|
|
<div className="space-y-6">
|
|
<PageHeader title={title}>
|
|
<button
|
|
onClick={onButtonClick}
|
|
className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 font-bold text-xs uppercase transition-colors shadow-sm"
|
|
>
|
|
<Plus size={18} /> {buttonLabel}
|
|
</button>
|
|
</PageHeader>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
const DentistHoursModal: React.FC<{ dentist: Dentista; clinicaId: string; onClose: () => void }> = ({ dentist, clinicaId, onClose }) => {
|
|
const { data: horarios, refresh } = useHybridBackend<DentistaHorario[]>(() => 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 (
|
|
<div className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center backdrop-blur-sm p-0">
|
|
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-2xl flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
|
|
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
|
|
<div>
|
|
<h3 className="text-lg font-black text-gray-900 uppercase leading-tight">ESCALA DE ATENDIMENTO</h3>
|
|
<p className="text-[10px] text-blue-600 font-bold uppercase">{dentist.nome}</p>
|
|
</div>
|
|
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-xl transition-colors"><X size={20} /></button>
|
|
</div>
|
|
<div className="p-6 space-y-6">
|
|
<form onSubmit={handleAdd} className="grid grid-cols-1 md:grid-cols-4 gap-3 bg-blue-50/50 p-4 rounded-xl border border-blue-100">
|
|
<select name="dia" className="bg-white border-blue-200 border rounded-lg p-2 text-xs font-bold uppercase" required>
|
|
{dias.map((d, i) => <option key={i} value={i}>{d}</option>)}
|
|
</select>
|
|
<input name="inicio" type="time" className="bg-white border-blue-200 border rounded-lg p-2 text-xs font-bold" required />
|
|
<input name="fim" type="time" className="bg-white border-blue-200 border rounded-lg p-2 text-xs font-bold" required />
|
|
<button type="submit" className="bg-blue-600 text-white rounded-lg font-black text-[10px] uppercase hover:bg-blue-700 transition-all flex items-center justify-center gap-2">
|
|
<Plus size={14} /> ADICIONAR
|
|
</button>
|
|
</form>
|
|
|
|
<div className="space-y-2 flex-1 overflow-y-auto pr-2">
|
|
{horarios?.map(h => (
|
|
<div key={h.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-xl border border-gray-100 hover:border-blue-200 transition-all group">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center shadow-sm text-blue-600 border border-gray-100 font-black text-[10px]">
|
|
{dias[h.dia_semana].substring(0, 3)}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs font-black text-gray-700">
|
|
<Clock size={14} className="text-gray-400" />
|
|
<span>{h.hora_inicio.substring(0, 5)}</span>
|
|
<ArrowRight size={12} className="text-gray-300" />
|
|
<span>{h.hora_fim.substring(0, 5)}</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={async () => { await HybridBackend.deleteHorario(h.id); refresh(); toast.success("REMOVIDO!"); }}
|
|
className="p-2 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// --- DENTISTAS ---
|
|
export const DentistasView: React.FC = () => {
|
|
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
|
const { data: dentistas, isLoading, error, refresh } = useHybridBackend<Dentista[]>(() => HybridBackend.getDentistas(activeWorkspace?.id));
|
|
const { data: especialidadesList } = useHybridBackend<Especialidade[]>(HybridBackend.getEspecialidades);
|
|
const [selEsp, setSelEsp] = useState<string[]>([]);
|
|
const [selDias, setSelDias] = useState<number[]>([]);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [editingDentista, setEditingDentista] = useState<Partial<Dentista> | null>(null);
|
|
const [novaCredencial, setNovaCredencial] = useState<{ email: string; senha: string } | null>(null);
|
|
const [isHoursModalOpen, setIsHoursModalOpen] = useState(false);
|
|
const [selectedDentist, setSelectedDentist] = useState<Dentista | null>(null);
|
|
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
|
|
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<string | null>(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 (
|
|
<AdminSection title="DENTISTAS & ESPECIALISTAS" buttonLabel="NOVO DENTISTA" onButtonClick={() => { setEditingDentista({}); setIsModalOpen(true); }}>
|
|
{isLoading && <Loader2 className="animate-spin text-blue-500" />}
|
|
{error && <p className="text-red-500">Erro: {error.message}</p>}
|
|
{!isLoading && (dentistas?.length ?? 0) === 0 && (
|
|
<EmptyState icon={Stethoscope} title="Nenhum dentista cadastrado" description="Cadastre o primeiro dentista no botão “Novo dentista” acima para começar." />
|
|
)}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{dentistas?.map(d => (
|
|
<div key={d.id} className="bg-white border border-gray-200 rounded-xl shadow-sm hover:shadow-md transition-shadow flex flex-col">
|
|
<div className="p-5 flex-grow">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h3 className="font-bold text-gray-900 uppercase">{d.nome}</h3>
|
|
<span className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full uppercase">{d.especialidade}</span>
|
|
</div>
|
|
<div className="w-4 h-4 rounded-full border-2 border-white shadow" style={{ backgroundColor: d.corAgenda }}></div>
|
|
</div>
|
|
<div className="mt-4 space-y-2 text-xs text-gray-500">
|
|
<p className="flex items-center gap-2 font-medium"><Mail size={14} /> {d.email}</p>
|
|
<p className="flex items-center gap-2 font-medium"><Phone size={14} /> {d.telefone}</p>
|
|
</div>
|
|
|
|
<div className="mt-5 border-t border-gray-50 pt-4 flex items-center gap-2">
|
|
<div className="flex-1">
|
|
<GoogleConnectButton
|
|
ownerId={d.id}
|
|
clinicaId={activeWorkspace?.id}
|
|
isConnected={connectedAccounts.some(a => a.owner_id === d.id)}
|
|
onStatusChange={fetchGoogleStatus}
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => handleInviteDentist(d)}
|
|
title="COMPARTILHAR LINK DE CADASTRO"
|
|
className={`p-2.5 rounded-xl border transition-all ${copiedId === d.id ? 'bg-green-50 text-green-600 border-green-200' : 'bg-gray-50 text-gray-400 border-gray-100 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-100'}`}
|
|
>
|
|
{copiedId === d.id ? <Check size={18} /> : <Share2 size={18} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="border-t border-gray-100 p-2 grid grid-cols-3 gap-1">
|
|
<button onClick={() => { setEditingDentista(d); setIsModalOpen(true); }} className="text-[10px] uppercase font-bold text-gray-600 hover:bg-gray-100 py-2 rounded-md flex items-center justify-center gap-1"><Edit size={12} /> EDITAR</button>
|
|
<button onClick={() => { setSelectedDentist(d); setIsHoursModalOpen(true); }} className="text-[10px] uppercase font-bold text-blue-600 hover:bg-blue-50 py-2 rounded-md flex items-center justify-center gap-1"><Clock size={12} /> ESCALA</button>
|
|
<button onClick={() => handleDelete(d.id)} className="text-[10px] uppercase font-bold text-red-500 hover:bg-red-50 py-2 rounded-md flex items-center justify-center gap-1"><Trash2 size={12} /> EXCLUIR</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{isModalOpen && (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-0">
|
|
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-xl flex flex-col overflow-hidden animate-in fade-in zoom-in-50 duration-200">
|
|
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-blue-50 to-white">
|
|
<h3 className="text-lg font-bold text-gray-800 uppercase">{editingDentista?.id ? 'EDITAR' : 'NOVO'} DENTISTA</h3>
|
|
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
|
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto">
|
|
<input id="dentista-nome" type="text" name="nome" placeholder="NOME COMPLETO" defaultValue={editingDentista?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
|
|
<input id="dentista-email" type="email" name="email" placeholder="EMAIL" defaultValue={editingDentista?.email} className="w-full border border-gray-300 rounded-lg p-2" required />
|
|
{/* 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 && (
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase block mb-1.5">Setor (opcional)</label>
|
|
<select id="dentista-setor" name="setor_id" defaultValue={(editingDentista as any)?.setor_id || ''} className="w-full border border-gray-300 rounded-lg p-2">
|
|
<option value="">SEM SETOR (geral — visível a todos)</option>
|
|
{setores.map(s => <option key={s.id} value={s.id}>{s.nome}</option>)}
|
|
</select>
|
|
</div>
|
|
)}
|
|
{/* Especialidades que o dentista atende (várias) */}
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase block mb-1.5">Especialidades que atende *</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{(especialidadesList || []).map(esp => {
|
|
const on = selEsp.includes(esp.nome);
|
|
return (
|
|
<button type="button" key={esp.id} onClick={() => setSelEsp(s => on ? s.filter(x => x !== esp.nome) : [...s, esp.nome])}
|
|
className={`px-3 py-1.5 rounded-full text-[11px] font-black uppercase border-2 transition-colors ${on ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-white border-gray-200 text-gray-500 hover:border-gray-300'}`}>
|
|
{esp.nome}
|
|
</button>
|
|
);
|
|
})}
|
|
{(!especialidadesList || especialidadesList.length === 0) && (
|
|
<span className="text-[11px] text-gray-400 font-bold uppercase">Cadastre especialidades primeiro (menu Especialidades).</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* Dias de atendimento na clínica */}
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase block mb-1.5">Dias de atendimento</label>
|
|
<div className="flex gap-1.5">
|
|
{['DOM', 'SEG', 'TER', 'QUA', 'QUI', 'SEX', 'SÁB'].map((dia, i) => {
|
|
const on = selDias.includes(i);
|
|
return (
|
|
<button type="button" key={i} onClick={() => setSelDias(s => on ? s.filter(x => x !== i) : [...s, i])}
|
|
className={`flex-1 py-2 rounded-lg text-[10px] font-black uppercase border-2 transition-colors ${on ? 'bg-emerald-50 border-emerald-300 text-emerald-700' : 'bg-white border-gray-200 text-gray-400 hover:border-gray-300'}`}>
|
|
{dia}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<p className="text-[10px] text-gray-400 mt-1">Selecione os dias em que ele atende nesta clínica.</p>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="dentista-cor" className="text-xs font-bold text-gray-500 uppercase">COR NA AGENDA</label>
|
|
<input id="dentista-cor" type="color" name="corAgenda" defaultValue={editingDentista?.corAgenda || '#3B82F6'} className="w-full h-10 border border-gray-300 rounded-lg p-1" />
|
|
</div>
|
|
<button type="submit" className="w-full bg-green-600 text-white py-2 rounded-lg font-bold hover:bg-green-700 uppercase">SALVAR</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isHoursModalOpen && selectedDentist && activeWorkspace && (
|
|
<DentistHoursModal
|
|
dentist={selectedDentist}
|
|
clinicaId={activeWorkspace.id}
|
|
onClose={() => { setIsHoursModalOpen(false); setSelectedDentist(null); }}
|
|
/>
|
|
)}
|
|
|
|
{novaCredencial && (
|
|
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center p-4 backdrop-blur-sm">
|
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6 space-y-4">
|
|
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><UserPlus size={16} className="text-emerald-600" /> Conta do dentista criada</h3>
|
|
<p className="text-[11px] text-gray-500 font-bold uppercase leading-relaxed">Envie estes dados ao dentista — ele troca a senha no 1º acesso e já acessa a agenda da clínica.</p>
|
|
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4 space-y-3">
|
|
<div><p className="text-[9px] font-black text-gray-400 uppercase">E-mail</p><p className="font-bold text-gray-800 text-sm break-all">{novaCredencial.email}</p></div>
|
|
<div className="flex items-end justify-between gap-2">
|
|
<div><p className="text-[9px] font-black text-gray-400 uppercase flex items-center gap-1"><KeyRound size={11} /> Senha temporária</p><p className="font-mono font-black text-gray-900 text-lg tracking-widest">{novaCredencial.senha}</p></div>
|
|
<button type="button" onClick={() => { navigator.clipboard.writeText(`E-mail: ${novaCredencial.email}\nSenha: ${novaCredencial.senha}`); toast.success('CREDENCIAIS COPIADAS!'); }}
|
|
className="p-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl flex items-center gap-1 text-[10px] font-black uppercase" title="Copiar"><Copy size={15} /> Copiar</button>
|
|
</div>
|
|
</div>
|
|
<button type="button" onClick={() => setNovaCredencial(null)} className="w-full py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-[11px] font-black uppercase">Concluir</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</AdminSection>
|
|
);
|
|
};
|
|
|
|
// --- ESPECIALIDADES ---
|
|
export const EspecialidadesView: React.FC = () => {
|
|
const { data: especialidades, isLoading, error, refresh } = useHybridBackend<Especialidade[]>(HybridBackend.getEspecialidades);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [editingEsp, setEditingEsp] = useState<Partial<Especialidade> | 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 (
|
|
<AdminSection title="ESPECIALIDADES CLÍNICAS" buttonLabel="NOVA ESPECIALIDADE" onButtonClick={() => { setEditingEsp({}); setIsModalOpen(true); }}>
|
|
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-gray-50 text-gray-500 uppercase text-xs font-bold">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left w-10"></th>
|
|
<th className="px-6 py-3 text-left">NOME</th>
|
|
<th className="px-6 py-3 text-left">DESCRIÇÃO</th>
|
|
<th className="px-6 py-3 text-right">AÇÕES</th>
|
|
</tr>
|
|
</thead>
|
|
<DragDropContext onDragEnd={onDragEnd}>
|
|
<Droppable droppableId="especialidades">
|
|
{(provided) => (
|
|
<tbody {...provided.droppableProps} ref={provided.innerRef} className="divide-y divide-gray-100">
|
|
{isLoading && <tr><td colSpan={4} className="p-6 text-center"><Loader2 className="animate-spin text-blue-500 mx-auto" /></td></tr>}
|
|
{error && <tr><td colSpan={4} className="p-6 text-center text-red-500">{error.message}</td></tr>}
|
|
{!isLoading && (especialidades?.length ?? 0) === 0 && (
|
|
<tr><td colSpan={4}><EmptyState icon={Award} title="Nenhuma especialidade" description="Cadastre uma especialidade no botão acima." /></td></tr>
|
|
)}
|
|
{especialidades?.map((esp, index) => (
|
|
<Draggable key={esp.id} draggableId={esp.id} index={index}>
|
|
{(provided, snapshot) => (
|
|
<tr
|
|
ref={provided.innerRef}
|
|
{...provided.draggableProps}
|
|
className={`${snapshot.isDragging ? 'bg-blue-50 shadow-md ring-1 ring-blue-200' : 'bg-white'} ${esp.ativo === false ? 'opacity-50' : ''} transition-shadow`}
|
|
>
|
|
<td className="px-6 py-4 text-gray-400 cursor-grab active:cursor-grabbing" {...provided.dragHandleProps}>
|
|
<GripVertical size={18} />
|
|
</td>
|
|
<td className="px-6 py-4 font-bold text-gray-800">{esp.nome}
|
|
{esp.area && <span className="ml-2 text-[9px] font-black uppercase px-2 py-0.5 rounded-full text-white" style={{ backgroundColor: areaCor(esp.area) }}>{areaNome(esp.area)}</span>}
|
|
{esp.ativo === false && <span className="ml-2 text-[9px] font-black uppercase bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">Inativa</span>}</td>
|
|
<td className="px-6 py-4 text-gray-600">{esp.descricao}</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<button onClick={() => handleToggleAtivo(esp)} title={esp.ativo === false ? 'Ativar' : 'Desativar'} className="p-2 text-gray-500 hover:bg-gray-100 rounded-md">{esp.ativo === false ? <ToggleLeft size={16} /> : <ToggleRight size={16} className="text-green-600" />}</button>
|
|
<button onClick={() => { setEditingEsp(esp); setIsModalOpen(true); }} className="p-2 text-gray-500 hover:bg-gray-100 rounded-md"><Edit size={16} /></button>
|
|
<button onClick={() => handleDelete(esp.id)} className="p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 rounded-md"><Trash2 size={16} /></button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</Draggable>
|
|
))}
|
|
{provided.placeholder}
|
|
</tbody>
|
|
)}
|
|
</Droppable>
|
|
</DragDropContext>
|
|
</table>
|
|
</div>
|
|
|
|
{isModalOpen && (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-0">
|
|
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-xl flex flex-col overflow-hidden animate-in fade-in zoom-in-50 duration-200">
|
|
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-blue-50 to-white">
|
|
<h3 className="text-lg font-bold text-gray-800 uppercase">{editingEsp?.id ? 'EDITAR' : 'NOVA'} ESPECIALIDADE</h3>
|
|
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
|
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto">
|
|
<input id="esp-nome" name="nome" placeholder="NOME DA ESPECIALIDADE" defaultValue={editingEsp?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
|
|
<select name="area" defaultValue={editingEsp?.area || ''} className="w-full border border-gray-300 rounded-lg p-2">
|
|
<option value="">— Área (padrão pelo seu perfil) —</option>
|
|
{areas.map(a => <option key={a.slug} value={a.slug}>{a.nome}</option>)}
|
|
</select>
|
|
<textarea id="esp-desc" name="descricao" placeholder="BREVE DESCRIÇÃO" defaultValue={editingEsp?.descricao} className="w-full border border-gray-300 rounded-lg p-2 h-24 uppercase-textarea"></textarea>
|
|
<button type="submit" className="w-full bg-green-600 text-white py-2 rounded-lg font-bold hover:bg-green-700 uppercase">SALVAR</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</AdminSection>
|
|
);
|
|
};
|
|
|
|
// --- PROCEDIMENTOS ---
|
|
export const ProcedimentosView: React.FC = () => {
|
|
const { data: procedimentos, isLoading, error, refresh } = useHybridBackend<Procedimento[]>(HybridBackend.getProcedimentos);
|
|
const { data: especialidades } = useHybridBackend<Especialidade[]>(HybridBackend.getEspecialidades);
|
|
const { data: planos } = useHybridBackend<Plano[]>(HybridBackend.getPlanos);
|
|
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [editingProcedimento, setEditingProcedimento] = useState<Partial<Procedimento> | null>(null);
|
|
const [planValues, setPlanValues] = useState<Partial<ProcedimentoValorPlano>[]>([]);
|
|
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<Procedimento> = {
|
|
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 (
|
|
<AdminSection title="PROCEDIMENTOS E VALORES" buttonLabel="NOVO PROCEDIMENTO" onButtonClick={() => { setEditingProcedimento({}); setIsModalOpen(true); }}>
|
|
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-gray-50 text-gray-500 uppercase text-xs font-bold">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left w-10"></th>
|
|
<th className="px-6 py-3 text-left">NOME</th>
|
|
<th className="px-6 py-3 text-left">CÓDIGO (TUSS/INT)</th>
|
|
<th className="px-6 py-3 text-left">CATEGORIA</th>
|
|
<th className="px-6 py-3 text-left">ESPECIALIDADE</th>
|
|
<th className="px-6 py-3 text-left">VALOR PARTICULAR</th>
|
|
<th className="px-6 py-3 text-right">AÇÕES</th>
|
|
</tr>
|
|
</thead>
|
|
<DragDropContext onDragEnd={onDragEnd}>
|
|
<Droppable droppableId="procedimentos">
|
|
{(provided) => (
|
|
<tbody {...provided.droppableProps} ref={provided.innerRef} className="divide-y divide-gray-100">
|
|
{isLoading && <tr><td colSpan={7} className="p-6 text-center"><Loader2 className="animate-spin text-blue-500 mx-auto" /></td></tr>}
|
|
{error && <tr><td colSpan={7} className="p-6 text-center text-red-500">{error.message}</td></tr>}
|
|
{!isLoading && (procedimentos?.length ?? 0) === 0 && (
|
|
<tr><td colSpan={7}><EmptyState icon={ClipboardList} title="Nenhum procedimento" description="Cadastre um procedimento no botão acima." /></td></tr>
|
|
)}
|
|
{procedimentos?.map((proc, index) => (
|
|
<Draggable key={proc.id} draggableId={proc.id} index={index}>
|
|
{(provided, snapshot) => (
|
|
<tr
|
|
ref={provided.innerRef}
|
|
{...provided.draggableProps}
|
|
className={`${snapshot.isDragging ? 'bg-blue-50 shadow-md ring-1 ring-blue-200' : 'bg-white'} ${proc.ativo === false ? 'opacity-50' : ''} transition-shadow`}
|
|
>
|
|
<td className="px-6 py-4 text-gray-400 cursor-grab active:cursor-grabbing" {...provided.dragHandleProps}>
|
|
<GripVertical size={18} />
|
|
</td>
|
|
<td className="px-6 py-4 font-bold text-gray-800">{proc.nome}
|
|
{proc.area && <span className="ml-2 text-[9px] font-black uppercase px-2 py-0.5 rounded-full text-white" style={{ backgroundColor: areaCor(proc.area) }}>{areaNome(proc.area)}</span>}
|
|
{proc.ativo === false && <span className="ml-2 text-[9px] font-black uppercase bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">Inativo</span>}</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-[10px] font-bold text-gray-400 uppercase">TUSS: {proc.codigo || '---'}</span>
|
|
<span className="text-[10px] font-bold text-blue-600 uppercase bg-blue-50 px-1.5 py-0.5 rounded w-fit">INT: {proc.codigoInterno || '---'}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4"><span className="text-xs font-bold px-2 py-1 rounded-full bg-gray-100 text-gray-600 uppercase">{proc.categoria}</span></td>
|
|
<td className="px-6 py-4 text-gray-600">{proc.especialidadeNome}</td>
|
|
<td className="px-6 py-4 font-semibold text-gray-700">R$ {Number(proc.valorParticular).toFixed(2)}</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<button onClick={() => handleToggleAtivo(proc)} title={proc.ativo === false ? 'Ativar' : 'Desativar'} className="p-2 text-gray-500 hover:bg-gray-100 rounded-md">{proc.ativo === false ? <ToggleLeft size={16} /> : <ToggleRight size={16} className="text-green-600" />}</button>
|
|
<button onClick={() => { setEditingProcedimento(proc); setIsModalOpen(true); }} className="p-2 text-gray-500 hover:bg-gray-100 rounded-md"><Edit size={16} /></button>
|
|
<button onClick={() => handleDelete(proc.id)} className="p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 rounded-md"><Trash2 size={16} /></button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</Draggable>
|
|
))}
|
|
{provided.placeholder}
|
|
</tbody>
|
|
)}
|
|
</Droppable>
|
|
</DragDropContext>
|
|
</table>
|
|
</div>
|
|
|
|
{isModalOpen && (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-0">
|
|
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-xl flex flex-col overflow-hidden animate-in fade-in zoom-in-50 duration-200">
|
|
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-blue-50 to-white">
|
|
<h3 className="text-lg font-bold text-gray-800 uppercase">{editingProcedimento?.id ? 'EDITAR' : 'NOVO'} PROCEDIMENTO</h3>
|
|
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
|
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto pb-4">
|
|
<input name="nome" placeholder="NOME DO PROCEDIMENTO" defaultValue={editingProcedimento?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
|
|
<select name="area" defaultValue={editingProcedimento?.area || ''} className="w-full border border-gray-300 rounded-lg p-2">
|
|
<option value="">— Área (padrão pelo seu perfil) —</option>
|
|
{areas.map(a => <option key={a.slug} value={a.slug}>{a.nome}</option>)}
|
|
</select>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase">CATEGORIA</label>
|
|
<select
|
|
name="categoria"
|
|
value={editingProcedimento?.categoria || 'Particular'}
|
|
onChange={(e) => {
|
|
const cat = e.target.value as any;
|
|
setEditingProcedimento(prev => ({ ...prev, categoria: cat }));
|
|
if (cat === 'Particular' && !editingProcedimento?.codigoInterno) {
|
|
suggestInternalCode();
|
|
}
|
|
}}
|
|
className="w-full border border-gray-300 rounded-lg p-2 bg-white uppercase-select"
|
|
required
|
|
>
|
|
<option value="Particular">Particular</option>
|
|
<option value="Plano">Plano</option>
|
|
<option value="Convênio">Convênio</option>
|
|
<option value="Outros">Outros</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase">CÓDIGO TUSS</label>
|
|
<input
|
|
name="codigo"
|
|
placeholder={editingProcedimento?.categoria === 'Plano' ? "00000000" : "OPCIONAL"}
|
|
defaultValue={editingProcedimento?.codigo}
|
|
className={`w-full border border-gray-300 rounded-lg p-2 uppercase-input ${editingProcedimento?.categoria !== 'Plano' ? 'bg-gray-50' : ''}`}
|
|
required={editingProcedimento?.categoria === 'Plano'}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase flex justify-between">
|
|
CÓDIGO INTERNO
|
|
{editingProcedimento?.categoria !== 'Particular' && (
|
|
<button type="button" onClick={suggestInternalCode} className="text-blue-600 hover:text-blue-800">
|
|
<RefreshCw size={12} />
|
|
</button>
|
|
)}
|
|
</label>
|
|
<input
|
|
name="codigoInterno"
|
|
placeholder={editingProcedimento?.categoria === 'Particular' ? "AUTO (CONTAGEM + 1)" : "EX: 101"}
|
|
value={editingProcedimento?.codigoInterno || ''}
|
|
onChange={(e) => 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'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<textarea name="descricao" placeholder="BREVE DESCRIÇÃO" defaultValue={editingProcedimento?.descricao} className="w-full border border-gray-300 rounded-lg p-2 h-20 uppercase-textarea"></textarea>
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase flex items-center gap-2">
|
|
DESCRIÇÃO MOBILE <span className="text-[10px] text-blue-500 font-normal normal-case">(versão curta para celular)</span>
|
|
</label>
|
|
<input name="descricao_mobile" placeholder="EX: RESTAURAÇÃO COMP." defaultValue={editingProcedimento?.descricao_mobile} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase">ESPECIALIDADE</label>
|
|
<select
|
|
name="especialidadeId"
|
|
value={editingProcedimento?.especialidadeId || ''}
|
|
onChange={(e) => {
|
|
const espId = e.target.value;
|
|
const espNorm = normEsp(especialidades?.find(es => es.id === espId)?.nome || '');
|
|
const willHide = espNorm.includes('dentistic') || espNorm.includes('endodont');
|
|
setEditingProcedimento(prev => ({
|
|
...prev,
|
|
especialidadeId: espId,
|
|
tipo_regiao: (willHide && prev?.tipo_regiao === 'ARCO') ? 'DENTE' : prev?.tipo_regiao,
|
|
num_faces: undefined,
|
|
num_raizes: undefined,
|
|
}));
|
|
}}
|
|
className="w-full border border-gray-300 rounded-lg p-2 bg-white uppercase-select"
|
|
required
|
|
>
|
|
<option value="">SELECIONE UMA ESPECIALIDADE</option>
|
|
{especialidades?.map(e => <option key={e.id} value={e.id}>{e.nome}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase">VALOR PARTICULAR (R$)</label>
|
|
<input type="number" step="0.01" name="valorParticular" placeholder="0.00" defaultValue={editingProcedimento?.valorParticular} className="w-full border border-gray-300 rounded-lg p-2" required />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-blue-50/50 p-4 rounded-xl border border-blue-100 space-y-4">
|
|
<h4 className="text-[10px] font-black text-blue-600 uppercase tracking-widest flex items-center gap-2">
|
|
<Activity size={12} /> REGRA DO ODONTOGRAMA (GTO)
|
|
</h4>
|
|
|
|
<div className="space-y-3">
|
|
<label className="text-xs font-bold text-gray-700 uppercase">EXIGÊNCIA DE REGIÃO NO LANÇAMENTO</label>
|
|
<div className={`grid gap-2 ${hideArco ? 'grid-cols-2' : 'grid-cols-3'}`}>
|
|
{[
|
|
{ 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) => (
|
|
<label
|
|
key={opt.id}
|
|
className={`
|
|
relative flex flex-col p-3 rounded-lg border-2 cursor-pointer transition-all
|
|
${(editingProcedimento?.tipo_regiao || 'GERAL') === opt.id
|
|
? 'border-blue-600 bg-blue-50 ring-2 ring-blue-100'
|
|
: 'border-gray-200 bg-white hover:border-gray-300'}
|
|
`}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="tipo_regiao"
|
|
value={opt.id}
|
|
checked={(editingProcedimento?.tipo_regiao || 'GERAL') === opt.id}
|
|
onChange={(e) => setEditingProcedimento(prev => ({ ...prev, tipo_regiao: e.target.value as any }))}
|
|
className="sr-only"
|
|
/>
|
|
<span className="text-[10px] font-black uppercase text-gray-900 leading-tight">{opt.label}</span>
|
|
<span className="text-[9px] font-bold text-gray-400 mt-1 uppercase">{opt.desc}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{(editingProcedimento?.tipo_regiao === 'DENTE') && (
|
|
<div className="pt-2 animate-in slide-in-from-top-2 duration-200">
|
|
<label className="flex items-center gap-3 p-3 bg-white rounded-lg border border-blue-200 cursor-pointer hover:bg-blue-50/50 transition-colors">
|
|
<div className="relative flex items-center">
|
|
<input
|
|
type="checkbox"
|
|
name="exige_face"
|
|
checked={!!editingProcedimento?.exige_face}
|
|
onChange={(e) => setEditingProcedimento(prev => ({ ...prev, exige_face: e.target.checked }))}
|
|
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-[11px] font-black text-gray-800 uppercase">Exigir preenchimento de Face?</span>
|
|
<span className="text-[9px] font-bold text-gray-400 uppercase">(O, M, D, V, L, P) será obrigatório na GTO</span>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
{isDentistica && (
|
|
<div className="pt-2 animate-in slide-in-from-top-2 duration-200">
|
|
<label className="text-[10px] font-black text-blue-700 uppercase tracking-widest mb-1.5 block">
|
|
Nº de Faces (Dentística)
|
|
</label>
|
|
<select
|
|
value={editingProcedimento?.num_faces || ''}
|
|
onChange={(e) => setEditingProcedimento(prev => ({ ...prev, num_faces: e.target.value ? parseInt(e.target.value) : undefined }))}
|
|
className="w-full border border-blue-200 rounded-lg p-2.5 bg-white text-sm font-bold text-gray-700 focus:border-blue-500 outline-none"
|
|
>
|
|
<option value="">NÃO DEFINIDO</option>
|
|
{[1, 2, 3, 4, 5].map(n => (
|
|
<option key={n} value={n}>{n} {n === 1 ? 'face' : 'faces'}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{isEndodontia && (
|
|
<div className="pt-2 animate-in slide-in-from-top-2 duration-200">
|
|
<label className="text-[10px] font-black text-blue-700 uppercase tracking-widest mb-1.5 block">
|
|
Nº de Raízes (Endodontia)
|
|
</label>
|
|
<select
|
|
value={editingProcedimento?.num_raizes || ''}
|
|
onChange={(e) => setEditingProcedimento(prev => ({ ...prev, num_raizes: e.target.value ? parseInt(e.target.value) : undefined }))}
|
|
className="w-full border border-blue-200 rounded-lg p-2.5 bg-white text-sm font-bold text-gray-700 focus:border-blue-500 outline-none"
|
|
>
|
|
<option value="">NÃO DEFINIDO</option>
|
|
{[1, 2, 3, 4, 5].map(n => (
|
|
<option key={n} value={n}>{n} {n === 1 ? 'raiz' : 'raízes'}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="pt-4">
|
|
<h4 className="font-bold text-gray-700 uppercase mb-2">VALORES PARA PLANOS / CONVÊNIOS</h4>
|
|
<div className="space-y-2">
|
|
{planValues.map((pv, index) => (
|
|
<div key={index} className="flex flex-col md:flex-row items-center gap-2 bg-gray-50 p-2 rounded-lg border">
|
|
<select value={pv.planoId} onChange={(e) => handlePlanValueChange(index, 'planoId', e.target.value)} className="flex-1 border border-gray-300 rounded-lg p-2 bg-white uppercase-select">
|
|
<option value="">SELECIONE UM PLANO</option>
|
|
{planos?.filter(p => p.tipo !== 'Particular').map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
|
|
</select>
|
|
<input type="number" step="0.01" placeholder="VALOR (R$)" value={pv.valor} onChange={(e) => handlePlanValueChange(index, 'valor', e.target.value)} className="w-32 border border-gray-300 rounded-lg p-2" />
|
|
<input type="text" placeholder="CÓD. PLANO" value={pv.codigoPlano} onChange={(e) => handlePlanValueChange(index, 'codigoPlano', e.target.value)} className="w-32 border border-gray-300 rounded-lg p-2 uppercase-input" />
|
|
<button type="button" onClick={() => handleRemovePlanValue(index)} className="p-2 text-red-500 hover:bg-red-100 rounded-md"><Trash2 size={16} /></button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button type="button" onClick={handleAddPlanValue} className="mt-2 text-xs font-bold text-blue-600 hover:underline uppercase flex items-center gap-1"><Plus size={14} /> ADICIONAR VALOR DE PLANO</button>
|
|
</div>
|
|
|
|
<div className="pt-4 flex-shrink-0">
|
|
<button type="submit" className="w-full bg-green-600 text-white py-4 rounded-xl font-black hover:bg-green-700 uppercase text-xs shadow-lg shadow-green-100 transition-all active:scale-[0.98]">
|
|
SALVAR PROCEDIMENTO
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</AdminSection>
|
|
);
|
|
};
|
|
|
|
|
|
// --- PLANOS ---
|
|
export const PlanosView: React.FC = () => {
|
|
const { data: planos, isLoading, error, refresh } = useHybridBackend<Plano[]>(HybridBackend.getPlanos);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [editingPlano, setEditingPlano] = useState<Partial<Plano> | 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 (
|
|
<AdminSection title="PLANOS E CONVÊNIOS" buttonLabel="NOVO PLANO" onButtonClick={() => { setEditingPlano({}); setIsModalOpen(true); }}>
|
|
{isLoading && <Loader2 className="animate-spin text-blue-500" />}
|
|
{error && <p className="text-red-500">Erro: {error.message}</p>}
|
|
{!isLoading && (planos?.length ?? 0) === 0 && (
|
|
<EmptyState icon={BookOpen} title="Nenhum plano ou convênio" description="Adicione um plano no botão “Novo plano” acima." />
|
|
)}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{planos?.map(p => (
|
|
<div key={p.id} className="bg-white border border-gray-200 rounded-xl shadow-sm hover:shadow-md transition-shadow flex flex-col overflow-hidden" style={{ borderTop: `4px solid ${p.corCartao}` }}>
|
|
<div className="p-5 flex-grow">
|
|
<div className="flex justify-between items-start">
|
|
<h3 className="font-bold text-gray-900 uppercase">{p.nome}</h3>
|
|
<span className="text-xs font-bold text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full uppercase">{p.tipo}</span>
|
|
</div>
|
|
<p className="text-sm text-gray-600 mt-2">DESCONTO PADRÃO: <span className="font-bold">{p.descontoPadrao}%</span></p>
|
|
</div>
|
|
<div className="border-t border-gray-100 p-2 flex gap-2 bg-gray-50/50">
|
|
<button onClick={() => { setEditingPlano(p); setIsModalOpen(true); }} className="flex-1 text-xs uppercase font-bold text-gray-600 hover:bg-gray-100 py-2 rounded-md flex items-center justify-center gap-2"><Edit size={14} /> EDITAR</button>
|
|
<button onClick={() => handleDelete(p.id)} className="flex-1 text-xs uppercase font-bold text-red-500 hover:bg-red-50 py-2 rounded-md flex items-center justify-center gap-2"><Trash2 size={14} /> EXCLUIR</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{isModalOpen && (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-0">
|
|
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-xl flex flex-col overflow-hidden animate-in fade-in zoom-in-50 duration-200">
|
|
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-blue-50 to-white">
|
|
<h3 className="text-lg font-bold text-gray-800 uppercase">{editingPlano?.id ? 'EDITAR' : 'NOVO'} PLANO</h3>
|
|
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
|
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto">
|
|
<input name="nome" placeholder="NOME DO PLANO/CONVÊNIO" defaultValue={editingPlano?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
|
|
<select name="tipo" defaultValue={editingPlano?.tipo || 'Convenio'} className="w-full border border-gray-300 rounded-lg p-2 bg-white uppercase-select" required>
|
|
<option>Convenio</option>
|
|
<option>Particular</option>
|
|
<option>Parceria</option>
|
|
</select>
|
|
<input type="number" name="descontoPadrao" placeholder="DESCONTO PADRÃO (%)" defaultValue={editingPlano?.descontoPadrao} className="w-full border border-gray-300 rounded-lg p-2" />
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase">COR DO CARTÃO</label>
|
|
<input type="color" name="corCartao" defaultValue={editingPlano?.corCartao || '#1f2937'} className="w-full h-10 border border-gray-300 rounded-lg p-1" />
|
|
</div>
|
|
<button type="submit" className="w-full bg-green-600 text-white py-2 rounded-lg font-bold hover:bg-green-700 uppercase">SALVAR</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</AdminSection>
|
|
);
|
|
};
|
|
|
|
|
|
// --- SYNC ---
|
|
const SYNC_TABS = ['pacientes', 'agendamentos', 'financeiro', 'leads', 'guias'];
|
|
|
|
type DupGroup = { criterion: string; key: string; records: any[] };
|
|
type DupData = Record<string, DupGroup[]>;
|
|
|
|
export const SyncView: React.FC = () => {
|
|
const [logs, setLogs] = useState<string[]>([]);
|
|
const [operation, setOperation] = useState<'push' | 'pull' | 'import-legacy' | 'limpar' | 'limpar-ag' | 'truncate' | null>(null);
|
|
const [status, setStatus] = useState<any>(null);
|
|
const [dupData, setDupData] = useState<DupData | null>(null);
|
|
const [dupTab, setDupTab] = useState<string>('pacientes');
|
|
const [selectedDups, setSelectedDups] = useState<Set<string>>(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<string, string> = {
|
|
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<string, string> = {
|
|
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<string, React.ReactNode> = {
|
|
pacientes: <Users size={14} className="text-blue-400" />,
|
|
agendamentos: <Calendar size={14} className="text-violet-400" />,
|
|
financeiro: <Banknote size={14} className="text-emerald-400" />,
|
|
leads: <UserPlus size={14} className="text-amber-400" />,
|
|
guias: <ClipboardList size={14} className="text-rose-400" />,
|
|
};
|
|
|
|
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 (
|
|
<div className="max-w-4xl mx-auto space-y-5 pb-8">
|
|
|
|
{/* Header */}
|
|
<div className="flex flex-col gap-0.5">
|
|
<h1 className="text-xl font-black text-gray-900 uppercase tracking-tight">Backup & Sincronização</h1>
|
|
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">PostgreSQL ↔ Google Sheets · Atualização automática</p>
|
|
</div>
|
|
|
|
{/* Ações principais — Backup / Restaurar */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<button
|
|
onClick={() => runSync('push')}
|
|
disabled={!!operation}
|
|
className="group relative overflow-hidden rounded-2xl p-5 text-left transition-all duration-300 disabled:opacity-40 disabled:cursor-not-allowed bg-gradient-to-br from-emerald-500 to-green-600 shadow-lg shadow-green-200/60 hover:shadow-xl hover:shadow-green-300/50 hover:scale-[1.01] active:scale-[0.99]"
|
|
>
|
|
<div className="absolute -top-6 -right-6 w-24 h-24 bg-white/10 rounded-full group-hover:scale-125 transition-transform duration-500" />
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-2.5 bg-white/20 rounded-xl backdrop-blur-sm flex-shrink-0">
|
|
<RefreshCw size={20} className={`text-white ${operation === 'push' ? 'animate-spin' : ''}`} />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-black text-white uppercase tracking-tight leading-none">
|
|
{operation === 'push' ? 'Enviando...' : 'Backup Agora'}
|
|
</p>
|
|
<p className="text-[10px] text-white/70 font-bold uppercase mt-1">PostgreSQL → Google Sheets</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => runSync('pull')}
|
|
disabled={!!operation}
|
|
className="group relative overflow-hidden rounded-2xl p-5 text-left transition-all duration-300 disabled:opacity-40 disabled:cursor-not-allowed bg-gradient-to-br from-blue-500 to-blue-700 shadow-lg shadow-blue-200/60 hover:shadow-xl hover:shadow-blue-300/50 hover:scale-[1.01] active:scale-[0.99]"
|
|
>
|
|
<div className="absolute -top-6 -right-6 w-24 h-24 bg-white/10 rounded-full group-hover:scale-125 transition-transform duration-500" />
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-2.5 bg-white/20 rounded-xl backdrop-blur-sm flex-shrink-0">
|
|
<Database size={20} className={`text-white ${operation === 'pull' ? 'animate-spin' : ''}`} />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-black text-white uppercase tracking-tight leading-none">
|
|
{operation === 'pull' ? 'Restaurando...' : 'Restaurar Backup'}
|
|
</p>
|
|
<p className="text-[10px] text-white/70 font-bold uppercase mt-1">Google Sheets → PostgreSQL</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Importação legada */}
|
|
<button
|
|
onClick={() => runSync('import-legacy')}
|
|
disabled={!!operation}
|
|
className="group w-full relative overflow-hidden rounded-2xl p-5 text-left transition-all duration-300 disabled:opacity-40 disabled:cursor-not-allowed bg-gradient-to-r from-amber-400 via-amber-500 to-orange-500 shadow-lg shadow-amber-200/60 hover:shadow-xl hover:shadow-amber-300/50 hover:scale-[1.005] active:scale-[0.998]"
|
|
>
|
|
<div className="absolute -top-8 -right-8 w-32 h-32 bg-white/10 rounded-full group-hover:scale-125 transition-transform duration-500" />
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-2.5 bg-white/20 rounded-xl backdrop-blur-sm flex-shrink-0">
|
|
<FileText size={20} className={`text-white ${operation === 'import-legacy' ? 'animate-spin' : ''}`} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-black text-white uppercase tracking-tight leading-none">
|
|
{operation === 'import-legacy' ? 'Importando Pacientes...' : 'Importar Planilha Legada'}
|
|
</p>
|
|
<p className="text-[10px] text-white/75 font-bold uppercase mt-1">Aba PACIENTES-CONSULTT-CLINIC · Vincula à clínica ativa</p>
|
|
</div>
|
|
<ArrowRight size={18} className="text-white/50 group-hover:translate-x-1 transition-transform duration-200 flex-shrink-0" />
|
|
</div>
|
|
</button>
|
|
|
|
{/* Zona de Perigo */}
|
|
<div className="rounded-2xl border border-red-100 overflow-hidden">
|
|
<div className="px-5 py-3 bg-red-50/80 border-b border-red-100 flex items-center gap-2">
|
|
<AlertTriangle size={12} className="text-red-500 flex-shrink-0" />
|
|
<span className="text-[9px] font-black text-red-600 uppercase tracking-widest">Zona de Perigo — Operações irreversíveis</span>
|
|
</div>
|
|
<div className="p-3 bg-white grid grid-cols-1 sm:grid-cols-3 gap-2">
|
|
<button
|
|
onClick={handleLimparLegado}
|
|
disabled={!!operation}
|
|
className="group flex items-center gap-3 p-4 rounded-xl border border-red-100 hover:border-red-200 hover:bg-red-50/60 transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed text-left"
|
|
>
|
|
<div className="p-2 bg-red-50 group-hover:bg-red-100 rounded-lg transition-colors flex-shrink-0">
|
|
<Trash2 size={15} className={`text-red-500 ${operation === 'limpar' ? 'animate-spin' : ''}`} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[11px] font-black text-gray-800 uppercase tracking-tight">
|
|
{operation === 'limpar' ? 'Removendo...' : 'Limpar Pacientes Legados'}
|
|
</p>
|
|
<p className="text-[9px] text-gray-400 font-bold uppercase mt-0.5">Sem clínica vinculada</p>
|
|
</div>
|
|
</button>
|
|
<button
|
|
onClick={handleLimparAgendamentos}
|
|
disabled={!!operation}
|
|
className="group flex items-center gap-3 p-4 rounded-xl border border-red-100 hover:border-red-200 hover:bg-red-50/60 transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed text-left"
|
|
>
|
|
<div className="p-2 bg-red-50 group-hover:bg-red-100 rounded-lg transition-colors flex-shrink-0">
|
|
<Trash2 size={15} className={`text-red-500 ${operation === 'limpar-ag' ? 'animate-spin' : ''}`} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[11px] font-black text-gray-800 uppercase tracking-tight">
|
|
{operation === 'limpar-ag' ? 'Removendo...' : 'Limpar Agendamentos Legados'}
|
|
</p>
|
|
<p className="text-[9px] text-gray-400 font-bold uppercase mt-0.5">Sem clínica vinculada</p>
|
|
</div>
|
|
</button>
|
|
{truncateConfirmMode ? (
|
|
<div className="flex flex-col gap-2 p-4 rounded-xl border-2 border-red-400 bg-red-50/80">
|
|
<p className="text-[10px] font-black text-red-700 uppercase">Digite <span className="font-mono bg-red-100 px-1 rounded">CONFIRMAR</span> para zerar:</p>
|
|
<input
|
|
autoFocus
|
|
value={truncateInput}
|
|
onChange={e => 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"
|
|
/>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={executeTruncateAll}
|
|
disabled={truncateInput !== 'CONFIRMAR' || !!operation}
|
|
className="flex-1 py-1.5 rounded-lg text-[10px] font-black uppercase text-white bg-red-600 hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{operation === 'truncate' ? 'Zerando...' : 'Executar'}
|
|
</button>
|
|
<button onClick={() => { setTruncateConfirmMode(false); setTruncateInput(''); }} className="px-3 py-1.5 rounded-lg text-[10px] font-black uppercase text-gray-500 bg-white border border-gray-200 hover:bg-gray-50 transition-colors">Cancelar</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => setTruncateConfirmMode(true)}
|
|
disabled={!!operation}
|
|
className="group flex items-center gap-3 p-4 rounded-xl border-2 border-red-300 hover:border-red-500 hover:bg-red-50/80 transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed text-left"
|
|
>
|
|
<div className="p-2 bg-red-100 group-hover:bg-red-200 rounded-lg transition-colors flex-shrink-0">
|
|
<Trash2 size={15} className={`text-red-700 ${operation === 'truncate' ? 'animate-spin' : ''}`} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[11px] font-black text-red-700 uppercase tracking-tight">Zerar Banco de Dados</p>
|
|
<p className="text-[9px] text-red-400 font-bold uppercase mt-0.5">Todas as tabelas · Sheets intacta</p>
|
|
</div>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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 (
|
|
<div className="rounded-2xl border border-violet-100 bg-violet-50/40 px-5 py-4 flex items-center gap-3">
|
|
<Loader2 size={14} className="text-violet-400 animate-spin" />
|
|
<span className="text-[10px] font-black text-violet-500 uppercase tracking-widest">Verificando duplicatas...</span>
|
|
</div>
|
|
);
|
|
|
|
if (!hasDups) return (
|
|
<div className="rounded-2xl border border-gray-100 bg-gray-50/60 px-5 py-4 flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<Check size={13} className="text-emerald-500" />
|
|
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Nenhuma duplicata detectada</span>
|
|
</div>
|
|
<button onClick={loadDuplicates} className="text-[9px] font-bold text-gray-300 hover:text-gray-500 uppercase tracking-widest transition-colors">Verificar novamente</button>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="rounded-2xl border border-violet-200 overflow-hidden">
|
|
{/* Header */}
|
|
<div className="px-5 py-3 bg-violet-50 border-b border-violet-100 flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<AlertTriangle size={12} className="text-violet-500" />
|
|
<span className="text-[9px] font-black text-violet-700 uppercase tracking-widest">
|
|
Revisor de Duplicatas — {totalDups} registros suspeitos
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{selectedDups.size > 0 && (
|
|
<button
|
|
onClick={handleDeleteDups}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded-lg text-[9px] font-black uppercase tracking-widest transition-colors"
|
|
>
|
|
<Trash2 size={10} />
|
|
Remover {selectedDups.size} selecionado(s)
|
|
</button>
|
|
)}
|
|
<button onClick={loadDuplicates} className="text-[9px] font-bold text-violet-400 hover:text-violet-600 uppercase tracking-widest transition-colors">Atualizar</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs das tabelas com dups */}
|
|
<div className="flex border-b border-violet-100 bg-white">
|
|
{tabsWithDups.map(t => (
|
|
<button
|
|
key={t}
|
|
onClick={() => { setDupTab(t); setSelectedDups(new Set()); }}
|
|
className={`flex items-center gap-1.5 px-4 py-2.5 text-[9px] font-black uppercase tracking-widest border-b-2 transition-colors ${dupTab === t ? 'border-violet-500 text-violet-700 bg-violet-50/50' : 'border-transparent text-gray-400 hover:text-gray-600'}`}
|
|
>
|
|
{TAB_ICONS[t]}
|
|
{t}
|
|
<span className={`px-1.5 py-0.5 rounded-full text-[8px] font-black ${dupTab === t ? 'bg-violet-500 text-white' : 'bg-gray-100 text-gray-500'}`}>
|
|
{dupData?.[t]?.reduce((a, g) => a + g.records.length, 0)}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Grupos de duplicatas */}
|
|
<div className="p-3 bg-white space-y-3 max-h-96 overflow-y-auto">
|
|
{currentGroups.length === 0 ? (
|
|
<p className="text-[10px] text-gray-400 text-center py-4">Sem duplicatas nesta tabela.</p>
|
|
) : currentGroups.map((group, gi) => (
|
|
<div key={gi} className="border border-violet-100 rounded-xl overflow-hidden">
|
|
<div className="px-4 py-2 bg-violet-50/60 border-b border-violet-100 flex items-center gap-2">
|
|
<span className="text-[8px] font-black text-violet-500 uppercase">Critério: {group.criterion}</span>
|
|
<span className="text-[8px] font-mono text-violet-400 truncate">· {group.key}</span>
|
|
<span className="ml-auto text-[8px] font-black text-violet-400">{group.records.length} registros</span>
|
|
</div>
|
|
<div className="p-2 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
|
{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 (
|
|
<label
|
|
key={rec.id}
|
|
className={`flex items-start gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${isSelected ? 'border-red-400 bg-red-50/60' : 'border-gray-100 hover:border-violet-200 hover:bg-violet-50/30'}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onChange={() => toggleDupSelect(rec.id)}
|
|
className="mt-0.5 accent-red-500 flex-shrink-0"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p className={`text-[11px] font-black uppercase truncate ${isSelected ? 'text-red-700' : 'text-gray-800'}`}>{label}</p>
|
|
{sub1 && <p className="text-[9px] text-gray-400 font-bold truncate mt-0.5">{sub1}</p>}
|
|
{sub2 && <p className="text-[9px] text-gray-300 font-bold truncate">{sub2}</p>}
|
|
<p className="text-[8px] font-mono text-gray-200 truncate mt-1">
|
|
{rec.clinica_id ? `clinica: ${rec.clinica_id.slice(0,8)}` : 'sem clínica'}
|
|
</p>
|
|
</div>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="px-5 py-2.5 bg-violet-50/40 border-t border-violet-100 flex items-center gap-2">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-violet-400" />
|
|
<p className="text-[9px] text-violet-500 font-bold uppercase">
|
|
Marque os registros a remover · O que ficar desmarcado é mantido · A planilha não é afetada
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* Status das tabelas */}
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
|
<div className="px-5 py-3.5 border-b border-gray-50 flex flex-wrap items-center gap-2">
|
|
<h3 className="text-[10px] font-black text-gray-500 uppercase tracking-widest flex-shrink-0">Estado das Tabelas</h3>
|
|
<div className="flex items-center gap-1.5 min-w-0 ml-auto">
|
|
{editingSheetId ? (
|
|
<div className="flex items-center gap-1.5">
|
|
<input
|
|
autoFocus
|
|
value={sheetIdInput}
|
|
onChange={e => 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"
|
|
/>
|
|
<button onClick={handleSaveSheetId} className="text-[9px] font-black text-emerald-600 hover:text-emerald-700 uppercase px-2 py-1 bg-emerald-50 rounded-lg border border-emerald-100 transition-colors">Salvar</button>
|
|
<button onClick={() => { setEditingSheetId(false); setSheetIdInput(''); }} className="text-[9px] font-black text-gray-400 hover:text-gray-600 uppercase px-2 py-1 bg-gray-50 rounded-lg border border-gray-100 transition-colors">Cancelar</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{status?.spreadsheetId && (
|
|
<span className="text-[9px] font-mono text-blue-400 truncate max-w-[180px]">{status.spreadsheetId}</span>
|
|
)}
|
|
<button onClick={() => setEditingSheetId(true)} title="Alterar ID da planilha" className="p-1 rounded-lg text-gray-300 hover:text-blue-400 hover:bg-blue-50 transition-all">
|
|
<Edit size={11} />
|
|
</button>
|
|
<button onClick={handleClearSheetId} title="Remover ID da planilha" className="p-1 rounded-lg text-gray-300 hover:text-red-400 hover:bg-red-50 transition-all">
|
|
<Unlink size={11} />
|
|
</button>
|
|
<button onClick={handleResetStatus} title="Limpar histórico de sync" className="p-1 rounded-lg text-gray-300 hover:text-amber-400 hover:bg-amber-50 transition-all">
|
|
<X size={11} />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="divide-y divide-gray-50/80">
|
|
{SYNC_TABS.map(tab => {
|
|
const s = status?.syncStatus?.[tab];
|
|
const count = status?.counts?.[tab];
|
|
return (
|
|
<div key={tab} className="flex items-center gap-3 px-5 py-3">
|
|
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gray-50 flex-shrink-0">
|
|
{TAB_ICONS[tab]}
|
|
</div>
|
|
<div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${s?.lastPush ? 'bg-emerald-400' : 'bg-gray-200'}`} />
|
|
<span className="text-xs font-black uppercase text-gray-700 w-24 flex-shrink-0">{tab}</span>
|
|
<div className="flex-1 grid grid-cols-3 gap-1 text-right">
|
|
<div>
|
|
<p className="text-[8px] font-bold text-gray-300 uppercase">Banco</p>
|
|
<p className="text-sm font-black text-gray-800 leading-none">{count ?? '—'}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[8px] font-bold text-gray-300 uppercase">Backup</p>
|
|
<p className="text-[10px] font-bold text-gray-500">{fmtDate(s?.lastPush)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[8px] font-bold text-gray-300 uppercase">Planilha</p>
|
|
<p className="text-[10px] font-bold text-gray-500">{s?.count ?? '—'}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Terminal / Log */}
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
|
<div className="px-5 py-3 border-b border-gray-50 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex gap-1.5">
|
|
<div className="w-2.5 h-2.5 rounded-full bg-red-400/70" />
|
|
<div className="w-2.5 h-2.5 rounded-full bg-yellow-400/70" />
|
|
<div className="w-2.5 h-2.5 rounded-full bg-green-400/70" />
|
|
</div>
|
|
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Log de Operação</span>
|
|
</div>
|
|
{logs.length > 0 && (
|
|
<button onClick={() => setLogs([])} className="text-[9px] font-bold text-gray-300 hover:text-gray-500 uppercase tracking-widest transition-colors">limpar</button>
|
|
)}
|
|
</div>
|
|
<div className="p-4">
|
|
<div className="bg-gray-950 rounded-xl p-4 font-mono text-xs text-green-400 min-h-[140px] max-h-64 overflow-y-auto">
|
|
{logs.length === 0
|
|
? <span className="text-gray-600">$ aguardando operação...</span>
|
|
: logs.map((log, i) => (
|
|
<div key={i} className={`mb-1 pl-2 border-l-2 ${log.startsWith('[ERRO]') ? 'border-red-500 text-red-400' : log.startsWith('[✓]') ? 'border-emerald-400 text-emerald-300' : log.startsWith('>>>') ? 'border-blue-400 text-blue-300' : 'border-gray-700 text-gray-400'}`}>
|
|
{log}
|
|
</div>
|
|
))
|
|
}
|
|
{operation && <div className="animate-pulse text-green-400 mt-2">▋</div>}
|
|
</div>
|
|
</div>
|
|
<div className="px-5 py-3 bg-gray-50/60 border-t border-gray-100 flex items-start gap-2">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 mt-1.5 flex-shrink-0" />
|
|
<p className="text-[9px] text-gray-400 font-bold uppercase leading-relaxed">
|
|
Backup automático: alterações em pacientes, agendamentos, financeiro e leads são enviadas com delay de 5s.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}; |