702 lines
33 KiB
TypeScript
702 lines
33 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { SchoolData, SchoolProfile } from '../types';
|
|
import { dbService } from '../services/dbService';
|
|
import { Download, Upload, Trash2, Database, School, Camera, FileText, Info, AlertTriangle, X, CheckCircle, AlertCircle, Cloud, HelpCircle, RefreshCw, Plus, User } from 'lucide-react';
|
|
import { isSupabaseConfigured, uploadLogo } from '../services/supabase';
|
|
import { useDialog } from '../DialogContext';
|
|
import imageCompression from 'browser-image-compression';
|
|
|
|
interface SettingsProps {
|
|
data: SchoolData;
|
|
updateData: (newData: Partial<SchoolData>) => void;
|
|
setData: (data: SchoolData) => void;
|
|
}
|
|
|
|
const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
|
|
const { showAlert, showConfirm } = useDialog();
|
|
const [selectedProfileId, setSelectedProfileId] = useState<string>(data.profile.id || 'main-school');
|
|
const [profiles, setProfiles] = useState<SchoolProfile[]>(data.profiles || [data.profile]);
|
|
const [globalLogo, setGlobalLogo] = useState<string>(data.logo || '');
|
|
|
|
const currentProfile = profiles.find(p => p.id === selectedProfileId) || profiles[0];
|
|
|
|
const currentDirector = useMemo(() => {
|
|
const employees = data.employees || [];
|
|
const categories = data.employeeCategories || [];
|
|
|
|
return employees.find(e => {
|
|
const cat = categories.find(c => c.id === e.categoryId);
|
|
const catName = cat?.name.toLowerCase() || '';
|
|
const empName = e.name.toLowerCase();
|
|
return catName.includes('diretor') || catName.includes('diretoria') ||
|
|
empName.includes('diretor') || empName.includes('diretoria');
|
|
});
|
|
}, [data.employees, data.employeeCategories]);
|
|
|
|
const [profileForm, setProfileForm] = useState<SchoolProfile>(currentProfile);
|
|
|
|
const [showEvolutionModal, setShowEvolutionModal] = useState(false);
|
|
const [evolutionForm, setEvolutionForm] = useState({
|
|
apiUrl: data.evolutionConfig?.apiUrl || '',
|
|
instanceName: data.evolutionConfig?.instanceName || '',
|
|
apiKey: data.evolutionConfig?.apiKey || ''
|
|
});
|
|
|
|
const saveEvolutionConfig = () => {
|
|
updateData({ evolutionConfig: evolutionForm });
|
|
setShowEvolutionModal(false);
|
|
showAlert('Sucesso', 'Configurações da Evolution API salvas!', 'success');
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
setProfileForm(currentProfile);
|
|
}, [selectedProfileId, profiles]);
|
|
|
|
React.useEffect(() => {
|
|
setGlobalLogo(data.logo || '');
|
|
}, [data.logo]);
|
|
|
|
const [activeTab, setActiveTab] = useState<'perfil' | 'monitoramento'>('perfil');
|
|
const [apiLogs, setApiLogs] = useState<any[]>([]);
|
|
const [systemStats, setSystemStats] = useState<any>(null);
|
|
|
|
const fetchStats = () => {
|
|
fetch('/api/system-stats')
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.error) console.error('Erro na API:', data.error);
|
|
setSystemStats(data);
|
|
})
|
|
.catch(err => {
|
|
console.error('Erro ao buscar stats do sistema:', err);
|
|
setSystemStats({ error: true });
|
|
});
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
fetchStats();
|
|
const interval = setInterval(fetchStats, 30000); // Atualiza a cada 30s
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (activeTab === 'monitoramento') {
|
|
fetch('/api/logs')
|
|
.then(res => res.json())
|
|
.then(data => setApiLogs(data))
|
|
.catch(err => console.error('Erro ao buscar logs:', err));
|
|
}
|
|
}, [activeTab]);
|
|
|
|
const validateCNPJ = (cnpj: string) => {
|
|
cnpj = cnpj.replace(/[^\d]+/g, '');
|
|
if (cnpj === '' || cnpj.length !== 14) return false;
|
|
if (/^(\d)\1+$/.test(cnpj)) return false;
|
|
|
|
let tamanho = cnpj.length - 2;
|
|
let numeros = cnpj.substring(0, tamanho);
|
|
let digitos = cnpj.substring(tamanho);
|
|
let soma = 0;
|
|
let pos = tamanho - 7;
|
|
for (let i = tamanho; i >= 1; i--) {
|
|
soma += parseInt(numeros.charAt(tamanho - i)) * pos--;
|
|
if (pos < 2) pos = 9;
|
|
}
|
|
let resultado = soma % 11 < 2 ? 0 : 11 - (soma % 11);
|
|
if (resultado !== parseInt(digitos.charAt(0))) return false;
|
|
|
|
tamanho = tamanho + 1;
|
|
numeros = cnpj.substring(0, tamanho);
|
|
soma = 0;
|
|
pos = tamanho - 7;
|
|
for (let i = tamanho; i >= 1; i--) {
|
|
soma += parseInt(numeros.charAt(tamanho - i)) * pos--;
|
|
if (pos < 2) pos = 9;
|
|
}
|
|
resultado = soma % 11 < 2 ? 0 : 11 - (soma % 11);
|
|
if (resultado !== parseInt(digitos.charAt(1))) return false;
|
|
|
|
return true;
|
|
};
|
|
|
|
const handleZipChange = async (zip: string) => {
|
|
const cleanZip = zip.replace(/\D/g, '');
|
|
setProfileForm(prev => ({ ...prev, zip: zip.replace(/^(\d{5})(\d)/, '$1-$2').slice(0, 9) }));
|
|
|
|
if (cleanZip.length === 8) {
|
|
try {
|
|
const response = await fetch(`https://viacep.com.br/ws/${cleanZip}/json/`);
|
|
const data = await response.json();
|
|
if (!data.erro) {
|
|
setProfileForm(prev => ({
|
|
...prev,
|
|
address: data.logradouro,
|
|
city: data.localidade,
|
|
state: data.uf
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
console.error('Erro ao buscar CEP:', error);
|
|
}
|
|
}
|
|
};
|
|
|
|
const saveProfile = () => {
|
|
if (!validateCNPJ(profileForm.cnpj)) {
|
|
showAlert('Erro', 'CNPJ inválido. Por favor, insira um CNPJ verdadeiro.', 'error');
|
|
return;
|
|
}
|
|
|
|
// Check if trying to set as Matriz but another Matriz already exists
|
|
if (profileForm.type === 'matriz') {
|
|
const otherMatriz = profiles.find(p => p.type === 'matriz' && p.id !== profileForm.id);
|
|
if (otherMatriz) {
|
|
showAlert('Erro', `Já existe uma matriz cadastrada (${otherMatriz.name}). Só é permitida uma matriz.`, 'error');
|
|
return;
|
|
}
|
|
}
|
|
|
|
const updatedProfiles = profiles.map(p => p.id === profileForm.id ? profileForm : p);
|
|
const mainProfile = updatedProfiles.find(p => p.type === 'matriz') || updatedProfiles[0];
|
|
|
|
setProfiles(updatedProfiles);
|
|
updateData({ profiles: updatedProfiles, profile: mainProfile });
|
|
showAlert('Sucesso', 'Configurações salvas com sucesso!', 'success');
|
|
};
|
|
|
|
const addNewInstitution = () => {
|
|
const newId = `school-${Date.now()}`;
|
|
const newProfile: SchoolProfile = {
|
|
id: newId,
|
|
name: 'Nova Instituição',
|
|
address: '',
|
|
city: '',
|
|
state: '',
|
|
zip: '',
|
|
cnpj: '',
|
|
phone: '',
|
|
email: '',
|
|
type: 'filial'
|
|
};
|
|
setProfiles([...profiles, newProfile]);
|
|
setSelectedProfileId(newId);
|
|
};
|
|
|
|
const deleteInstitution = (id: string) => {
|
|
if (profiles.length <= 1) {
|
|
showAlert('Erro', 'É necessário ter pelo menos uma instituição cadastrada.', 'error');
|
|
return;
|
|
}
|
|
|
|
const profileToDelete = profiles.find(p => p.id === id);
|
|
if (profileToDelete?.type === 'matriz') {
|
|
showAlert('Erro', 'Não é possível excluir a instituição matriz. Altere outra para matriz primeiro.', 'error');
|
|
return;
|
|
}
|
|
|
|
showConfirm(
|
|
'Excluir Instituição?',
|
|
`Tem certeza que deseja excluir a instituição "${profileToDelete?.name}"?`,
|
|
() => {
|
|
const updatedProfiles = profiles.filter(p => p.id !== id);
|
|
setProfiles(updatedProfiles);
|
|
setSelectedProfileId(updatedProfiles[0].id);
|
|
updateData({ profiles: updatedProfiles, profile: updatedProfiles[0] });
|
|
}
|
|
);
|
|
};
|
|
|
|
|
|
|
|
const closeModal = () => {
|
|
setIsClosing(true);
|
|
setTimeout(() => {
|
|
setShowImportModal(false);
|
|
setIsClosing(false);
|
|
}, 300);
|
|
};
|
|
|
|
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
try {
|
|
showAlert('Aguarde', 'Fazendo upload e otimizando a logo...', 'info');
|
|
|
|
// Compression options
|
|
const options = {
|
|
maxSizeMB: 0.1, // 100KB
|
|
maxWidthOrHeight: 500,
|
|
useWebWorker: true
|
|
};
|
|
|
|
const compressedFile = await imageCompression(file, options);
|
|
|
|
const url = await uploadLogo(compressedFile);
|
|
if (!url) {
|
|
throw new Error("Falha ao obter a URL da logo após o upload");
|
|
}
|
|
|
|
setGlobalLogo(url);
|
|
updateData({ logo: url });
|
|
showAlert('Sucesso', 'Logo atualizada com sucesso!', 'success');
|
|
} catch (error) {
|
|
console.error('Erro ao fazer upload da imagem:', error);
|
|
showAlert('Erro', 'Falha ao processar e salvar a imagem.', 'error');
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleReset = async () => {
|
|
await dbService.resetData();
|
|
window.location.reload();
|
|
};
|
|
|
|
const formatPhone = (value: string) => {
|
|
return value
|
|
.replace(/\D/g, '')
|
|
.replace(/^(\d{2})(\d)/, '($1) $2 ')
|
|
.replace(/(\d{4})(\d)/, '$1-$2')
|
|
.slice(0, 16);
|
|
};
|
|
|
|
|
|
|
|
const inputClass = "w-full px-4 py-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm text-sm";
|
|
|
|
return (
|
|
<div className="space-y-8 animate-in fade-in duration-300 pb-20">
|
|
<header>
|
|
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Configurações</h2>
|
|
<p className="text-slate-500 font-medium">Gerencie o perfil da escola, modelo de contrato e dados.</p>
|
|
|
|
<div className="flex gap-4 mt-6 border-b border-slate-200">
|
|
<button
|
|
onClick={() => setActiveTab('perfil')}
|
|
className={`pb-2 font-bold text-sm ${activeTab === 'perfil' ? 'text-indigo-600 border-b-2 border-indigo-600' : 'text-slate-500 hover:text-slate-700'}`}
|
|
>
|
|
Perfil
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('monitoramento')}
|
|
className={`pb-2 font-bold text-sm ${activeTab === 'monitoramento' ? 'text-indigo-600 border-b-2 border-indigo-600' : 'text-slate-500 hover:text-slate-700'}`}
|
|
>
|
|
Monitoramento de API
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{activeTab === 'perfil' ? (
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
<div className="lg:col-span-2 space-y-6">
|
|
<div className="bg-white p-8 rounded-xl border border-slate-200 shadow-xl space-y-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3 text-indigo-600">
|
|
<div className="p-3 bg-indigo-50 rounded-lg">
|
|
<School size={24} />
|
|
</div>
|
|
<h3 className="text-xl font-black text-slate-800">Perfil da Instituição</h3>
|
|
</div>
|
|
<button
|
|
onClick={addNewInstitution}
|
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-all font-bold text-xs shadow-md"
|
|
>
|
|
<Plus size={16} /> Nova Instituição
|
|
</button>
|
|
</div>
|
|
|
|
{/* Institution Selector */}
|
|
<div className="flex flex-wrap gap-2 mb-6">
|
|
{profiles.map(p => (
|
|
<div key={p.id} className="flex items-center">
|
|
<button
|
|
onClick={() => setSelectedProfileId(p.id)}
|
|
className={`px-4 py-2 rounded-lg font-bold text-xs transition-all border ${
|
|
selectedProfileId === p.id
|
|
? 'bg-indigo-600 text-white border-indigo-600 shadow-md'
|
|
: 'bg-white text-slate-600 border-slate-200 hover:border-indigo-300'
|
|
}`}
|
|
>
|
|
{p.name} {p.type === 'matriz' && '(Matriz)'}
|
|
</button>
|
|
{p.id !== selectedProfileId && p.type !== 'matriz' && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); deleteInstitution(p.id); }}
|
|
className="ml-1 p-1 text-red-400 hover:text-red-600 transition-colors"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex flex-col md:flex-row gap-8">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<div className="w-40 h-40 rounded-xl bg-slate-50 border-2 border-dashed border-slate-200 flex items-center justify-center overflow-hidden relative group shadow-inner">
|
|
{globalLogo ? (
|
|
<img src={globalLogo} alt="Logo" className="w-full h-full object-contain p-2" />
|
|
) : (
|
|
<div className="text-slate-300 text-center p-4">
|
|
<Camera size={40} className="mx-auto mb-2 opacity-20" />
|
|
<span className="text-[10px] font-bold uppercase text-slate-500">Logo Global</span>
|
|
</div>
|
|
)}
|
|
<label className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer text-white">
|
|
<Upload size={24} />
|
|
<input type="file" accept="image/*" className="hidden" onChange={handleLogoUpload} />
|
|
</label>
|
|
</div>
|
|
<p className="text-[10px] font-bold text-slate-400 uppercase text-center">Logo única para todas as unidades</p>
|
|
</div>
|
|
|
|
<div className="flex-1 space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Nome da Escola</label>
|
|
<input className={inputClass} value={profileForm.name} onChange={e => setProfileForm({...profileForm, name: e.target.value})} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">CNPJ</label>
|
|
<input className={inputClass} placeholder="00.000.000/0001-00" value={profileForm.cnpj} onChange={e => setProfileForm({...profileForm, cnpj: e.target.value})} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="md:col-span-1">
|
|
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">CEP</label>
|
|
<input className={inputClass} placeholder="00000-000" value={profileForm.zip} onChange={e => handleZipChange(e.target.value)} />
|
|
</div>
|
|
<div className="md:col-span-2">
|
|
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Endereço</label>
|
|
<input className={inputClass} value={profileForm.address} onChange={e => setProfileForm({...profileForm, address: e.target.value})} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="md:col-span-1">
|
|
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Cidade</label>
|
|
<input className={inputClass} value={profileForm.city} onChange={e => setProfileForm({...profileForm, city: e.target.value})} />
|
|
</div>
|
|
<div className="md:col-span-1">
|
|
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Estado (UF)</label>
|
|
<input className={inputClass} placeholder="UF" value={profileForm.state} onChange={e => setProfileForm({...profileForm, state: e.target.value.toUpperCase().slice(0, 2)})} />
|
|
</div>
|
|
<div className="md:col-span-1">
|
|
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Tipo</label>
|
|
<select
|
|
className={inputClass}
|
|
value={profileForm.type}
|
|
onChange={e => setProfileForm({...profileForm, type: e.target.value as 'matriz' | 'filial'})}
|
|
>
|
|
<option value="matriz">Matriz</option>
|
|
<option value="filial">Filial</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Telefone</label>
|
|
<input className={inputClass} placeholder="(00) 0 0000-0000" value={profileForm.phone} onChange={e => setProfileForm({...profileForm, phone: formatPhone(e.target.value)})} maxLength={16} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Email</label>
|
|
<input className={inputClass} placeholder="Email" value={profileForm.email} onChange={e => setProfileForm({...profileForm, email: e.target.value})} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4">
|
|
<button
|
|
onClick={saveProfile}
|
|
className="w-full py-4 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition-all shadow-lg font-bold text-sm"
|
|
>
|
|
Salvar Perfil da Instituição
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
{/* POSTGRESQL CARD */}
|
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl space-y-4 relative overflow-hidden">
|
|
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-50 rounded-full -mr-16 -mt-16 pointer-events-none"></div>
|
|
<div className="flex items-center justify-between relative z-10">
|
|
<div className="flex items-center gap-3 text-blue-600">
|
|
<div className="p-2 bg-blue-50 rounded-lg shadow-sm border border-blue-100">
|
|
<Database size={20} />
|
|
</div>
|
|
<h3 className="text-lg font-black text-slate-800">Banco de Dados</h3>
|
|
</div>
|
|
{systemStats ? (
|
|
<span className="flex items-center gap-1.5 px-2.5 py-1 bg-emerald-100 text-emerald-700 rounded-full text-[10px] font-black uppercase tracking-wider">
|
|
<CheckCircle size={12} /> Online
|
|
</span>
|
|
) : (
|
|
<RefreshCw size={16} className="text-slate-300 animate-spin" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 mt-4 relative z-10">
|
|
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100 shadow-inner">
|
|
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Tamanho em Disco</p>
|
|
<p className="text-xl font-black text-slate-800">{systemStats?.postgres?.dbSize || '--'}</p>
|
|
</div>
|
|
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100 shadow-inner">
|
|
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Tabelas SGBD</p>
|
|
<p className="text-xl font-black text-slate-800">{systemStats?.postgres?.tableCount || '--'} <span className="text-sm font-medium text-slate-400">PostgreSQL</span></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* MINIO STORAGE CARD */}
|
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl space-y-4 relative overflow-hidden">
|
|
<div className="absolute bottom-0 right-0 w-24 h-24 bg-red-50 rounded-full -mr-8 -mb-8 pointer-events-none"></div>
|
|
<div className="flex items-center justify-between relative z-10">
|
|
<div className="flex items-center gap-3 text-red-600">
|
|
<div className="p-2 bg-red-50 rounded-lg shadow-sm border border-red-100">
|
|
<Cloud size={20} />
|
|
</div>
|
|
<h3 className="text-lg font-black text-slate-800">Storage Físico</h3>
|
|
</div>
|
|
{systemStats && !systemStats.minio?.error ? (
|
|
<span className="flex items-center gap-1.5 px-2.5 py-1 bg-emerald-100 text-emerald-700 rounded-full text-[10px] font-black uppercase tracking-wider">
|
|
<CheckCircle size={12} /> MinIO
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-1.5 px-2.5 py-1 bg-red-100 text-red-700 rounded-full text-[10px] font-black uppercase tracking-wider">
|
|
<AlertTriangle size={12} /> Backup
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-4 relative z-10">
|
|
<div className="flex-1 p-3 bg-slate-50 rounded-xl border border-slate-100 shadow-inner">
|
|
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Uso Total</p>
|
|
<p className="text-xl font-black text-slate-800">{systemStats?.minio?.totalSizeMB || '0.00'} <span className="text-sm font-medium text-slate-400">MB</span></p>
|
|
</div>
|
|
<div className="flex-1 p-3 bg-slate-50 rounded-xl border border-slate-100 shadow-inner">
|
|
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Arquivos</p>
|
|
<p className="text-xl font-black text-slate-800">{systemStats?.minio?.totalItems || '0'}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{systemStats?.minio?.buckets && systemStats.minio.buckets.length > 0 && (
|
|
<div className="pt-4 border-t border-slate-100 mt-2 relative z-10">
|
|
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-3">Buckets Mapeados</p>
|
|
<div className="space-y-2">
|
|
{systemStats.minio.buckets.map((b: any, idx: number) => (
|
|
<div key={idx} className="flex items-center justify-between bg-white p-3 rounded-lg border border-slate-100 shadow-sm hover:border-red-200 transition-colors">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-2.5 h-2.5 rounded-full bg-red-500 shadow-sm shadow-red-200"></div>
|
|
<span className="text-sm font-bold text-slate-700">{b.name}</span>
|
|
</div>
|
|
<div className="text-xs font-bold text-slate-400">
|
|
<span className="text-slate-600">{b.items}</span> itens • <span className="text-slate-600">{b.sizeMB}</span> MB
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl space-y-4">
|
|
<div className="flex items-center gap-3 text-indigo-600">
|
|
<div className="p-2 bg-indigo-50 rounded-lg">
|
|
<Database size={20} />
|
|
</div>
|
|
<h3 className="text-lg font-black text-slate-800">Dados do System</h3>
|
|
</div>
|
|
<button onClick={async () => await dbService.exportData()} className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-all font-bold text-xs">
|
|
<Download size={16} /> Exportar Backup
|
|
</button>
|
|
<label className="flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 text-slate-600 rounded-lg cursor-pointer hover:bg-slate-50 transition-colors font-bold text-xs">
|
|
<Upload size={16} /> Importar Backup
|
|
<input type="file" className="hidden" accept=".json" onChange={async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
showConfirm(
|
|
'Substituir Dados?',
|
|
'⚠️ Tem certeza que deseja substituir todos os dados atuais? Esta ação não pode ser desfeita.',
|
|
async () => {
|
|
await dbService.importData(file);
|
|
const newData = await dbService.initData();
|
|
setData(newData);
|
|
showAlert('Sucesso', '✅ Dados restaurados com sucesso!', 'success');
|
|
}
|
|
);
|
|
}
|
|
}} />
|
|
</label>
|
|
</div>
|
|
|
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl space-y-4">
|
|
<div className="flex items-center gap-3 text-indigo-600">
|
|
<div className="p-2 bg-indigo-50 rounded-lg">
|
|
<FileText size={20} />
|
|
</div>
|
|
<h3 className="text-lg font-black text-slate-800">Evolution API</h3>
|
|
</div>
|
|
|
|
<div className="p-4 rounded-lg bg-slate-50 border border-slate-200">
|
|
{data.evolutionConfig?.apiUrl ? (
|
|
<div className="space-y-2 text-sm text-slate-600">
|
|
<p><strong>URL:</strong> {data.evolutionConfig.apiUrl}</p>
|
|
<p><strong>Instância:</strong> {data.evolutionConfig.instanceName}</p>
|
|
<p><strong>API Key:</strong> ••••••••</p>
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-slate-500 text-center">Nenhuma credencial configurada.</p>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setShowEvolutionModal(true)}
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-all font-bold text-xs shadow-md"
|
|
>
|
|
<Plus size={16} /> Configurar Credenciais
|
|
</button>
|
|
</div>
|
|
|
|
<div className="bg-gradient-to-br from-indigo-50 to-blue-50 p-6 rounded-xl border border-indigo-100 shadow-xl space-y-4">
|
|
<div className="flex items-center gap-3 text-indigo-800">
|
|
<div className="p-2 bg-white rounded-lg shadow-sm">
|
|
<User size={20} className="text-indigo-600" />
|
|
</div>
|
|
<h3 className="text-lg font-black text-slate-800">Responsável Legal / Diretor</h3>
|
|
</div>
|
|
|
|
<div className="p-4 rounded-lg bg-white border border-indigo-50 shadow-sm">
|
|
{currentDirector ? (
|
|
<div className="space-y-2 text-sm text-slate-700">
|
|
<p><strong>Nome:</strong> {currentDirector.name}</p>
|
|
<p><strong>CPF:</strong> {currentDirector.cpf}</p>
|
|
<p className="text-xs text-indigo-500 mt-2 font-medium bg-indigo-50 inline-block px-2 py-1 rounded">Este responsável assinará automaticamente os documentos.</p>
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-slate-500 text-center">Nenhum diretor localizado. Cadastre um funcionário como Diretor na aba Funcionários.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl">
|
|
<button
|
|
onClick={() => showConfirm(
|
|
'Resetar Sistema',
|
|
'Isso apagará TODOS os dados cadastrados. Não há como desfazer.',
|
|
handleReset,
|
|
'alert'
|
|
)}
|
|
className="w-full py-3 border border-red-200 text-red-600 rounded-lg hover:bg-red-50 transition-colors font-bold text-xs flex items-center justify-center gap-2"
|
|
>
|
|
<Trash2 size={16} /> Resetar Fábrica
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="bg-white p-8 rounded-xl border border-slate-200 shadow-xl">
|
|
<h3 className="text-xl font-black text-slate-800 mb-6">Logs de API</h3>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead className="bg-slate-50 text-slate-500 uppercase text-[10px] font-black tracking-wider">
|
|
<tr>
|
|
<th className="px-4 py-3">Data</th>
|
|
<th className="px-4 py-3">Serviço</th>
|
|
<th className="px-4 py-3">Ação</th>
|
|
<th className="px-4 py-3">Detalhes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{apiLogs.map((log, i) => (
|
|
<tr key={i}>
|
|
<td className="px-4 py-3 text-slate-500">{new Date(log.date).toLocaleString()}</td>
|
|
<td className="px-4 py-3 font-bold text-indigo-600">{log.service}</td>
|
|
<td className="px-4 py-3 text-slate-700">{log.action}</td>
|
|
<td className="px-4 py-3 text-slate-600 text-xs font-mono">{JSON.stringify(log.details)}</td>
|
|
</tr>
|
|
))}
|
|
{apiLogs.length === 0 && (
|
|
<tr>
|
|
<td colSpan={4} className="px-4 py-8 text-center text-slate-400">Nenhum log encontrado.</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Evolution API Modal */}
|
|
{showEvolutionModal && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-transparent animate-in fade-in duration-200">
|
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden animate-slide-up">
|
|
<div className="px-6 py-5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
|
|
<h3 className="text-xl font-bold text-slate-800">Credenciais Evolution API</h3>
|
|
<button
|
|
onClick={() => setShowEvolutionModal(false)}
|
|
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full transition-colors"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">URL da API</label>
|
|
<input
|
|
type="text"
|
|
value={evolutionForm.apiUrl}
|
|
onChange={e => setEvolutionForm({...evolutionForm, apiUrl: e.target.value})}
|
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:bg-white transition-all shadow-sm text-sm"
|
|
placeholder="https://api.evolution.com"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">Nome da Instância</label>
|
|
<input
|
|
type="text"
|
|
value={evolutionForm.instanceName}
|
|
onChange={e => setEvolutionForm({...evolutionForm, instanceName: e.target.value})}
|
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:bg-white transition-all shadow-sm text-sm"
|
|
placeholder="minha-instancia"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">API Key</label>
|
|
<input
|
|
type="password"
|
|
value={evolutionForm.apiKey}
|
|
onChange={e => setEvolutionForm({...evolutionForm, apiKey: e.target.value})}
|
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:bg-white transition-all shadow-sm text-sm"
|
|
placeholder="••••••••••••"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6 border-t border-slate-100 bg-slate-50/50 flex justify-end gap-3">
|
|
<button
|
|
onClick={() => setShowEvolutionModal(false)}
|
|
className="px-5 py-2.5 text-slate-600 font-semibold hover:bg-slate-200 rounded-xl transition-all"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
onClick={saveEvolutionConfig}
|
|
className="px-6 py-2.5 bg-indigo-600 text-white font-bold rounded-xl hover:bg-indigo-700 shadow-md transition-all flex items-center gap-2"
|
|
>
|
|
<CheckCircle size={18} /> Confirmar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Settings; |