"use client"; import { Fragment, useEffect, useMemo, useState } from 'react'; import { Menu, Transition, Tab } from '@headlessui/react'; import { ConfirmDialog, BulkActionBar } from "@/components/ui"; import { useToast } from '@/components/layout/ToastContext'; import { UserIcon, TrashIcon, PencilIcon, EllipsisVerticalIcon, MagnifyingGlassIcon, PlusIcon, XMarkIcon, PhoneIcon, EnvelopeIcon, MapPinIcon, TagIcon, KeyIcon, LockClosedIcon, EyeIcon, CheckCircleIcon, IdentificationIcon, PhotoIcon, Cog6ToothIcon, } from '@heroicons/react/24/outline'; function classNames(...classes: string[]) { return classes.filter(Boolean).join(' '); } interface Customer { id: string; tenant_id: string; name: string; email: string; phone: string; company: string; position: string; address: string; city: string; state: string; zip_code: string; country: string; tags: string[]; notes: string; created_at: string; updated_at: string; created_by?: string; is_active: boolean; logo_url?: string; } type PublicRegistrationNotes = { person_type?: 'pf' | 'pj'; cpf?: string; cnpj?: string; full_name?: string; email?: string; phone?: string; responsible_name?: string; company_name?: string; trade_name?: string; postal_code?: string; street?: string; number?: string; complement?: string; neighborhood?: string; city?: string; state?: string; message?: string; logo_path?: string; }; const PENDING_APPROVAL_TAG = 'pendente_aprovacao'; const PUBLIC_REGISTRATION_TAG = 'cadastro_publico'; export default function CustomersPage() { const toast = useToast(); const [customers, setCustomers] = useState([]); const [loading, setLoading] = useState(true); const [isModalOpen, setIsModalOpen] = useState(false); const [editingCustomer, setEditingCustomer] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false); const [customerToDelete, setCustomerToDelete] = useState(null); const [searchTerm, setSearchTerm] = useState(''); const [showPendingOnly, setShowPendingOnly] = useState(false); const [detailsCustomer, setDetailsCustomer] = useState(null); const [isApprovingId, setIsApprovingId] = useState(null); const [selectedIds, setSelectedIds] = useState([]); // Portal Access States const [isPortalModalOpen, setIsPortalModalOpen] = useState(false); const [selectedCustomerForPortal, setSelectedCustomerForPortal] = useState(null); const [portalPassword, setPortalPassword] = useState(''); const [formData, setFormData] = useState({ name: '', email: '', phone: '', company: '', position: '', address: '', city: '', state: '', zip_code: '', country: 'Brasil', tags: '', notes: '', logo_url: '', }); const pendingCustomers = customers.filter(customer => customer.tags?.includes(PENDING_APPROVAL_TAG)); const isPendingApproval = (customer: Customer) => customer.tags?.includes(PENDING_APPROVAL_TAG); const parsePublicNotes = (notes: string): PublicRegistrationNotes | null => { if (!notes) return null; try { return JSON.parse(notes); } catch (error) { console.warn('[Clientes] Notas públicas inválidas', error); return null; } }; const getStatusBadge = (customer: Customer) => { if (isPendingApproval(customer)) { return { label: 'Pendente de aprovação', className: 'bg-amber-100 text-amber-800 border border-amber-200', }; } if (customer.tags?.includes(PUBLIC_REGISTRATION_TAG)) { return { label: 'Cadastro público', className: 'bg-sky-100 text-sky-700 border border-sky-200', }; } return { label: 'Ativo', className: 'bg-emerald-100 text-emerald-700 border border-emerald-200', }; }; const formatDateTime = (value: string) => { if (!value) return '-'; return new Date(value).toLocaleString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); }; useEffect(() => { fetchCustomers(); }, []); const fetchCustomers = async () => { try { const token = localStorage.getItem('token'); console.log('[Clientes] Fetching customers, token exists:', !!token); const response = await fetch('/api/crm/customers', { headers: { 'Authorization': `Bearer ${token}`, }, }); console.log('[Clientes] Response status:', response.status); const data = await response.json(); console.log('[Clientes] Response data:', data); if (response.ok) { setCustomers(data.customers || []); } else { console.error('[Clientes] Error response:', data); } } catch (error) { console.error('[Clientes] Error fetching customers:', error); } finally { setLoading(false); setSelectedIds([]); } }; const handleBulkDelete = async () => { if (selectedIds.length === 0) return; setBulkConfirmOpen(true); }; const handleConfirmBulkDelete = async () => { try { setLoading(true); const results = await Promise.all(selectedIds.map(async (id) => { const response = await fetch(`/api/crm/customers/${id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}`, }, }); return { id, ok: response.ok }; })); const successful = results.filter(r => r.ok).length; if (successful > 0) { toast.success('Exclusão completa', `${successful} clientes excluídos com sucesso.`); } if (successful < selectedIds.length) { toast.error('Erro', 'Não foi possível excluir alguns clientes.'); } await fetchCustomers(); } catch (error) { toast.error('Erro', 'Ocorreu um erro na exclusão em lote.'); } finally { setLoading(false); setBulkConfirmOpen(false); setSelectedIds([]); } }; const handleBulkApprove = async () => { const pendingToApprove = customers.filter(c => selectedIds.includes(c.id) && isPendingApproval(c)); if (pendingToApprove.length === 0) { toast.error('Nenhum dos clientes selecionados está pendente de aprovação.'); return; } try { setLoading(true); await Promise.all(pendingToApprove.map(async (customer) => { const updatedTags = (customer.tags || []).filter(tag => tag !== PENDING_APPROVAL_TAG); let logoUrl = customer.logo_url; const publicData = parsePublicNotes(customer.notes); if (publicData?.logo_path && !logoUrl) { logoUrl = publicData.logo_path; } return fetch(`/api/crm/customers/${customer.id}`, { method: 'PUT', headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: customer.name, email: customer.email, phone: customer.phone, company: customer.company, position: customer.position, address: customer.address, city: customer.city, state: customer.state, zip_code: customer.zip_code, country: customer.country, notes: customer.notes, tags: updatedTags, is_active: customer.is_active ?? true, logo_url: logoUrl, }), }); })); toast.success('Sucesso', `${pendingToApprove.length} clientes aprovados.`); await fetchCustomers(); } catch (error) { toast.error('Erro ao aprovar clientes'); } finally { setLoading(false); } }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const url = editingCustomer ? `/api/crm/customers/${editingCustomer.id}` : '/api/crm/customers'; const method = editingCustomer ? 'PUT' : 'POST'; const payload = { ...formData, tags: formData.tags.split(',').map(t => t.trim()).filter(t => t.length > 0), }; try { const response = await fetch(url, { method, headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); if (response.ok) { toast.success( editingCustomer ? 'Cliente atualizado' : 'Cliente criado', editingCustomer ? 'O cliente foi atualizado com sucesso.' : 'O novo cliente foi criado com sucesso.' ); await fetchCustomers(); handleCloseModal(); } else { const error = await response.json(); toast.error('Erro', error.message || 'Não foi possível salvar o cliente.'); } } catch (error) { console.error('Error saving customer:', error); toast.error('Erro', 'Ocorreu um erro ao salvar o cliente.'); } }; const handleEdit = (customer: Customer) => { setEditingCustomer(customer); setFormData({ name: customer.name, email: customer.email, phone: customer.phone, company: customer.company, position: customer.position, address: customer.address, city: customer.city, state: customer.state, zip_code: customer.zip_code, country: customer.country, tags: customer.tags?.join(', ') || '', notes: customer.notes, logo_url: customer.logo_url || '', }); setIsModalOpen(true); }; const handleDeleteClick = (id: string) => { setCustomerToDelete(id); setConfirmOpen(true); }; const handleConfirmDelete = async () => { if (!customerToDelete) return; try { const response = await fetch(`/api/crm/customers/${customerToDelete}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}`, }, }); if (response.ok) { await fetchCustomers(); toast.success('Cliente excluído', 'O cliente foi excluído com sucesso.'); } else { toast.error('Erro ao excluir', 'Não foi possível excluir o cliente.'); } } catch (error) { console.error('Error deleting customer:', error); toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir o cliente.'); } finally { setConfirmOpen(false); setCustomerToDelete(null); } }; const handleGeneratePortalAccess = (customer: Customer) => { setSelectedCustomerForPortal(customer); setPortalPassword(''); setIsPortalModalOpen(true); }; const openDetails = (customer: Customer) => { setDetailsCustomer(customer); }; const closeDetails = () => setDetailsCustomer(null); const handleSubmitPortalAccess = async (e: React.FormEvent) => { e.preventDefault(); if (!selectedCustomerForPortal || !portalPassword) return; try { const response = await fetch(`/api/crm/customers/${selectedCustomerForPortal.id}/portal-access`, { method: 'POST', headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ password: portalPassword }), }); if (response.ok) { toast.success( 'Acesso criado!', `Credenciais geradas para ${selectedCustomerForPortal.email}` ); setIsPortalModalOpen(false); setSelectedCustomerForPortal(null); setPortalPassword(''); } else { const error = await response.json(); toast.error('Erro', error.error || 'Não foi possível gerar acesso ao portal.'); } } catch (error) { console.error('Error generating portal access:', error); toast.error('Erro', 'Ocorreu um erro ao gerar acesso ao portal.'); } }; const handleApproveCustomer = async (customer: Customer) => { if (!isPendingApproval(customer)) return; setIsApprovingId(customer.id); const updatedTags = (customer.tags || []).filter(tag => tag !== PENDING_APPROVAL_TAG); // Extrair logo das notas se existir let logoUrl = customer.logo_url; const publicData = parsePublicNotes(customer.notes); if (publicData?.logo_path && !logoUrl) { logoUrl = publicData.logo_path; } try { const response = await fetch(`/api/crm/customers/${customer.id}`, { method: 'PUT', headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: customer.name, email: customer.email, phone: customer.phone, company: customer.company, position: customer.position, address: customer.address, city: customer.city, state: customer.state, zip_code: customer.zip_code, country: customer.country, notes: customer.notes, tags: updatedTags, is_active: customer.is_active ?? true, logo_url: logoUrl, }), }); if (response.ok) { toast.success('Acesso liberado!', 'O cliente já pode fazer login no portal com a senha cadastrada.'); await fetchCustomers(); setDetailsCustomer(prev => prev && prev.id === customer.id ? { ...prev, tags: updatedTags } : prev); } else { const error = await response.json(); toast.error('Erro ao aprovar', error?.error || 'Não foi possível atualizar o status.'); } } catch (error) { console.error('Error approving customer:', error); toast.error('Erro ao aprovar', 'Ocorreu um erro ao atualizar este cadastro.'); } finally { setIsApprovingId(null); } }; const handleCloseModal = () => { setIsModalOpen(false); setEditingCustomer(null); setFormData({ name: '', email: '', phone: '', company: '', position: '', address: '', city: '', state: '', zip_code: '', country: 'Brasil', tags: '', notes: '', logo_url: '', }); }; const filteredCustomers = customers.filter((customer) => { const searchLower = searchTerm.toLowerCase(); const matchesSearch = (customer.name?.toLowerCase() || '').includes(searchLower) || (customer.email?.toLowerCase() || '').includes(searchLower) || (customer.company?.toLowerCase() || '').includes(searchLower) || (customer.phone?.toLowerCase() || '').includes(searchLower); const matchesPendingFilter = !showPendingOnly || isPendingApproval(customer); return matchesSearch && matchesPendingFilter; }); const detailsPublicData = useMemo(() => { if (!detailsCustomer?.notes) return null; return parsePublicNotes(detailsCustomer.notes); }, [detailsCustomer]); const detailsStatusBadge = detailsCustomer ? getStatusBadge(detailsCustomer) : null; const isDetailsPendingApproval = detailsCustomer ? isPendingApproval(detailsCustomer) : false; const detailsDocumentLabel = detailsPublicData ? detailsPublicData.person_type === 'pj' ? 'CNPJ' : 'CPF' : null; const detailsDocumentValue = detailsPublicData ? detailsPublicData.person_type === 'pj' ? detailsPublicData.cnpj : detailsPublicData.cpf : null; const detailsPublicAddress = detailsPublicData ? [ detailsPublicData.street && [detailsPublicData.street, detailsPublicData.number].filter(Boolean).join(', '), detailsPublicData.neighborhood, detailsPublicData.city && detailsPublicData.state ? `${detailsPublicData.city} - ${detailsPublicData.state}` : detailsPublicData.city || detailsPublicData.state, detailsPublicData.postal_code, ] .filter(Boolean) .join(' · ') : null; return (
{/* Header */}

Clientes

Gerencie seus clientes e contatos

{/* Search */} {pendingCustomers.length > 0 && (

{pendingCustomers.length === 1 ? 'Existe 1 cadastro aguardando aprovação.' : `Existem ${pendingCustomers.length} cadastros aguardando aprovação.`}

Use este painel para revisar os envios do formulário público enquanto o e-mail automático não está ativo.

)}
setSearchTerm(e.target.value)} />
{/* Table */} {loading ? (
) : filteredCustomers.length === 0 ? (

Nenhum cliente encontrado

{searchTerm ? 'Nenhum cliente corresponde à sua busca.' : 'Comece adicionando seu primeiro cliente.'}

) : (
{filteredCustomers.map((customer) => { const badge = getStatusBadge(customer); const pendingApproval = isPendingApproval(customer); return ( openDetails(customer)} className={`group transition-colors cursor-pointer ${pendingApproval ? 'bg-amber-50/40 dark:bg-amber-900/10 hover:bg-amber-50/70 dark:hover:bg-amber-900/20' : 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50' } ${selectedIds.includes(customer.id) ? 'bg-brand-50/30 dark:bg-brand-500/5' : ''}`} > ); })}
0 && selectedIds.length === filteredCustomers.length} onChange={(e) => { if (e.target.checked) { setSelectedIds(filteredCustomers.map(c => c.id)); } else { setSelectedIds([]); } }} className="w-4 h-4 rounded border-zinc-300 text-brand-600 focus:ring-brand-500 cursor-pointer" />
Cliente Empresa Contato Tags Status Ações
e.stopPropagation()}>
{ if (selectedIds.includes(customer.id)) { setSelectedIds(selectedIds.filter(id => id !== customer.id)); } else { setSelectedIds([...selectedIds, customer.id]); } }} className="w-4 h-4 rounded border-zinc-300 text-brand-600 focus:ring-brand-500 cursor-pointer" />
{customer.logo_url ? ( {customer.name} { // Fallback se a imagem falhar (e.target as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(customer.name)}&background=random`; }} /> ) : (
{customer.name.substring(0, 2).toUpperCase()}
)}
{customer.name}
{customer.position && (
{customer.position}
)}
{customer.company || '-'}
{customer.email && (
{customer.email}
)} {customer.phone && (
{customer.phone}
)}
{customer.tags && customer.tags.length > 0 ? ( customer.tags.slice(0, 3).map((tag, idx) => ( {tag} )) ) : ( - )} {customer.tags && customer.tags.length > 3 && ( +{customer.tags.length - 3} )}
{badge.label} {pendingApproval && (

Novo cadastro via formulário público

)}
{({ active }) => ( )} {({ active }) => ( )}
{pendingApproval && (
{({ active }) => ( )}
)}
{({ active }) => ( )}
{({ active }) => ( )}
)} {/* Modal */} {isModalOpen && (

{editingCustomer ? 'Editar Cliente' : 'Novo Cliente'}

{editingCustomer ? 'Atualize as informações do cliente.' : 'Adicione um novo cliente ao seu CRM.'}

classNames( 'w-full rounded-lg py-2.5 text-sm font-medium leading-5 transition-all', selected ? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300' ) } >
Básico
classNames( 'w-full rounded-lg py-2.5 text-sm font-medium leading-5 transition-all', selected ? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300' ) } >
Endereço
classNames( 'w-full rounded-lg py-2.5 text-sm font-medium leading-5 transition-all', selected ? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300' ) } >
Config
setFormData({ ...formData, name: e.target.value })} required className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all" />
setFormData({ ...formData, email: e.target.value })} className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all" />
setFormData({ ...formData, phone: e.target.value })} className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all" />
setFormData({ ...formData, company: e.target.value })} className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all" />
setFormData({ ...formData, position: e.target.value })} className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all" />
setFormData({ ...formData, address: e.target.value })} className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all" />
setFormData({ ...formData, city: e.target.value })} className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all" />
setFormData({ ...formData, state: e.target.value })} className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all" />
setFormData({ ...formData, zip_code: e.target.value })} className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all" />
setFormData({ ...formData, country: e.target.value })} className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all" />
{formData.logo_url ? ( Logo do Cliente ) : ( )}

{formData.logo_url ? 'Logo cadastrado' : 'Nenhum logo cadastrado'}

O logo é gerenciado pelo próprio cliente através do portal do cliente.

setFormData({ ...formData, tags: e.target.value })} placeholder="vip, premium, lead-quente" className="w-full pl-10 pr-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all" />