Files
scoreodonto.com/frontend/views/ConfiguracoesView.tsx
T
VPS 4 Builder 3e64514b08 feat(financeiro+salas): motor financeiro V1/V2, glosas, GTO→financeiro, contratos de convênio e redesenho de Salas
Financeiro V1 (Épicos 1–4):
- FKs de origem no lançamento (paciente/profissional/procedimento/agendamento/contrato/realizado), soft delete e cron "Atrasado"
- prontuário de procedimentos realizados (fotos antes/depois) + regra sem-comissão (garantia/reabertura do mesmo profissional)
- normalizeFinanceiro: corrige CHECK capitalizado vs enforceUppercase (lançamentos não salvavam pela UI)
- baixa de pagamento (pago_em), despesas (fornecedor/centro de custo/recorrente), competência por data de conclusão
- relatório financeiro (período, paciente, profissional, forma, convênio, glosa)
- orçamento Assinado -> contas a receber; contrato vigente -> cobrança recorrente; renovação automática de contrato

Financeiro V2 (Comissão/Repasse):
- regras de % (padrão/procedimento/override por profissional) + custo de laboratório
- base selecionável (recebido/produção), fechamento persistido, pagar (gera despesa) + comprovante + estorno

Glosas de convênio: por item de GTO, recurso/protocolo/prazo, recuperar/estornar, impacto no relatório

GTO -> Financeiro: finalizar gera RECEITA (convênio a receber); LancarGTO passa a persistir; convênios reais (planos)

Contratos: modelo "Atendimento a Convênios / Repasse (Dentista Credenciado)" + variáveis de convênio

Salas (redesenho): perfil "Dono de Sala"; locação sempre ativa; menus MINHAS/ALUGAR SALAS;
sala como workspace isolado (seletor agrupado Clínicas x Salas, tenantGuard por dono); financeiro de
locação (reserva confirmada -> recebível); Painel da Sala (KPIs, agenda visual, recebíveis, relatório por sala)

Docs: BACKLOG-FINANCEIRO-V1, AUDITORIA-FUNCIONAL-SCOREODONTO, CHECKLIST-DEPLOY-PRODUCAO

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:28:07 +02:00

574 lines
37 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { User, Building2, ShieldCheck, LogOut, KeyRound, CheckCircle2, AlertCircle, Eye, EyeOff, Loader2, Pencil, X, Upload, Download } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { GoogleConnectButton } from '../components/GoogleConnectButton.tsx';
import { useToast } from '../contexts/ToastContext.tsx';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import { ChangePasswordModal } from '../components/ChangePasswordModal.tsx';
import { APP_VERSION } from '../constants.ts';
import { buscarCep } from '../services/viacep.ts';
const FIELD_CLS = 'w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm font-medium text-gray-800 outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-100 transition-all';
const LABEL_CLS = 'text-[10px] font-black text-gray-400 uppercase tracking-wider block mb-1';
const TIPO_LABEL: Record<string, string> = { pessoal: 'Pessoal', consultorio: 'Consultório', clinica: 'Clínica' };
// --- Card: Meu Perfil (Sistema) ---
const MeuPerfilCard: React.FC<{ clinColor: string; role: string }> = ({ clinColor, role }) => {
const toast = useToast();
const confirm = useConfirm();
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [f, setF] = useState<any>({});
const load = async () => {
setLoading(true);
try { const r = await HybridBackend.getMeuPerfil(); if (r.success) setF(r.usuario || {}); }
catch { /* ignore */ } finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const set = (k: string, v: any) => setF((p: any) => ({ ...p, [k]: v }));
const save = async () => {
if (!f.nome?.trim()) { toast.error('NOME OBRIGATÓRIO.'); return; }
setSaving(true);
try {
const r = await HybridBackend.updateMeuPerfil({
nome: f.nome, email: f.email, celular: f.celular, cro: f.cro, cro_uf: f.cro_uf,
especialidade: f.especialidade, cidade: f.cidade, bairro: f.bairro, estado: f.estado,
});
if (!r.success) { toast.error(r.message || 'ERRO AO SALVAR.'); return; }
toast.success('PERFIL ATUALIZADO!'); setEditing(false);
} catch { toast.error('ERRO AO SALVAR.'); } finally { setSaving(false); }
};
return (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-50 flex items-center justify-between">
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest">Meu Perfil (Sistema)</h2>
{!editing && !loading && (
<button onClick={() => setEditing(true)} className="flex items-center gap-1 text-[10px] font-black text-teal-600 uppercase hover:text-teal-800"><Pencil size={12} /> Editar</button>
)}
</div>
<div className="p-6">
{loading ? (
<div className="flex justify-center py-6"><Loader2 size={20} className="animate-spin text-gray-300" /></div>
) : !editing ? (
<div className="flex items-center gap-4">
<div className="w-14 h-14 bg-gray-100 rounded-full flex items-center justify-center text-gray-500 border border-gray-200 flex-shrink-0"><User size={24} /></div>
<div className="min-w-0">
<p className="text-base font-black text-gray-800 uppercase truncate leading-none mb-1">{f.nome || '—'}</p>
<p className="text-sm font-medium text-gray-400 truncate">{f.email || '—'}</p>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-1.5 text-[10px] font-bold text-gray-400 uppercase">
{f.celular && <span>{f.celular}</span>}
{f.cro && <span>CRO {f.cro_uf}/{f.cro}</span>}
{f.especialidade && <span>{f.especialidade}</span>}
</div>
<span className="inline-block mt-2 text-[10px] font-black uppercase tracking-widest px-2 py-0.5 rounded-full" style={{ backgroundColor: `${clinColor}18`, color: clinColor }}>{role}</span>
</div>
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div><label className={LABEL_CLS}>Nome *</label><input className={`${FIELD_CLS} uppercase`} value={f.nome || ''} onChange={e => set('nome', e.target.value.toUpperCase())} /></div>
<div><label className={LABEL_CLS}>E-mail</label><input type="email" className={FIELD_CLS} value={f.email || ''} onChange={e => set('email', e.target.value)} /></div>
<div><label className={LABEL_CLS}>Celular</label><input className={FIELD_CLS} value={f.celular || ''} onChange={e => set('celular', e.target.value)} placeholder="(00) 00000-0000" /></div>
<div><label className={LABEL_CLS}>Especialidade</label><input className={`${FIELD_CLS} uppercase`} value={f.especialidade || ''} onChange={e => set('especialidade', e.target.value.toUpperCase())} /></div>
<div><label className={LABEL_CLS}>CRO</label><input className={FIELD_CLS} value={f.cro || ''} onChange={e => set('cro', e.target.value)} /></div>
<div><label className={LABEL_CLS}>CRO UF</label><input maxLength={2} className={`${FIELD_CLS} uppercase`} value={f.cro_uf || ''} onChange={e => set('cro_uf', e.target.value.toUpperCase())} /></div>
<div><label className={LABEL_CLS}>Cidade</label><input className={`${FIELD_CLS} uppercase`} value={f.cidade || ''} onChange={e => set('cidade', e.target.value.toUpperCase())} /></div>
<div><label className={LABEL_CLS}>Estado</label><input maxLength={2} className={`${FIELD_CLS} uppercase`} value={f.estado || ''} onChange={e => set('estado', e.target.value.toUpperCase())} /></div>
</div>
<div className="flex gap-2 pt-1">
<button onClick={() => { setEditing(false); load(); }} className="px-4 py-2 border border-gray-200 rounded-xl text-[11px] font-black uppercase hover:bg-gray-50">Cancelar</button>
<button onClick={save} disabled={saving} className="flex-1 py-2 bg-teal-600 text-white rounded-xl text-[11px] font-black uppercase disabled:opacity-50 flex items-center justify-center gap-2">{saving ? <Loader2 size={13} className="animate-spin" /> : <CheckCircle2 size={13} />} Salvar</button>
</div>
</div>
)}
</div>
</div>
);
};
// --- Card: Perfil da Unidade (clínica/consultório/laboratório) ---
const UnidadePerfilCard: React.FC<{ workspaceId?: string; clinColor: string }> = ({ workspaceId, clinColor }) => {
const toast = useToast();
const confirm = useConfirm();
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [f, setF] = useState<any>({});
const [allAreas, setAllAreas] = useState<{ slug: string; nome: string; cor: string }[]>([]);
const [clinAreas, setClinAreas] = useState<string[]>([]);
useEffect(() => { HybridBackend.getAreas().then(setAllAreas); }, []);
const load = async () => {
if (!workspaceId) { setLoading(false); return; }
setLoading(true);
try {
const r = await HybridBackend.getClinicaProfile(workspaceId); if (r.success) setF(r.clinica || {});
setClinAreas(await HybridBackend.getClinicaAreas(workspaceId));
}
catch { /* ignore */ } finally { setLoading(false); }
};
useEffect(() => { load(); }, [workspaceId]);
const set = (k: string, v: any) => setF((p: any) => ({ ...p, [k]: v }));
const [buscandoCep, setBuscandoCep] = useState(false);
const handleCep = async (v: string) => {
set('cep', v);
if (v.replace(/\D/g, '').length === 8) {
setBuscandoCep(true);
const r = await buscarCep(v);
setBuscandoCep(false);
if (r) { setF((p: any) => ({ ...p, logradouro: r.logradouro, bairro: r.bairro, cidade: r.cidade, estado: r.estado })); toast.success('ENDEREÇO PREENCHIDO PELO CEP!'); }
else toast.error('CEP NÃO ENCONTRADO.');
}
};
const save = async () => {
if (!f.nome_fantasia?.trim()) { toast.error('NOME DA UNIDADE OBRIGATÓRIO.'); return; }
if (!workspaceId) return;
setSaving(true);
try {
const r = await HybridBackend.updateClinicaProfile(workspaceId, {
nome_fantasia: f.nome_fantasia, documento: f.documento, telefone: f.telefone, cor: f.cor,
logradouro: f.logradouro, numero: f.numero, bairro: f.bairro, cidade: f.cidade, estado: f.estado, cep: f.cep,
});
if (!r.success) { toast.error(r.message || 'ERRO AO SALVAR.'); return; }
await HybridBackend.setClinicaAreas(workspaceId, clinAreas);
toast.success('DADOS DA UNIDADE ATUALIZADOS!'); setEditing(false);
} catch { toast.error('ERRO AO SALVAR.'); } finally { setSaving(false); }
};
const endereco = [f.logradouro, f.numero, f.bairro, f.cidade && `${f.cidade}${f.estado ? '/' + f.estado : ''}`].filter(Boolean).join(', ');
return (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-50 flex items-center justify-between">
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest">Perfil da Unidade {f.tipo ? `· ${TIPO_LABEL[f.tipo] || f.tipo}` : ''}</h2>
{!editing && !loading && workspaceId && (
<button onClick={() => setEditing(true)} className="flex items-center gap-1 text-[10px] font-black text-teal-600 uppercase hover:text-teal-800"><Pencil size={12} /> Editar</button>
)}
</div>
<div className="p-6">
{loading ? (
<div className="flex justify-center py-6"><Loader2 size={20} className="animate-spin text-gray-300" /></div>
) : !workspaceId ? (
<p className="text-sm font-bold text-gray-400 uppercase">Sem unidade vinculada</p>
) : !editing ? (
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-white flex-shrink-0" style={{ backgroundColor: f.cor || clinColor }}><Building2 size={20} /></div>
<div className="flex-1 min-w-0">
<p className="text-sm font-black text-gray-800 uppercase truncate">{f.nome_fantasia || '—'}</p>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-1 text-[11px] font-medium text-gray-500">
{f.documento && <span>Doc: {f.documento}</span>}
{f.telefone && <span>{f.telefone}</span>}
</div>
{endereco && <p className="text-[11px] text-gray-400 mt-1">{endereco}{f.cep ? `${f.cep}` : ''}</p>}
<p className="text-[10px] text-gray-300 font-mono mt-1">ID: {f.id}</p>
</div>
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="md:col-span-2"><label className={LABEL_CLS}>Nome da unidade *</label><input className={`${FIELD_CLS} uppercase`} value={f.nome_fantasia || ''} onChange={e => set('nome_fantasia', e.target.value.toUpperCase())} /></div>
<div><label className={LABEL_CLS}>Documento (CNPJ/CPF)</label><input className={FIELD_CLS} value={f.documento || ''} onChange={e => set('documento', e.target.value)} placeholder="00.000.000/0000-00" /></div>
<div><label className={LABEL_CLS}>Telefone</label><input className={FIELD_CLS} value={f.telefone || ''} onChange={e => set('telefone', e.target.value)} placeholder="(00) 00000-0000" /></div>
<div><label className={LABEL_CLS}>CEP {buscandoCep && <Loader2 size={10} className="inline animate-spin ml-1 text-teal-500" />}</label><input className={FIELD_CLS} value={f.cep || ''} onChange={e => handleCep(e.target.value)} placeholder="00000-000" /></div>
<div><label className={LABEL_CLS}>Número</label><input className={FIELD_CLS} value={f.numero || ''} onChange={e => set('numero', e.target.value)} /></div>
<div className="md:col-span-2"><label className={LABEL_CLS}>Logradouro</label><input className={`${FIELD_CLS} uppercase`} value={f.logradouro || ''} onChange={e => set('logradouro', e.target.value.toUpperCase())} /></div>
<div><label className={LABEL_CLS}>Bairro</label><input className={`${FIELD_CLS} uppercase`} value={f.bairro || ''} onChange={e => set('bairro', e.target.value.toUpperCase())} /></div>
<div><label className={LABEL_CLS}>Cidade</label><input className={`${FIELD_CLS} uppercase`} value={f.cidade || ''} onChange={e => set('cidade', e.target.value.toUpperCase())} /></div>
<div><label className={LABEL_CLS}>Estado</label><input maxLength={2} className={`${FIELD_CLS} uppercase`} value={f.estado || ''} onChange={e => set('estado', e.target.value.toUpperCase())} /></div>
<div><label className={LABEL_CLS}>Cor</label><input type="color" className="w-full h-10 border border-gray-200 rounded-xl px-1" value={f.cor || clinColor} onChange={e => set('cor', e.target.value)} /></div>
</div>
<div>
<label className={LABEL_CLS}>Áreas de atuação da unidade</label>
<div className="flex flex-wrap gap-2 mt-1">
{allAreas.map(a => {
const on = clinAreas.includes(a.slug);
return (
<button key={a.slug} type="button"
onClick={() => setClinAreas(prev => on ? prev.filter(s => s !== a.slug) : [...prev, a.slug])}
className={`text-[11px] font-black uppercase px-3 py-1.5 rounded-full border transition-all ${on ? 'text-white border-transparent' : 'text-gray-500 border-gray-200 bg-white hover:bg-gray-50'}`}
style={on ? { backgroundColor: a.cor } : undefined}>
{a.nome}
</button>
);
})}
</div>
<p className="text-[10px] text-gray-400 mt-1">Uma unidade pode ter mais de uma área (ex.: odontologia + biomedicina).</p>
</div>
<div className="flex gap-2 pt-1">
<button onClick={() => { setEditing(false); load(); }} className="px-4 py-2 border border-gray-200 rounded-xl text-[11px] font-black uppercase hover:bg-gray-50">Cancelar</button>
<button onClick={save} disabled={saving} className="flex-1 py-2 bg-teal-600 text-white rounded-xl text-[11px] font-black uppercase disabled:opacity-50 flex items-center justify-center gap-2">{saving ? <Loader2 size={13} className="animate-spin" /> : <CheckCircle2 size={13} />} Salvar</button>
</div>
</div>
)}
</div>
</div>
);
};
// --- Card: Backup por clínica (Google Sheets) ---
const BackupSheetsCard: React.FC = () => {
const toast = useToast();
const confirm = useConfirm();
const [loading, setLoading] = useState(true);
const [st, setSt] = useState<any>(null);
const [busy, setBusy] = useState<'' | 'push' | 'pull' | 'setid' | 'export'>('');
const [logs, setLogs] = useState<string[]>([]);
const [sheetIdInput, setSheetIdInput] = useState('');
const exportarCsv = async () => {
setBusy('export');
try { await HybridBackend.exportPacientesCsv(); toast.success('BACKUP (.CSV) BAIXADO!'); }
catch { toast.error('ERRO AO EXPORTAR.'); } finally { setBusy(''); }
};
const load = async () => {
setLoading(true);
try { setSt(await HybridBackend.getClinicaSyncStatus()); } catch { /* ignore */ } finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const importarPlanilha = async () => {
if (!sheetIdInput.trim()) return;
if (!await confirm('Importar pacientes desta planilha? (pacientes com mesmo id são atualizados; a planilha NÃO é alterada)')) return;
setBusy('setid'); setLogs([]);
try {
const r = await HybridBackend.importPacientesDaPlanilha(sheetIdInput.trim());
setLogs(r.logs || []);
if (!r.success) { toast.error(r.message || 'ERRO AO IMPORTAR.'); return; }
toast.success(`${r.imported ?? 0} PACIENTES IMPORTADOS!`);
load();
} catch { toast.error('ERRO AO IMPORTAR.'); } finally { setBusy(''); }
};
const push = async () => {
setBusy('push'); setLogs([]);
try {
const r = await HybridBackend.clinicaSyncPush();
setLogs(r.logs || []);
if (!r.success) { toast.error(r.message || 'ERRO NO BACKUP.'); return; }
toast.success('BACKUP ENVIADO!'); load();
} catch { toast.error('ERRO NO BACKUP.'); } finally { setBusy(''); }
};
const pull = async () => {
if (!await confirm('IMPORTAR vai trazer os dados da planilha para esta unidade (registros com mesmo id são sobrescritos). Continuar?')) return;
setBusy('pull'); setLogs([]);
try {
const r = await HybridBackend.clinicaSyncPull();
setLogs(r.logs || []);
if (!r.success) { toast.error(r.message || 'ERRO AO RESTAURAR.'); return; }
toast.success('DADOS RESTAURADOS!'); load();
} catch { toast.error('ERRO AO RESTAURAR.'); } finally { setBusy(''); }
};
return (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-50 flex items-center justify-between">
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest">Backup · Google Sheets</h2>
{st?.spreadsheetUrl && <a href={st.spreadsheetUrl} target="_blank" rel="noreferrer" className="text-[10px] font-black text-emerald-600 uppercase hover:underline">Abrir planilha </a>}
</div>
<div className="p-6 space-y-4">
{/* Exportar pacientes (.csv) — sempre disponível, não depende do Google */}
<div className="flex items-center justify-between gap-3 bg-gray-50 rounded-xl p-3">
<div className="min-w-0">
<p className="text-[11px] font-black text-gray-700 uppercase">Backup dos pacientes (.csv)</p>
<p className="text-[10px] text-gray-400 leading-snug">Baixa todos os pacientes desta unidade. Guarde para contingência (ex.: usar no seu outro sistema se o servidor cair).</p>
</div>
<button onClick={exportarCsv} disabled={!!busy}
className="px-3 py-2 bg-gray-900 text-white rounded-xl text-[10px] font-black uppercase hover:bg-gray-800 disabled:opacity-50 whitespace-nowrap flex items-center gap-1.5">
{busy === 'export' ? <Loader2 size={13} className="animate-spin" /> : <Download size={13} />} Baixar CSV
</button>
</div>
{loading ? (
<div className="flex justify-center py-4"><Loader2 size={20} className="animate-spin text-gray-300" /></div>
) : !st?.connected ? (
<div className="bg-amber-50 border border-amber-100 rounded-xl p-4 text-[11px] font-bold text-amber-700 uppercase leading-relaxed">
Conecte a conta Google desta unidade na Agenda Configurações (engrenagem) para habilitar o backup. A planilha é criada automaticamente no Drive dessa conta.
</div>
) : (
<>
<p className="text-[11px] text-gray-500">Conta Google desta unidade: <strong className="text-gray-700">{st.googleEmail}</strong></p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(st.counts || {}).map(([k, v]: any) => (
<div key={k} className="bg-gray-50 rounded-xl p-3 text-center">
<p className="text-lg font-black text-gray-800">{v}</p>
<p className="text-[9px] font-black text-gray-400 uppercase">{k}</p>
</div>
))}
</div>
{st.lastSync?.lastPush && <p className="text-[10px] text-gray-400">Último backup: {new Date(st.lastSync.lastPush).toLocaleString('pt-BR')}</p>}
{/* Importar de uma planilha EXISTENTE (cole o ID ou o link) */}
<div className="bg-teal-50/60 border border-teal-100 rounded-xl p-3 space-y-2">
<p className="text-[10px] font-black text-teal-700 uppercase tracking-widest">Importar de uma planilha existente</p>
<p className="text-[10px] text-teal-600 leading-snug">Cole o ID ou o link da planilha. Precisa ter uma aba <strong>PACIENTES</strong> com as colunas <strong>id, nome, cpf, telefone, email, ultimoTratamento, convenio, dataNascimento</strong>. Compartilhe a planilha com <strong>{st.googleEmail}</strong>. (A planilha não é alterada.)</p>
<div className="flex gap-2">
<input value={sheetIdInput} onChange={e => setSheetIdInput(e.target.value)}
placeholder="ID ou link da planilha do Google"
className="flex-1 min-w-0 border border-teal-200 rounded-lg px-3 py-2 text-xs font-medium focus:outline-none focus:border-teal-400" />
<button onClick={importarPlanilha} disabled={!!busy || !sheetIdInput.trim()}
className="px-3 py-2 bg-teal-600 text-white rounded-lg text-[10px] font-black uppercase hover:bg-teal-700 disabled:opacity-50 whitespace-nowrap flex items-center gap-1.5">
{busy === 'setid' ? <Loader2 size={13} className="animate-spin" /> : <Download size={13} />} Importar
</button>
</div>
</div>
<div className="flex gap-2">
<button onClick={push} disabled={!!busy} className="flex-1 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-[11px] font-black uppercase disabled:opacity-50 flex items-center justify-center gap-2">
{busy === 'push' ? <Loader2 size={13} className="animate-spin" /> : <Upload size={13} />} Enviar backup
</button>
<button onClick={pull} disabled={!!busy || !st.spreadsheetId} className="flex-1 py-2.5 border border-gray-200 rounded-xl text-[11px] font-black uppercase text-gray-600 hover:bg-gray-50 disabled:opacity-50 flex items-center justify-center gap-2">
{busy === 'pull' ? <Loader2 size={13} className="animate-spin" /> : <Download size={13} />} Importar
</button>
</div>
{logs.length > 0 && (
<pre className="bg-gray-900 text-gray-100 text-[10px] rounded-xl p-3 max-h-40 overflow-auto whitespace-pre-wrap">{logs.join('\n')}</pre>
)}
</>
)}
</div>
</div>
);
};
export const ConfiguracoesView: React.FC = () => {
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
const [credsStatus, setCredsStatus] = useState<{ configured: boolean; clientIdHint: string | null; fromEnv: boolean } | null>(null);
const [clientId, setClientId] = useState('');
const [clientSecret, setClientSecret] = useState('');
const [showSecret, setShowSecret] = useState(false);
const [savingCreds, setSavingCreds] = useState(false);
const currentRole = HybridBackend.getCurrentRole();
const userName = HybridBackend.getCurrentUserName();
const userEmail = HybridBackend.getCurrentUser();
const activeWorkspace = HybridBackend.getActiveWorkspace();
const clinColor = activeWorkspace?.cor || '#2563eb';
const toast = useToast();
const confirm = useConfirm();
const [showChangePw, setShowChangePw] = useState(false);
const authHeader = { 'Authorization': `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}`, 'Content-Type': 'application/json' };
const fetchGoogleStatus = async () => {
try {
const clinicaId = activeWorkspace?.id || '';
const res = await fetch(`/api/auth/google/status${clinicaId ? `?clinicaId=${clinicaId}` : ''}`);
setConnectedAccounts(await res.json());
} catch {}
};
const fetchCredsStatus = async () => {
try {
const res = await fetch('/api/settings/google-credentials', { headers: authHeader });
if (res.ok) setCredsStatus(await res.json());
} catch {}
};
useEffect(() => {
fetchGoogleStatus();
if (currentRole === 'superadmin') fetchCredsStatus();
}, []);
const handleSaveCreds = async () => {
if (!clientId.trim() || !clientSecret.trim()) {
toast.error('PREENCHA OS DOIS CAMPOS.');
return;
}
setSavingCreds(true);
try {
const res = await fetch('/api/settings/google-credentials', {
method: 'POST',
headers: authHeader,
body: JSON.stringify({ clientId: clientId.trim(), clientSecret: clientSecret.trim() })
});
if (res.ok) {
toast.success('CREDENCIAIS SALVAS!');
setClientId('');
setClientSecret('');
await fetchCredsStatus();
} else {
const d = await res.json();
toast.error(d.error || 'ERRO AO SALVAR.');
}
} catch {
toast.error('FALHA NA CONEXÃO.');
} finally {
setSavingCreds(false);
}
};
const handleLogout = async () => {
if (await confirm('TEM CERTEZA QUE DESEJA SAIR DO SISTEMA?')) {
HybridBackend.logout();
}
};
return (
<div className="space-y-8 max-w-2xl">
<div className="flex items-end justify-between">
<div>
<h1 className="text-2xl font-black text-gray-900 uppercase tracking-tight">Configurações</h1>
<p className="text-sm text-gray-400 font-medium mt-1">Gerencie seu perfil e integrações</p>
</div>
<span className="bg-gray-100 text-gray-400 text-[11px] font-mono font-bold px-3 py-1 rounded-full border border-gray-200 mb-1">
{APP_VERSION}
</span>
</div>
{/* Perfil do sistema (usuário) */}
<MeuPerfilCard clinColor={clinColor} role={currentRole} />
{/* Perfil da unidade (clínica/consultório/laboratório) */}
<UnidadePerfilCard workspaceId={activeWorkspace?.id} clinColor={clinColor} />
{/* Backup por clínica (Google Sheets) */}
{activeWorkspace?.id && currentRole !== 'paciente' && <BackupSheetsCard />}
{/* Integrações — só para admin */}
{currentRole === 'superadmin' && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-50">
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest">Integrações Google</h2>
</div>
{/* Credenciais OAuth */}
<div className="p-6 space-y-4 border-b border-gray-50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<KeyRound size={15} className="text-gray-400" />
<span className="text-xs font-black text-gray-700 uppercase tracking-wide">Credenciais OAuth 2.0</span>
</div>
{credsStatus?.configured ? (
<div className="flex items-center gap-1.5 text-green-600 bg-green-50 px-3 py-1 rounded-full border border-green-100">
<CheckCircle2 size={12} />
<span className="text-[10px] font-black uppercase">
{credsStatus.fromEnv ? 'Via ENV' : 'Configurado'}
</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-amber-600 bg-amber-50 px-3 py-1 rounded-full border border-amber-100">
<AlertCircle size={12} />
<span className="text-[10px] font-black uppercase">Não configurado</span>
</div>
)}
</div>
{credsStatus?.configured && credsStatus.clientIdHint && !credsStatus.fromEnv && (
<div className="bg-gray-50 rounded-xl px-4 py-3 border border-gray-100">
<p className="text-[10px] font-bold text-gray-400 uppercase mb-1">Client ID atual</p>
<p className="text-xs font-mono text-gray-600">{credsStatus.clientIdHint}</p>
</div>
)}
{credsStatus?.fromEnv && (
<p className="text-[11px] text-gray-400 font-medium">
Credenciais carregadas via variável de ambiente. Para substituir, preencha os campos abaixo.
</p>
)}
<div className="space-y-3">
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1.5">
Client ID
</label>
<input
type="text"
value={clientId}
onChange={e => setClientId(e.target.value)}
placeholder={credsStatus?.configured ? 'Deixe vazio para manter atual' : 'xxxxxxxxxxxx.apps.googleusercontent.com'}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm font-mono focus:ring-2 focus:ring-teal-100 focus:border-teal-300 outline-none transition-all"
/>
</div>
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1.5">
Client Secret
</label>
<div className="relative">
<input
type={showSecret ? 'text' : 'password'}
value={clientSecret}
onChange={e => setClientSecret(e.target.value)}
placeholder={credsStatus?.configured ? 'Deixe vazio para manter atual' : 'GOCSPX-...'}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 pr-10 text-sm font-mono focus:ring-2 focus:ring-teal-100 focus:border-teal-300 outline-none transition-all"
/>
<button
type="button"
onClick={() => setShowSecret(s => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showSecret ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
</div>
<button
onClick={handleSaveCreds}
disabled={savingCreds || (!clientId.trim() && !clientSecret.trim())}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-xs font-black uppercase tracking-widest transition-all disabled:opacity-40 disabled:cursor-not-allowed bg-gray-900 text-white hover:bg-gray-700"
>
{savingCreds ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
{savingCreds ? 'Salvando...' : 'Salvar Credenciais'}
</button>
</div>
<p className="text-[10px] text-gray-400 leading-relaxed">
Obtenha as credenciais em{' '}
<span className="font-mono text-teal-500">console.cloud.google.com</span>
{' '} APIs & Services Credentials OAuth 2.0 Client ID.
Adicione{' '}
<span className="font-mono text-gray-600">https://scoreodonto.com/api/oauth2callback</span>
{' '}como URI de redirecionamento autorizado.
</p>
</div>
{/* Conectar conta Google */}
<div className="p-6">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3">Conta conectada</p>
<GoogleConnectButton
ownerId={activeWorkspace?.id || 'admin'}
clinicaId={activeWorkspace?.id}
isConnected={connectedAccounts.some(a => a.owner_id === (activeWorkspace?.id || 'admin') || a.owner_id === userEmail)}
onStatusChange={fetchGoogleStatus}
/>
</div>
</div>
)}
{/* Segurança */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-50">
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest">Segurança</h2>
</div>
<div className="p-6 space-y-3">
<div className="flex items-center gap-3 text-green-600 bg-green-50 px-4 py-3 rounded-xl border border-green-100">
<ShieldCheck size={16} />
<span className="text-xs font-bold uppercase tracking-wide">Sessão ativa e autenticada</span>
</div>
<button
onClick={() => setShowChangePw(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gray-50 text-gray-700 hover:bg-gray-100 rounded-xl text-xs font-black uppercase tracking-widest transition-all border border-gray-200"
>
<KeyRound size={16} />
Trocar Senha
</button>
<button
onClick={handleLogout}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-red-50 text-red-600 hover:bg-red-100 rounded-xl text-xs font-black uppercase tracking-widest transition-all border border-red-100"
>
<LogOut size={16} />
Sair do Sistema
</button>
</div>
</div>
{showChangePw && <ChangePasswordModal onClose={() => setShowChangePw(false)} onDone={() => setShowChangePw(false)} />}
</div>
);
};