feat: versão 1.5 - CRM Beta com leads, funis, campanhas e portal do cliente
This commit is contained in:
648
front-end-agency/app/(agency)/crm/leads/importar/page.tsx
Normal file
648
front-end-agency/app/(agency)/crm/leads/importar/page.tsx
Normal file
@@ -0,0 +1,648 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useToast } from '@/components/layout/ToastContext';
|
||||
import Papa from 'papaparse';
|
||||
import {
|
||||
ArrowUpTrayIcon,
|
||||
DocumentTextIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ArrowPathIcon,
|
||||
ChevronLeftIcon,
|
||||
InformationCircleIcon,
|
||||
TableCellsIcon,
|
||||
CommandLineIcon,
|
||||
CpuChipIcon,
|
||||
CloudArrowUpIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
name: string;
|
||||
company: string;
|
||||
}
|
||||
|
||||
interface Campaign {
|
||||
id: string;
|
||||
name: string;
|
||||
customer_id: string;
|
||||
}
|
||||
|
||||
function ImportLeadsContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const campaignIdFromUrl = searchParams.get('campaign');
|
||||
const customerIdFromUrl = searchParams.get('customer');
|
||||
|
||||
const toast = useToast();
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const [selectedCustomer, setSelectedCustomer] = useState(customerIdFromUrl || '');
|
||||
const [selectedCampaign, setSelectedCampaign] = useState(campaignIdFromUrl || '');
|
||||
const [jsonContent, setJsonContent] = useState('');
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<any[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importType, setImportType] = useState<'json' | 'csv' | 'typebot' | 'api'>('json');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Mapeamento inteligente de campos
|
||||
const mapLeadData = (data: any[]) => {
|
||||
const fieldMap: Record<string, string[]> = {
|
||||
name: ['nome', 'name', 'full name', 'nome completo', 'cliente', 'contato'],
|
||||
email: ['email', 'e-mail', 'mail', 'correio'],
|
||||
phone: ['phone', 'telefone', 'celular', 'mobile', 'whatsapp', 'zap', 'tel'],
|
||||
source: ['source', 'origem', 'canal', 'campanha', 'midia', 'mídia', 'campaign'],
|
||||
status: ['status', 'fase', 'etapa', 'situação', 'situacao'],
|
||||
notes: ['notes', 'notas', 'observações', 'observacoes', 'obs', 'comentário', 'comentario'],
|
||||
};
|
||||
|
||||
return data.map(item => {
|
||||
const mapped: any = { ...item };
|
||||
const itemKeys = Object.keys(item);
|
||||
|
||||
// Tenta encontrar correspondências para cada campo principal
|
||||
Object.entries(fieldMap).forEach(([targetKey, aliases]) => {
|
||||
const foundKey = itemKeys.find(k =>
|
||||
aliases.includes(k.toLowerCase().trim())
|
||||
);
|
||||
if (foundKey && !mapped[targetKey]) {
|
||||
mapped[targetKey] = item[foundKey];
|
||||
}
|
||||
});
|
||||
|
||||
// Garante que campos básicos existam
|
||||
if (!mapped.name && mapped.Nome) mapped.name = mapped.Nome;
|
||||
if (!mapped.email && mapped.Email) mapped.email = mapped.Email;
|
||||
if (!mapped.phone && (mapped.Celular || mapped.Telefone)) mapped.phone = mapped.Celular || mapped.Telefone;
|
||||
|
||||
return mapped;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.type !== 'text/csv' && !file.name.endsWith('.csv')) {
|
||||
toast.error('Erro', 'Por favor, selecione um arquivo CSV válido.');
|
||||
return;
|
||||
}
|
||||
|
||||
setCsvFile(file);
|
||||
setError(null);
|
||||
|
||||
// Tenta ler o arquivo primeiro para detectar onde começam os dados
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string;
|
||||
const lines = text.split('\n');
|
||||
|
||||
// Procura a linha que parece ser o cabeçalho (contém Nome, Email ou Celular)
|
||||
let headerIndex = 0;
|
||||
for (let i = 0; i < Math.min(lines.length, 10); i++) {
|
||||
const lowerLine = lines[i].toLowerCase();
|
||||
if (lowerLine.includes('nome') || lowerLine.includes('email') || lowerLine.includes('celular')) {
|
||||
headerIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const csvData = lines.slice(headerIndex).join('\n');
|
||||
|
||||
Papa.parse(csvData, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => {
|
||||
if (results.errors.length > 0 && results.data.length === 0) {
|
||||
setError('Erro ao processar CSV. Verifique a formatação.');
|
||||
setPreview([]);
|
||||
} else {
|
||||
const mappedData = mapLeadData(results.data);
|
||||
setPreview(mappedData.slice(0, 5));
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
setError('Falha ao ler o arquivo.');
|
||||
setPreview([]);
|
||||
}
|
||||
});
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [custRes, campRes] = await Promise.all([
|
||||
fetch('/api/crm/customers', {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
}),
|
||||
fetch('/api/crm/lists', {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
})
|
||||
]);
|
||||
|
||||
let fetchedCampaigns: Campaign[] = [];
|
||||
if (campRes.ok) {
|
||||
const data = await campRes.json();
|
||||
fetchedCampaigns = data.lists || [];
|
||||
setCampaigns(fetchedCampaigns);
|
||||
}
|
||||
|
||||
if (custRes.ok) {
|
||||
const data = await custRes.json();
|
||||
setCustomers(data.customers || []);
|
||||
}
|
||||
|
||||
// Se veio da campanha, tenta setar o cliente automaticamente
|
||||
if (campaignIdFromUrl && fetchedCampaigns.length > 0) {
|
||||
const campaign = fetchedCampaigns.find(c => c.id === campaignIdFromUrl);
|
||||
if (campaign && campaign.customer_id) {
|
||||
setSelectedCustomer(campaign.customer_id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJsonChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const content = e.target.value;
|
||||
setJsonContent(content);
|
||||
setError(null);
|
||||
|
||||
if (!content.trim()) {
|
||||
setPreview([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
const leads = Array.isArray(parsed) ? parsed : [parsed];
|
||||
const mappedData = mapLeadData(leads);
|
||||
setPreview(mappedData.slice(0, 5));
|
||||
} catch (err) {
|
||||
setError('JSON inválido. Verifique a formatação.');
|
||||
setPreview([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
let leads: any[] = [];
|
||||
|
||||
if (importType === 'json') {
|
||||
if (!jsonContent.trim() || error) {
|
||||
toast.error('Erro', 'Por favor, insira um JSON válido.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(jsonContent);
|
||||
leads = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch (err) {
|
||||
toast.error('Erro', 'JSON inválido.');
|
||||
return;
|
||||
}
|
||||
} else if (importType === 'csv') {
|
||||
if (!csvFile || error) {
|
||||
toast.error('Erro', 'Por favor, selecione um arquivo CSV válido.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse CSV again to get all data
|
||||
const results = await new Promise<any[]>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string;
|
||||
const lines = text.split('\n');
|
||||
let headerIndex = 0;
|
||||
for (let i = 0; i < Math.min(lines.length, 10); i++) {
|
||||
const lowerLine = lines[i].toLowerCase();
|
||||
if (lowerLine.includes('nome') || lowerLine.includes('email') || lowerLine.includes('celular')) {
|
||||
headerIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const csvData = lines.slice(headerIndex).join('\n');
|
||||
Papa.parse(csvData, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (results: any) => resolve(results.data)
|
||||
});
|
||||
};
|
||||
reader.readAsText(csvFile);
|
||||
});
|
||||
leads = results;
|
||||
}
|
||||
|
||||
if (leads.length === 0) {
|
||||
toast.error('Erro', 'Nenhum lead encontrado para importar.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Aplica o mapeamento inteligente antes de enviar
|
||||
const mappedLeads = mapLeadData(leads);
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const response = await fetch('/api/crm/leads/import', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
customer_id: selectedCustomer,
|
||||
campaign_id: selectedCampaign,
|
||||
leads: mappedLeads
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
toast.success('Sucesso', `${result.count} leads importados com sucesso.`);
|
||||
|
||||
// Se veio de uma campanha, volta para a campanha
|
||||
if (campaignIdFromUrl) {
|
||||
router.push(`/crm/campanhas/${campaignIdFromUrl}`);
|
||||
} else {
|
||||
router.push('/crm/leads');
|
||||
}
|
||||
} else {
|
||||
const errData = await response.json();
|
||||
toast.error('Erro na importação', errData.error || 'Ocorreu um erro ao importar os leads.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Import error:', err);
|
||||
toast.error('Erro', 'Falha ao processar a importação.');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-500 transition-colors"
|
||||
>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">Importar Leads</h1>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Selecione o método de importação e organize seus leads
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Methods */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button
|
||||
onClick={() => setImportType('json')}
|
||||
className={`p-4 rounded-xl border transition-all text-left flex flex-col gap-3 ${importType === 'json'
|
||||
? 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800 ring-1 ring-blue-500'
|
||||
: 'bg-white border-zinc-200 dark:bg-zinc-900 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${importType === 'json' ? 'bg-blue-500 text-white' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>
|
||||
<DocumentTextIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-zinc-900 dark:text-white">JSON</h3>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">Importação via código</p>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<span className="text-[10px] font-bold uppercase px-1.5 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 rounded">Ativo</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setImportType('csv');
|
||||
setPreview([]);
|
||||
setError(null);
|
||||
}}
|
||||
className={`p-4 rounded-xl border transition-all text-left flex flex-col gap-3 ${importType === 'csv'
|
||||
? 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800 ring-1 ring-blue-500'
|
||||
: 'bg-white border-zinc-200 dark:bg-zinc-900 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${importType === 'csv' ? 'bg-blue-500 text-white' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>
|
||||
<TableCellsIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-zinc-900 dark:text-white">CSV / Excel</h3>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">Planilhas padrão</p>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<span className="text-[10px] font-bold uppercase px-1.5 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 rounded">Ativo</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled
|
||||
className="p-4 rounded-xl border bg-zinc-50/50 dark:bg-zinc-900/50 border-zinc-200 dark:border-zinc-800 opacity-60 cursor-not-allowed text-left flex flex-col gap-3"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-zinc-400">
|
||||
<CpuChipIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-zinc-400">Typebot</h3>
|
||||
<p className="text-xs text-zinc-400">Integração direta</p>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<span className="text-[10px] font-bold uppercase px-1.5 py-0.5 bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-500 rounded">Em breve</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled
|
||||
className="p-4 rounded-xl border bg-zinc-50/50 dark:bg-zinc-900/50 border-zinc-200 dark:border-zinc-800 opacity-60 cursor-not-allowed text-left flex flex-col gap-3"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-zinc-400">
|
||||
<CommandLineIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-zinc-400">API / Webhook</h3>
|
||||
<p className="text-xs text-zinc-400">Endpoint externo</p>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<span className="text-[10px] font-bold uppercase px-1.5 py-0.5 bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-500 rounded">Em breve</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Config Side */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-zinc-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<InformationCircleIcon className="w-4 h-4 text-blue-500" />
|
||||
Destino dos Leads
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5">
|
||||
Campanha
|
||||
</label>
|
||||
<select
|
||||
value={selectedCampaign}
|
||||
onChange={(e) => {
|
||||
setSelectedCampaign(e.target.value);
|
||||
const camp = campaigns.find(c => c.id === e.target.value);
|
||||
if (camp?.customer_id) setSelectedCustomer(camp.customer_id);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
||||
>
|
||||
<option value="">Nenhuma</option>
|
||||
{campaigns.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{campaignIdFromUrl && (
|
||||
<p className="mt-1.5 text-[10px] text-blue-600 dark:text-blue-400 font-medium">
|
||||
* Campanha pré-selecionada via contexto
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5">
|
||||
Cliente Vinculado
|
||||
</label>
|
||||
<select
|
||||
value={selectedCustomer}
|
||||
onChange={(e) => setSelectedCustomer(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
||||
>
|
||||
<option value="">Nenhum (Geral)</option>
|
||||
{customers.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-100 dark:border-blue-800/30 p-4">
|
||||
<h3 className="text-xs font-bold text-blue-700 dark:text-blue-400 uppercase mb-2">Formato JSON Esperado</h3>
|
||||
<pre className="text-[10px] text-blue-600 dark:text-blue-300 overflow-x-auto">
|
||||
{`[
|
||||
{
|
||||
"name": "João Silva",
|
||||
"email": "joao@email.com",
|
||||
"phone": "11999999999",
|
||||
"source": "facebook",
|
||||
"tags": ["lead-quente"]
|
||||
}
|
||||
]`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor Side */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{importType === 'json' ? (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between bg-zinc-50/50 dark:bg-zinc-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentTextIcon className="w-5 h-5 text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Conteúdo JSON</span>
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1">
|
||||
<XCircleIcon className="w-4 h-4" />
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
{!error && preview.length > 0 && (
|
||||
<span className="text-xs text-green-500 flex items-center gap-1">
|
||||
<CheckCircleIcon className="w-4 h-4" />
|
||||
JSON Válido
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={jsonContent}
|
||||
onChange={handleJsonChange}
|
||||
placeholder="Cole seu JSON aqui..."
|
||||
className="w-full h-80 p-4 font-mono text-sm bg-transparent border-none focus:ring-0 resize-none text-zinc-800 dark:text-zinc-200"
|
||||
/>
|
||||
<div className="px-6 py-4 bg-zinc-50 dark:bg-zinc-800/50 border-t border-zinc-200 dark:border-zinc-800 flex justify-end">
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !!error || !jsonContent.trim()}
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-semibold text-sm hover:opacity-90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{importing ? (
|
||||
<ArrowPathIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUpTrayIcon className="w-4 h-4" />
|
||||
)}
|
||||
{importing ? 'Importando...' : 'Iniciar Importação'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : importType === 'csv' ? (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between bg-zinc-50/50 dark:bg-zinc-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<TableCellsIcon className="w-5 h-5 text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Upload de Arquivo CSV</span>
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1">
|
||||
<XCircleIcon className="w-4 h-4" />
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
{!error && csvFile && (
|
||||
<span className="text-xs text-green-500 flex items-center gap-1">
|
||||
<CheckCircleIcon className="w-4 h-4" />
|
||||
Arquivo Selecionado
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-2xl p-12 text-center cursor-pointer transition-all ${csvFile
|
||||
? 'border-green-200 bg-green-50/30 dark:border-green-900/30 dark:bg-green-900/10'
|
||||
: 'border-zinc-200 hover:border-blue-400 dark:border-zinc-800 dark:hover:border-blue-500 bg-zinc-50/50 dark:bg-zinc-800/30'
|
||||
}`}
|
||||
>
|
||||
<div className="w-16 h-16 bg-white dark:bg-zinc-800 rounded-2xl shadow-sm flex items-center justify-center mx-auto mb-4">
|
||||
<CloudArrowUpIcon className={`w-8 h-8 ${csvFile ? 'text-green-500' : 'text-zinc-400'}`} />
|
||||
</div>
|
||||
{csvFile ? (
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-zinc-900 dark:text-white">{csvFile.name}</h4>
|
||||
<p className="text-xs text-zinc-500 mt-1">{(csvFile.size / 1024).toFixed(2)} KB</p>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCsvFile(null);
|
||||
setPreview([]);
|
||||
}}
|
||||
className="mt-4 text-xs font-semibold text-red-500 hover:text-red-600"
|
||||
>
|
||||
Remover arquivo
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-zinc-900 dark:text-white">Clique para selecionar ou arraste o arquivo</h4>
|
||||
<p className="text-xs text-zinc-500 mt-1">Apenas arquivos .csv são aceitos</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-100 dark:border-blue-800/30">
|
||||
<h5 className="text-xs font-bold text-blue-700 dark:text-blue-400 uppercase mb-2">Importação Inteligente</h5>
|
||||
<p className="text-xs text-blue-600 dark:text-blue-300 leading-relaxed">
|
||||
Nosso sistema detecta automaticamente os cabeçalhos. Você pode usar nomes como <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">Nome</code>, <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">E-mail</code>, <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">Celular</code> ou <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">Telefone</code>.
|
||||
Linhas de título extras no topo do arquivo também são ignoradas automaticamente.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-zinc-50 dark:bg-zinc-800/50 border-t border-zinc-200 dark:border-zinc-800 flex justify-end">
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !!error || !csvFile}
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-semibold text-sm hover:opacity-90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{importing ? (
|
||||
<ArrowPathIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUpTrayIcon className="w-4 h-4" />
|
||||
)}
|
||||
{importing ? 'Importando...' : 'Iniciar Importação'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-12 text-center">
|
||||
<div className="w-16 h-16 bg-zinc-100 dark:bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<ArrowPathIcon className="w-8 h-8 text-zinc-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Em Desenvolvimento</h3>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 max-w-xs mx-auto mt-2">
|
||||
Este método de importação estará disponível em breve. Por enquanto, utilize o formato JSON.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{(importType === 'json' || importType === 'csv') && preview.length > 0 && (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white mb-4">Pré-visualização (Primeiros 5)</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="text-zinc-500 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<th className="pb-2 font-medium">Nome</th>
|
||||
<th className="pb-2 font-medium">Email</th>
|
||||
<th className="pb-2 font-medium">Telefone</th>
|
||||
<th className="pb-2 font-medium">Origem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-50 dark:divide-zinc-800">
|
||||
{preview.map((lead, i) => (
|
||||
<tr key={i}>
|
||||
<td className="py-2 text-zinc-900 dark:text-zinc-100">{lead.name || '-'}</td>
|
||||
<td className="py-2 text-zinc-600 dark:text-zinc-400">{lead.email || '-'}</td>
|
||||
<td className="py-2 text-zinc-600 dark:text-zinc-400">{lead.phone || '-'}</td>
|
||||
<td className="py-2">
|
||||
<span className="px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded text-[10px] uppercase font-bold text-zinc-500">
|
||||
{lead.source || 'manual'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImportLeadsPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
}>
|
||||
<ImportLeadsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user