- Validação cross-tenant no login e rotas protegidas
- File serving via /api/files/{bucket}/{path} (eliminação DNS)
- Mensagens de erro humanizadas inline (sem pop-ups)
- Middleware tenant detection via headers customizados
- Upload de logos retorna URLs via API
- README atualizado com changelog v1.4 completo
1175 lines
66 KiB
TypeScript
1175 lines
66 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { Tab } from '@headlessui/react';
|
|
import { Button, Dialog, Input } from '@/components/ui';
|
|
import { Toaster, toast } from 'react-hot-toast';
|
|
import {
|
|
BuildingOfficeIcon,
|
|
PhotoIcon,
|
|
UserGroupIcon,
|
|
ShieldCheckIcon,
|
|
BellIcon,
|
|
ArrowUpTrayIcon,
|
|
TrashIcon,
|
|
CheckCircleIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
|
|
const tabs = [
|
|
{ name: 'Dados da Agência', icon: BuildingOfficeIcon },
|
|
{ name: 'Logo e Marca', icon: PhotoIcon },
|
|
{ name: 'Equipe', icon: UserGroupIcon },
|
|
{ name: 'Segurança', icon: ShieldCheckIcon },
|
|
{ name: 'Notificações', icon: BellIcon },
|
|
];
|
|
|
|
const parseAddressParts = (address: string) => {
|
|
if (!address) return { street: '', number: '', complement: '' };
|
|
|
|
const [streetPart, rest] = address.split(',', 2).map(part => part.trim());
|
|
if (!rest) return { street: streetPart, number: '', complement: '' };
|
|
|
|
const [numberPart, complementPart] = rest.split('-', 2).map(part => part.trim());
|
|
return {
|
|
street: streetPart,
|
|
number: numberPart || '',
|
|
complement: complementPart || '',
|
|
};
|
|
};
|
|
|
|
export default function ConfiguracoesPage() {
|
|
const [selectedTab, setSelectedTab] = useState(0);
|
|
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
|
|
const [successMessage, setSuccessMessage] = useState('');
|
|
const [showSupportDialog, setShowSupportDialog] = useState(false);
|
|
const [supportMessage, setSupportMessage] = useState('Para alterar estes dados, contate o suporte.');
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [loadingCep, setLoadingCep] = useState(false);
|
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
|
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
|
const [logoHorizontalPreview, setLogoHorizontalPreview] = useState<string | null>(null);
|
|
|
|
// Dados da agência (buscados da API)
|
|
const [agencyData, setAgencyData] = useState({
|
|
name: '',
|
|
cnpj: '',
|
|
email: '',
|
|
phone: '',
|
|
website: '',
|
|
address: '',
|
|
street: '',
|
|
number: '',
|
|
complement: '',
|
|
neighborhood: '',
|
|
city: '',
|
|
state: '',
|
|
zip: '',
|
|
razaoSocial: '',
|
|
description: '',
|
|
industry: '',
|
|
teamSize: '',
|
|
logoUrl: '',
|
|
logoHorizontalUrl: '',
|
|
primaryColor: '#ff3a05',
|
|
secondaryColor: '#ff0080',
|
|
});
|
|
|
|
// Live Preview da Cor Primária
|
|
useEffect(() => {
|
|
if (agencyData.primaryColor) {
|
|
const root = document.documentElement;
|
|
const hexToRgb = (hex: string) => {
|
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
return result ? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}` : null;
|
|
};
|
|
|
|
const primaryRgb = hexToRgb(agencyData.primaryColor);
|
|
|
|
if (primaryRgb) {
|
|
root.style.setProperty('--brand-rgb', primaryRgb);
|
|
root.style.setProperty('--brand-strong-rgb', primaryRgb);
|
|
root.style.setProperty('--brand-hover-rgb', primaryRgb);
|
|
}
|
|
|
|
root.style.setProperty('--brand-color', agencyData.primaryColor);
|
|
root.style.setProperty('--gradient', `linear-gradient(135deg, ${agencyData.primaryColor}, ${agencyData.primaryColor})`);
|
|
}
|
|
}, [agencyData.primaryColor]);
|
|
|
|
// Dados para alteração de senha
|
|
const [passwordData, setPasswordData] = useState({
|
|
currentPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: '',
|
|
});
|
|
|
|
useEffect(() => {
|
|
const fetchAgencyData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const token = localStorage.getItem('token');
|
|
const userData = localStorage.getItem('user');
|
|
|
|
if (!token || !userData) {
|
|
console.error('Usuário não autenticado');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Buscar dados da API
|
|
const response = await fetch('/api/agency/profile', {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
console.log('DEBUG: API response data:', data);
|
|
console.log('DEBUG: logo_url:', data.logo_url);
|
|
console.log('DEBUG: logo_horizontal_url:', data.logo_horizontal_url);
|
|
|
|
const parsedAddress = parseAddressParts(data.address || '');
|
|
setAgencyData({
|
|
name: data.name || '',
|
|
cnpj: data.cnpj || '',
|
|
email: data.email || '',
|
|
phone: data.phone || '',
|
|
website: data.website || '',
|
|
address: data.address || '',
|
|
street: parsedAddress.street,
|
|
number: data.number || parsedAddress.number,
|
|
complement: data.complement || parsedAddress.complement,
|
|
neighborhood: data.neighborhood || '',
|
|
city: data.city || '',
|
|
state: data.state || '',
|
|
zip: data.zip || '',
|
|
razaoSocial: data.razao_social || '',
|
|
description: data.description || '',
|
|
industry: data.industry || '',
|
|
teamSize: data.team_size || '',
|
|
logoUrl: data.logo_url || '',
|
|
logoHorizontalUrl: data.logo_horizontal_url || '',
|
|
primaryColor: data.primary_color || '#ff3a05',
|
|
secondaryColor: data.secondary_color || '#ff0080',
|
|
});
|
|
|
|
// Set logo previews
|
|
console.log('DEBUG: Setting previews...');
|
|
if (data.logo_url) {
|
|
console.log('DEBUG: Setting logoPreview to:', data.logo_url);
|
|
setLogoPreview(data.logo_url);
|
|
}
|
|
if (data.logo_horizontal_url) {
|
|
console.log('DEBUG: Setting logoHorizontalPreview to:', data.logo_horizontal_url);
|
|
setLogoHorizontalPreview(data.logo_horizontal_url);
|
|
}
|
|
} else {
|
|
console.error('Erro ao buscar dados:', response.status);
|
|
// Fallback para localStorage se API falhar
|
|
const savedData = localStorage.getItem('cadastroData');
|
|
if (savedData) {
|
|
const data = JSON.parse(savedData);
|
|
const user = JSON.parse(userData);
|
|
setAgencyData({
|
|
name: data.formData?.companyName || '',
|
|
cnpj: data.formData?.cnpj || '',
|
|
email: data.formData?.email || user.email || '',
|
|
phone: data.contacts?.[0]?.phone || '',
|
|
website: data.formData?.website || '',
|
|
address: `${data.cepData?.logradouro || ''}, ${data.formData?.number || ''}`,
|
|
street: data.cepData?.logradouro || '',
|
|
number: data.formData?.number || '',
|
|
complement: data.formData?.complement || '',
|
|
neighborhood: data.cepData?.bairro || '',
|
|
city: data.cepData?.localidade || '',
|
|
state: data.cepData?.uf || '',
|
|
zip: data.formData?.cep || '',
|
|
razaoSocial: data.cnpjData?.razaoSocial || '',
|
|
description: data.formData?.description || '',
|
|
industry: data.formData?.industry || '',
|
|
teamSize: data.formData?.teamSize || '',
|
|
logoUrl: '',
|
|
logoHorizontalUrl: '',
|
|
primaryColor: '#ff3a05',
|
|
secondaryColor: '#ff0080',
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Erro ao buscar dados da agência:', error);
|
|
setSuccessMessage('Erro ao carregar dados da agência.');
|
|
setShowSuccessDialog(true);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchAgencyData();
|
|
}, []);
|
|
|
|
const handleLogoUpload = async (file: File, type: 'logo' | 'horizontal') => {
|
|
if (!file) return;
|
|
|
|
// Validar tipo de arquivo
|
|
if (!file.type.startsWith('image/')) {
|
|
toast.error('Por favor, selecione uma imagem válida');
|
|
return;
|
|
}
|
|
|
|
// Validar tamanho (2MB)
|
|
if (file.size > 2 * 1024 * 1024) {
|
|
toast.error('A imagem deve ter no máximo 2MB');
|
|
return;
|
|
}
|
|
|
|
setUploadingLogo(true);
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
if (!token) {
|
|
toast.error('Você precisa estar autenticado');
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('logo', file);
|
|
formData.append('type', type);
|
|
|
|
const response = await fetch('/api/agency/logo', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const logoUrl = data.logo_url || data.url;
|
|
|
|
if (type === 'logo') {
|
|
setAgencyData(prev => ({ ...prev, logoUrl }));
|
|
setLogoPreview(logoUrl);
|
|
// Salvar no localStorage para uso do favicon
|
|
localStorage.setItem('agency-logo-url', logoUrl);
|
|
} else {
|
|
setAgencyData(prev => ({ ...prev, logoHorizontalUrl: logoUrl }));
|
|
setLogoHorizontalPreview(logoUrl);
|
|
}
|
|
|
|
// Disparar evento para atualizar branding em tempo real
|
|
if (typeof window !== 'undefined') {
|
|
window.dispatchEvent(new Event('branding-update'));
|
|
}
|
|
|
|
toast.success('Logo enviado com sucesso!');
|
|
} else {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
console.error('Upload error:', errorData);
|
|
toast.error(errorData.error || 'Erro ao enviar logo. Tente novamente.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Erro ao fazer upload:', error);
|
|
toast.error('Erro ao enviar logo. Verifique sua conexão.');
|
|
} finally {
|
|
setUploadingLogo(false);
|
|
}
|
|
};
|
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>, type: 'logo' | 'horizontal') => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
// Create preview immediately
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
const previewUrl = reader.result as string;
|
|
if (type === 'logo') {
|
|
setLogoPreview(previewUrl);
|
|
} else {
|
|
setLogoHorizontalPreview(previewUrl);
|
|
}
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
// Upload file
|
|
handleLogoUpload(file, type);
|
|
}
|
|
};
|
|
|
|
const formatCep = (value: string) => {
|
|
const numbers = value.replace(/\D/g, '');
|
|
return numbers.replace(/(\d{5})(\d{0,3})/, '$1-$2').substring(0, 9);
|
|
};
|
|
|
|
const fetchCepData = async (cep: string) => {
|
|
const numbers = cep.replace(/\D/g, '');
|
|
if (numbers.length !== 8) return;
|
|
|
|
setLoadingCep(true);
|
|
try {
|
|
const response = await fetch(`https://viacep.com.br/ws/${numbers}/json/`);
|
|
if (!response.ok) {
|
|
toast.error('Não foi possível consultar o CEP agora.');
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (data?.erro) {
|
|
toast.error('CEP não encontrado. Verifique o número.');
|
|
setAgencyData(prev => ({
|
|
...prev,
|
|
address: '',
|
|
street: '',
|
|
neighborhood: '',
|
|
city: '',
|
|
state: '',
|
|
}));
|
|
return;
|
|
}
|
|
|
|
const formattedCep = formatCep(cep);
|
|
const nextAddress = data.logradouro || '';
|
|
const nextNeighborhood = data.bairro || '';
|
|
const nextCity = data.localidade || '';
|
|
const nextState = data.uf || '';
|
|
|
|
setAgencyData(prev => ({
|
|
...prev,
|
|
zip: formattedCep,
|
|
address: nextAddress,
|
|
street: nextAddress,
|
|
neighborhood: nextNeighborhood,
|
|
city: nextCity,
|
|
state: nextState,
|
|
}));
|
|
|
|
toast.success('Endereço preenchido pelo CEP');
|
|
} catch (error) {
|
|
console.error('Erro ao buscar CEP:', error);
|
|
toast.error('Erro ao buscar CEP. Tente novamente.');
|
|
} finally {
|
|
setLoadingCep(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveAgency = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
if (!token) {
|
|
setSuccessMessage('Você precisa estar autenticado.');
|
|
setShowSuccessDialog(true);
|
|
setSaving(false);
|
|
return;
|
|
}
|
|
|
|
const composedAddress = agencyData.street
|
|
? `${agencyData.street}${agencyData.number ? `, ${agencyData.number}` : ''}${agencyData.complement ? ` - ${agencyData.complement}` : ''}`
|
|
: agencyData.address;
|
|
|
|
const response = await fetch('/api/agency/profile', {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
name: agencyData.name,
|
|
cnpj: agencyData.cnpj,
|
|
email: agencyData.email,
|
|
phone: agencyData.phone,
|
|
website: agencyData.website,
|
|
address: composedAddress,
|
|
neighborhood: agencyData.neighborhood,
|
|
number: agencyData.number,
|
|
complement: agencyData.complement,
|
|
city: agencyData.city,
|
|
state: agencyData.state,
|
|
zip: agencyData.zip,
|
|
razao_social: agencyData.razaoSocial,
|
|
description: agencyData.description,
|
|
industry: agencyData.industry,
|
|
team_size: agencyData.teamSize,
|
|
primary_color: agencyData.primaryColor,
|
|
secondary_color: agencyData.secondaryColor,
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
setSuccessMessage('Dados da agência salvos com sucesso!');
|
|
|
|
// Atualiza localStorage imediatamente para persistência instantânea
|
|
localStorage.setItem('agency-primary-color', agencyData.primaryColor);
|
|
localStorage.setItem('agency-secondary-color', agencyData.secondaryColor);
|
|
if (agencyData.logoUrl) localStorage.setItem('agency-logo-url', agencyData.logoUrl);
|
|
if (agencyData.logoHorizontalUrl) localStorage.setItem('agency-logo-horizontal-url', agencyData.logoHorizontalUrl);
|
|
|
|
// Disparar evento para atualizar o tema em tempo real
|
|
window.dispatchEvent(new Event('branding-update'));
|
|
} else {
|
|
setSuccessMessage('Erro ao salvar dados. Tente novamente.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Erro ao salvar:', error);
|
|
setSuccessMessage('Erro ao salvar dados. Verifique sua conexão.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
setShowSuccessDialog(true);
|
|
};
|
|
|
|
const handleChangePassword = async () => {
|
|
// Validações
|
|
if (!passwordData.currentPassword) {
|
|
setSuccessMessage('Por favor, informe sua senha atual.');
|
|
setShowSuccessDialog(true);
|
|
return;
|
|
}
|
|
if (!passwordData.newPassword || passwordData.newPassword.length < 8) {
|
|
setSuccessMessage('A nova senha deve ter pelo menos 8 caracteres.');
|
|
setShowSuccessDialog(true);
|
|
return;
|
|
}
|
|
if (passwordData.newPassword !== passwordData.confirmPassword) {
|
|
setSuccessMessage('As senhas não coincidem.');
|
|
setShowSuccessDialog(true);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
if (!token) {
|
|
setSuccessMessage('Você precisa estar autenticado.');
|
|
setShowSuccessDialog(true);
|
|
return;
|
|
}
|
|
|
|
const response = await fetch('/api/auth/change-password', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
currentPassword: passwordData.currentPassword,
|
|
newPassword: passwordData.newPassword,
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
|
setSuccessMessage('Senha alterada com sucesso!');
|
|
} else {
|
|
const error = await response.text();
|
|
setSuccessMessage(error || 'Erro ao alterar senha. Verifique sua senha atual.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Erro ao alterar senha:', error);
|
|
setSuccessMessage('Erro ao alterar senha. Verifique sua conexão.');
|
|
}
|
|
setShowSuccessDialog(true);
|
|
};
|
|
|
|
return (
|
|
<div className="p-6 max-w-7xl mx-auto">
|
|
<Toaster position="top-center" />
|
|
{/* Header */}
|
|
<div className="mb-8">
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
|
Configurações
|
|
</h1>
|
|
<p className="text-gray-600 dark:text-gray-400">
|
|
Gerencie as configurações da sua agência
|
|
</p>
|
|
</div>
|
|
|
|
{/* Loading State */}
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b border-gray-900 dark:border-gray-100"></div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Tabs */}
|
|
<Tab.Group selectedIndex={selectedTab} onChange={setSelectedTab}>
|
|
<Tab.List className="flex space-x-1 rounded-xl bg-gray-100 dark:bg-gray-800 p-1 mb-8">
|
|
{tabs.map((tab) => {
|
|
const Icon = tab.icon;
|
|
return (
|
|
<Tab
|
|
key={tab.name}
|
|
className={({ selected }) =>
|
|
`w-full flex items-center justify-center space-x-2 rounded-lg py-2.5 text-sm font-medium leading-5 transition-all
|
|
${selected
|
|
? 'bg-white dark:bg-gray-900 text-gray-900 dark:text-white shadow'
|
|
: 'text-gray-600 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-700/50 hover:text-gray-900 dark:hover:text-white'
|
|
}`
|
|
}
|
|
>
|
|
<Icon className="w-5 h-5" />
|
|
<span className="hidden sm:inline">{tab.name}</span>
|
|
</Tab>
|
|
);
|
|
})}
|
|
</Tab.List>
|
|
|
|
<Tab.Panels>
|
|
{/* Tab 1: Dados da Agência */}
|
|
<Tab.Panel className="space-y-6">
|
|
{/* Card: Informações da Agência */}
|
|
<div className="rounded-xl bg-white dark:bg-gray-800 p-6 border border-gray-200 dark:border-gray-700">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
|
Informações da Agência
|
|
</h2>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<Input
|
|
label="Nome da Agência"
|
|
value={agencyData.name}
|
|
onChange={(e) => setAgencyData({ ...agencyData, name: e.target.value })}
|
|
placeholder="Ex: Minha Agência"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Input
|
|
label="Razão Social"
|
|
value={agencyData.razaoSocial}
|
|
onChange={(e) => setAgencyData({ ...agencyData, razaoSocial: e.target.value })}
|
|
placeholder="Razão Social Ltda"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Input
|
|
label="CNPJ"
|
|
value={agencyData.cnpj}
|
|
readOnly
|
|
onClick={() => {
|
|
setSupportMessage('Para alterar CNPJ, contate o suporte.');
|
|
setShowSupportDialog(true);
|
|
}}
|
|
className="cursor-pointer bg-gray-50 dark:bg-gray-800"
|
|
helperText="Alteração via suporte"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Input
|
|
label="E-mail (acesso)"
|
|
type="email"
|
|
value={agencyData.email}
|
|
readOnly
|
|
onClick={() => {
|
|
setSupportMessage('Para alterar o e-mail de acesso, contate o suporte.');
|
|
setShowSupportDialog(true);
|
|
}}
|
|
className="cursor-pointer bg-gray-50 dark:bg-gray-800"
|
|
helperText="Alteração via suporte"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Input
|
|
label="Telefone / WhatsApp"
|
|
type="tel"
|
|
value={agencyData.phone}
|
|
onChange={(e) => setAgencyData({ ...agencyData, phone: e.target.value })}
|
|
placeholder="(00) 00000-0000"
|
|
/>
|
|
</div>
|
|
|
|
<div className="md:col-span-2">
|
|
<Input
|
|
label="Website"
|
|
type="url"
|
|
value={agencyData.website}
|
|
onChange={(e) => setAgencyData({ ...agencyData, website: e.target.value })}
|
|
placeholder="https://www.suaagencia.com.br"
|
|
leftIcon="ri-global-line"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Input
|
|
label="Segmento / Indústria"
|
|
value={agencyData.industry}
|
|
onChange={(e) => setAgencyData({ ...agencyData, industry: e.target.value })}
|
|
placeholder="Ex: Marketing Digital"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Input
|
|
label="Tamanho da Equipe"
|
|
value={agencyData.teamSize}
|
|
onChange={(e) => setAgencyData({ ...agencyData, teamSize: e.target.value })}
|
|
placeholder="Ex: 10-50 funcionários"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card: Contato e Localização */}
|
|
<div className="rounded-xl bg-white dark:bg-gray-800 p-6 border border-gray-200 dark:border-gray-700">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
|
Contato e Localização
|
|
</h2>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
|
|
<div>
|
|
<Input
|
|
label="CEP"
|
|
value={agencyData.zip}
|
|
onChange={(e) => {
|
|
const formatted = formatCep(e.target.value);
|
|
setAgencyData(prev => ({ ...prev, zip: formatted }));
|
|
|
|
const numbers = formatted.replace(/\D/g, '');
|
|
if (numbers.length === 8) {
|
|
fetchCepData(formatted);
|
|
}
|
|
|
|
if (numbers.length === 0) {
|
|
setAgencyData(prev => ({
|
|
...prev,
|
|
address: '',
|
|
street: '',
|
|
neighborhood: '',
|
|
city: '',
|
|
state: '',
|
|
}));
|
|
}
|
|
}}
|
|
placeholder="00000-000"
|
|
rightIcon={loadingCep ? "ri-loader-4-line animate-spin" : undefined}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Input
|
|
label="Estado"
|
|
value={agencyData.state}
|
|
readOnly
|
|
className="bg-gray-50 dark:bg-gray-800 cursor-not-allowed"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Input
|
|
label="Cidade"
|
|
value={agencyData.city}
|
|
readOnly
|
|
className="bg-gray-50 dark:bg-gray-800 cursor-not-allowed"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Input
|
|
label="Bairro"
|
|
value={agencyData.neighborhood}
|
|
readOnly
|
|
className="bg-gray-50 dark:bg-gray-800 cursor-not-allowed"
|
|
/>
|
|
</div>
|
|
|
|
<div className="md:col-span-2">
|
|
<Input
|
|
label="Rua/Avenida"
|
|
value={agencyData.street}
|
|
readOnly
|
|
className="bg-gray-50 dark:bg-gray-800 cursor-not-allowed"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Input
|
|
label="Número"
|
|
value={agencyData.number}
|
|
onChange={(e) => setAgencyData({ ...agencyData, number: e.target.value })}
|
|
placeholder="123"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Input
|
|
label="Complemento (opcional)"
|
|
value={agencyData.complement}
|
|
onChange={(e) => setAgencyData({ ...agencyData, complement: e.target.value })}
|
|
placeholder="Apto 101, Bloco B"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 flex justify-end">
|
|
<Button
|
|
onClick={handleSaveAgency}
|
|
variant="primary"
|
|
size="lg"
|
|
>
|
|
Salvar Alterações
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Tab.Panel>
|
|
|
|
{/* Tab 2: Logo e Marca */}
|
|
<Tab.Panel className="space-y-6">
|
|
<div className="rounded-xl bg-white dark:bg-gray-800 p-6 border border-gray-200 dark:border-gray-700">
|
|
<div className="mb-6">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
Logo e Identidade Visual
|
|
</h2>
|
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
Configure os logos da sua agência que serão exibidos no sistema
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
{/* Logo Principal */}
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
|
Logo Principal (Quadrado)
|
|
</h3>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
Usado no menu lateral e ícones do sistema
|
|
</p>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
{logoPreview ? (
|
|
<div className="space-y-3">
|
|
<div className="relative border-2 border-gray-200 dark:border-gray-700 rounded-xl p-8 bg-linear-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
|
<div className="flex items-center justify-center min-h-[140px]">
|
|
<img
|
|
src={logoPreview}
|
|
alt="Logo Principal"
|
|
className="max-h-36 max-w-full object-contain drop-shadow-lg"
|
|
/>
|
|
</div>
|
|
{agencyData.logoUrl && (
|
|
<div className="absolute top-3 right-3">
|
|
<div className="bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-1 rounded-md flex items-center gap-1 text-xs font-medium">
|
|
<CheckCircleIcon className="w-3.5 h-3.5" />
|
|
Salvo
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<label className="flex-1 cursor-pointer">
|
|
<input
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/jpg,image/svg+xml"
|
|
onChange={(e) => handleFileSelect(e, 'logo')}
|
|
className="hidden"
|
|
disabled={uploadingLogo}
|
|
/>
|
|
<Button
|
|
variant="primary"
|
|
size="md"
|
|
className="w-full pointer-events-none"
|
|
disabled={uploadingLogo}
|
|
isLoading={uploadingLogo}
|
|
type="button"
|
|
>
|
|
<ArrowUpTrayIcon className="w-4 h-4 mr-2" />
|
|
{uploadingLogo ? 'Enviando...' : 'Trocar Logo'}
|
|
</Button>
|
|
</label>
|
|
<Button
|
|
variant="outline"
|
|
size="md"
|
|
onClick={() => {
|
|
setLogoPreview(null);
|
|
setAgencyData(prev => ({ ...prev, logoUrl: '' }));
|
|
}}
|
|
disabled={uploadingLogo}
|
|
className="border-red-200 dark:border-red-900 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
|
>
|
|
<TrashIcon className="w-4 h-4 mr-2" />
|
|
Remover
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<label className="block cursor-pointer">
|
|
<input
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/jpg,image/svg+xml"
|
|
onChange={(e) => handleFileSelect(e, 'logo')}
|
|
className="hidden"
|
|
disabled={uploadingLogo}
|
|
/>
|
|
<div className="border-2 border-dashed border-gray-200 dark:border-gray-600 rounded-xl p-8 text-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors bg-gray-50/50 dark:bg-gray-900/50">
|
|
<div className="flex flex-col items-center">
|
|
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
|
|
<PhotoIcon className="w-8 h-8 text-gray-400 dark:text-gray-500" />
|
|
</div>
|
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Clique para fazer upload
|
|
</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
|
ou arraste e solte aqui
|
|
</p>
|
|
<Button
|
|
variant="primary"
|
|
size="md"
|
|
disabled={uploadingLogo}
|
|
isLoading={uploadingLogo}
|
|
type="button"
|
|
className="pointer-events-none"
|
|
>
|
|
<ArrowUpTrayIcon className="w-4 h-4 mr-2" />
|
|
{uploadingLogo ? 'Enviando...' : 'Selecionar Arquivo'}
|
|
</Button>
|
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-3">
|
|
PNG, JPG ou SVG • Máx. 2MB
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</label>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Logo Horizontal */}
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
|
Logo Horizontal (Opcional)
|
|
</h3>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
Usado no cabeçalho e emails
|
|
</p>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
{logoHorizontalPreview ? (
|
|
<div className="space-y-3">
|
|
<div className="relative border-2 border-gray-200 dark:border-gray-700 rounded-xl p-8 bg-linear-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
|
<div className="flex items-center justify-center min-h-[140px]">
|
|
<img
|
|
src={logoHorizontalPreview}
|
|
alt="Logo Horizontal"
|
|
className="max-h-36 max-w-full object-contain drop-shadow-lg"
|
|
/>
|
|
</div>
|
|
{agencyData.logoHorizontalUrl && (
|
|
<div className="absolute top-3 right-3">
|
|
<div className="bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-1 rounded-md flex items-center gap-1 text-xs font-medium">
|
|
<CheckCircleIcon className="w-3.5 h-3.5" />
|
|
Salvo
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<label className="flex-1 cursor-pointer">
|
|
<input
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/jpg,image/svg+xml"
|
|
onChange={(e) => handleFileSelect(e, 'horizontal')}
|
|
className="hidden"
|
|
disabled={uploadingLogo}
|
|
/>
|
|
<Button
|
|
variant="primary"
|
|
size="md"
|
|
className="w-full pointer-events-none"
|
|
disabled={uploadingLogo}
|
|
isLoading={uploadingLogo}
|
|
type="button"
|
|
>
|
|
<ArrowUpTrayIcon className="w-4 h-4 mr-2" />
|
|
{uploadingLogo ? 'Enviando...' : 'Trocar Logo'}
|
|
</Button>
|
|
</label>
|
|
<Button
|
|
variant="outline"
|
|
size="md"
|
|
onClick={() => {
|
|
setLogoHorizontalPreview(null);
|
|
setAgencyData(prev => ({ ...prev, logoHorizontalUrl: '' }));
|
|
}}
|
|
disabled={uploadingLogo}
|
|
className="border-red-200 dark:border-red-900 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
|
>
|
|
<TrashIcon className="w-4 h-4 mr-2" />
|
|
Remover
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<label className="block cursor-pointer">
|
|
<input
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/jpg,image/svg+xml"
|
|
onChange={(e) => handleFileSelect(e, 'horizontal')}
|
|
className="hidden"
|
|
disabled={uploadingLogo}
|
|
/>
|
|
<div className="border-2 border-dashed border-gray-200 dark:border-gray-600 rounded-xl p-8 text-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors bg-gray-50/50 dark:bg-gray-900/50">
|
|
<div className="flex flex-col items-center">
|
|
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
|
|
<PhotoIcon className="w-8 h-8 text-gray-400 dark:text-gray-500" />
|
|
</div>
|
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Clique para fazer upload
|
|
</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
|
ou arraste e solte aqui
|
|
</p>
|
|
<Button
|
|
variant="primary"
|
|
size="md"
|
|
disabled={uploadingLogo}
|
|
isLoading={uploadingLogo}
|
|
type="button"
|
|
className="pointer-events-none"
|
|
>
|
|
<ArrowUpTrayIcon className="w-4 h-4 mr-2" />
|
|
{uploadingLogo ? 'Enviando...' : 'Selecionar Arquivo'}
|
|
</Button>
|
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-3">
|
|
PNG, JPG ou SVG • Máx. 2MB
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</label>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cores da Marca */}
|
|
<div className="pt-6 border-t border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
Personalização de Cores
|
|
</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<div className="flex items-end gap-3">
|
|
<div className="relative w-[50px] h-[42px] rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 shadow-sm shrink-0 mb-[2px]">
|
|
<input
|
|
type="color"
|
|
value={agencyData.primaryColor}
|
|
onChange={(e) => setAgencyData({ ...agencyData, primaryColor: e.target.value })}
|
|
className="absolute -top-2 -left-2 w-24 h-24 cursor-pointer p-0 border-0"
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<Input
|
|
label="Cor Primária"
|
|
type="text"
|
|
value={agencyData.primaryColor}
|
|
onChange={(e) => setAgencyData({ ...agencyData, primaryColor: e.target.value })}
|
|
className="uppercase"
|
|
maxLength={7}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="flex items-end gap-3">
|
|
<div className="relative w-[50px] h-[42px] rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 shadow-sm shrink-0 mb-[2px]">
|
|
<input
|
|
type="color"
|
|
value={agencyData.secondaryColor}
|
|
onChange={(e) => setAgencyData({ ...agencyData, secondaryColor: e.target.value })}
|
|
className="absolute -top-2 -left-2 w-24 h-24 cursor-pointer p-0 border-0"
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<Input
|
|
label="Cor Secundária"
|
|
type="text"
|
|
value={agencyData.secondaryColor}
|
|
onChange={(e) => setAgencyData({ ...agencyData, secondaryColor: e.target.value })}
|
|
className="uppercase"
|
|
maxLength={7}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-6 flex justify-end">
|
|
<Button
|
|
onClick={handleSaveAgency}
|
|
variant="primary"
|
|
isLoading={saving}
|
|
style={{ backgroundColor: agencyData.primaryColor }}
|
|
>
|
|
Salvar Cores
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info adicional */}
|
|
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
|
<p className="text-sm text-blue-800 dark:text-blue-300">
|
|
<strong>Dica:</strong> Para melhores resultados, use imagens de alta qualidade em formato PNG com fundo transparente.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Tab.Panel>
|
|
|
|
{/* Tab 3: Equipe */}
|
|
<Tab.Panel className="rounded-xl bg-white dark:bg-gray-800 p-6 border border-gray-200 dark:border-gray-700">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
|
Gerenciamento de Equipe
|
|
</h2>
|
|
|
|
<div className="text-center py-12">
|
|
<UserGroupIcon className="w-16 h-16 mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
Em breve: gerenciamento completo de usuários e permissões
|
|
</p>
|
|
<Button variant="primary">
|
|
Convidar Membro
|
|
</Button>
|
|
</div>
|
|
</Tab.Panel>
|
|
|
|
{/* Tab 3: Segurança */}
|
|
<Tab.Panel className="rounded-xl bg-white dark:bg-gray-800 p-6 border border-gray-200 dark:border-gray-700">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
|
Segurança e Privacidade
|
|
</h2>
|
|
|
|
{/* Alteração de Senha */}
|
|
<div className="max-w-2xl">
|
|
<h3 className="text-md font-medium text-gray-900 dark:text-white mb-4">
|
|
Alterar Senha
|
|
</h3>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Input
|
|
label="Senha Atual"
|
|
type="password"
|
|
value={passwordData.currentPassword}
|
|
onChange={(e) => setPasswordData({ ...passwordData, currentPassword: e.target.value })}
|
|
placeholder="Digite sua senha atual"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Input
|
|
label="Nova Senha"
|
|
type="password"
|
|
value={passwordData.newPassword}
|
|
onChange={(e) => setPasswordData({ ...passwordData, newPassword: e.target.value })}
|
|
placeholder="Digite a nova senha (mínimo 8 caracteres)"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Input
|
|
label="Confirmar Nova Senha"
|
|
type="password"
|
|
value={passwordData.confirmPassword}
|
|
onChange={(e) => setPasswordData({ ...passwordData, confirmPassword: e.target.value })}
|
|
placeholder="Digite a nova senha novamente"
|
|
/>
|
|
</div>
|
|
|
|
<div className="pt-4">
|
|
<Button
|
|
onClick={handleChangePassword}
|
|
variant="primary"
|
|
>
|
|
Alterar Senha
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recursos Futuros */}
|
|
<div className="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-md font-medium text-gray-900 dark:text-white mb-4">
|
|
Recursos em Desenvolvimento
|
|
</h3>
|
|
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
|
<div className="flex items-center space-x-2">
|
|
<ShieldCheckIcon className="w-5 h-5" />
|
|
<span>Autenticação em duas etapas (2FA)</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<ShieldCheckIcon className="w-5 h-5" />
|
|
<span>Histórico de acessos</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<ShieldCheckIcon className="w-5 h-5" />
|
|
<span>Dispositivos conectados</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Tab.Panel>
|
|
|
|
{/* Tab 4: Notificações */}
|
|
<Tab.Panel className="rounded-xl bg-white dark:bg-gray-800 p-6 border border-gray-200 dark:border-gray-700">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
|
Preferências de Notificações
|
|
</h2>
|
|
|
|
<div className="text-center py-12">
|
|
<BellIcon className="w-16 h-16 mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
|
<p className="text-gray-600 dark:text-gray-400">
|
|
Em breve: configuração de notificações por e-mail, push e mais
|
|
</p>
|
|
</div>
|
|
</Tab.Panel>
|
|
</Tab.Panels>
|
|
</Tab.Group>
|
|
</>
|
|
)}
|
|
|
|
{/* Dialog de Sucesso */}
|
|
<Dialog
|
|
isOpen={showSuccessDialog}
|
|
onClose={() => setShowSuccessDialog(false)}
|
|
title="Sucesso"
|
|
size="sm"
|
|
>
|
|
<Dialog.Body>
|
|
<p className="text-center py-4">{successMessage}</p>
|
|
</Dialog.Body>
|
|
<Dialog.Footer>
|
|
<Button
|
|
onClick={() => setShowSuccessDialog(false)}
|
|
variant="primary"
|
|
>
|
|
OK
|
|
</Button>
|
|
</Dialog.Footer>
|
|
</Dialog>
|
|
|
|
{/* Dialog de Suporte */}
|
|
<Dialog
|
|
isOpen={showSupportDialog}
|
|
onClose={() => setShowSupportDialog(false)}
|
|
title="Contatar suporte"
|
|
>
|
|
<Dialog.Body>
|
|
<p className="text-sm text-gray-700 dark:text-gray-200">{supportMessage}</p>
|
|
<p className="mt-3 text-sm text-gray-500">Envie um e-mail para suporte@aggios.app ou abra um chamado para ajuste desses dados.</p>
|
|
</Dialog.Body>
|
|
<Dialog.Footer>
|
|
<Button
|
|
onClick={() => setShowSupportDialog(false)}
|
|
variant="primary"
|
|
>
|
|
Fechar
|
|
</Button>
|
|
</Dialog.Footer>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|