import React, { useState, useEffect, createContext, useContext, useMemo, useCallback, useRef } from 'react';
import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from 'firebase/auth';
import { getFirestore, collection, addDoc, onSnapshot, doc, updateDoc, deleteDoc, query, orderBy, serverTimestamp, arrayUnion, arrayRemove, setDoc, deleteField } from 'firebase/firestore'; // Import deleteField
// Context for Firebase services and user ID
const FirebaseContext = createContext(null);
// Custom hook to use Firebase context
const useFirebase = () => useContext(FirebaseContext);
// Helper function to format phone number to Brazilian standard (XX) XX XXXXX-XXXX or (XX) XX XXXX-XXXX
const formatPhoneNumber = (phoneNumberString) => {
if (!phoneNumberString) return '';
const cleaned = ('' + phoneNumberString).replace(/\D/g, ''); // Remove all non-digit characters
const match = cleaned.match(/^(\d{2})(\d{2})(\d{4,5})(\d{4})$/); // Matches (DD) DD DDDD-DDDD or (DD) DD DDDDD-DDDD
if (match) {
return `(${match[1]}) ${match[2]} ${match[3]}-${match[4]}`;
}
return phoneNumberString; // Return original if no match
};
// Helper function to unformat phone number (remove non-digits)
const unformatPhoneNumber = (formattedPhoneNumberString) => {
if (!formattedPhoneNumberString) return '';
return formattedPhoneNumberString.replace(/\D/g, '');
};
// Notification Component
function Notification({ message, type, id, onDismiss }) {
useEffect(() => {
const timer = setTimeout(() => {
onDismiss(id);
}, 3000); // Notification disappears after 3 seconds
return () => clearTimeout(timer);
}, [id, onDismiss]);
let bgColor = 'bg-blue-600'; // Default for info
if (type === 'success') bgColor = 'bg-green-600';
if (type === 'error') bgColor = 'bg-red-600';
return (
{message}
);
}
function App() {
const [db, setDb] = useState(null);
const [auth, setAuth] = useState(null);
const [userId, setUserId] = useState(null);
const [isAuthReady, setIsAuthReady] = useState(false);
const [view, setView] = useState('kanban'); // Default to kanban view
const [selectedLead, setSelectedLead] = useState(null); // Used for LeadDetail modal
const [leads, setLeads] = useState([]);
const [kanbanColumns, setKanbanColumns] = useState([]); // State for dynamic Kanban columns
const [availableTags, setAvailableTags] = useState([]); // State for available tags
const [availableSources, setAvailableSources] = useState([]); // State for available sources
const [customFieldDefinitions, setCustomFieldDefinitions] = useState([]); // State for custom field definitions
const [notifications, setNotifications] = useState([]); // State for notifications
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [leadToDelete, setLeadToDelete] = useState(null);
const [showColumnManagement, setShowColumnManagement] = useState(false); // State to toggle column management view
const [showTagManagementModal, setShowTagManagementModal] = useState(false); // State for tag management modal
const [showSourceManagementModal, setShowSourceManagementModal] = useState(false); // State for source management modal
// Filter states
const [filterStatus, setFilterStatus] = useState('Todos');
const [filterSource, setFilterSource] = useState('');
const [filterName, setFilterName] = useState(''); // New: Filter by name
const [filterStartDate, setFilterStartDate] = useState(''); // New: Filter by date range
const [filterEndDate, setFilterEndDate] = useState(''); // New: Filter by date range
const [filterMinValue, setFilterMinValue] = useState(''); // New: Filter by value range
const [filterMaxValue, setFilterMaxValue] = useState(''); // New: Filter by value range
const [filterTags, setFilterTags] = useState([]); // New: Filter by tags
const [showFilters, setShowFilters] = useState(false); // State to toggle filter visibility, now default to false
// Function to add a notification
const addNotification = useCallback((message, type = 'info') => {
const id = Date.now();
setNotifications(prev => [...prev, { id, message, type }]);
}, []);
// Function to dismiss a notification
const dismissNotification = useCallback((id) => {
setNotifications(prev => prev.filter(notif => notif.id !== id));
}, []);
// Initialize Firebase and set up auth listener
useEffect(() => {
try {
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : {};
if (Object.keys(firebaseConfig).length === 0) {
console.error("Firebase config is missing. Please ensure __firebase_config is provided.");
addNotification("Erro: Configuração do Firebase ausente.", "error");
return;
}
const app = initializeApp(firebaseConfig);
const firestore = getFirestore(app);
const authentication = getAuth(app);
setDb(firestore);
setAuth(authentication);
const unsubscribe = onAuthStateChanged(authentication, async (user) => {
if (user) {
setUserId(user.uid);
} else {
// Sign in anonymously if no user is authenticated
try {
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
await signInWithCustomToken(authentication, __initial_auth_token);
setUserId(authentication.currentUser.uid);
} else {
await signInAnonymously(authentication);
setUserId(authentication.currentUser.uid);
}
} catch (error) {
console.error("Erro ao autenticar no Firebase:", error);
addNotification(`Erro de autenticação: ${error.message}`, "error");
}
}
setIsAuthReady(true);
});
return () => unsubscribe();
} catch (error) {
console.error("Erro na inicialização do Firebase:", error);
addNotification(`Erro de inicialização: ${error.message}`, "error");
}
}, [addNotification]);
// Fetch leads when Firebase is ready and userId is available
useEffect(() => {
if (db && userId && isAuthReady) {
const leadsCollectionRef = collection(db, `artifacts/${__app_id}/users/${userId}/leads`);
const q = query(leadsCollectionRef);
const unsubscribe = onSnapshot(q, async (snapshot) => {
const fetchedLeads = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
if (fetchedLeads.length === 0) {
// Add example lead if no leads exist
const exampleLead = {
name: 'Lead de Exemplo',
email: '
[email protected] ',
phone: '5562983315518', // Example phone number in raw format
status: 'Novo',
source: 'Website',
notes: 'Este é um lead de exemplo para demonstrar o CRM.',
billingValue: 1000.00, // Now using billingValue consistently
tags: ['demo', 'novo'], // Example tags
createdAt: serverTimestamp(), // Explicitly set creation timestamp
customFieldValues: {} // Initialize custom field values
};
await addDoc(leadsCollectionRef, exampleLead);
}
// Sort in memory by createdAt descending
fetchedLeads.sort((a, b) => (b.createdAt?.toDate() || 0) - (a.createdAt?.toDate() || 0));
setLeads(fetchedLeads);
}, (error) => {
console.error("Erro ao buscar leads:", error);
addNotification(`Erro ao carregar leads: ${error.message}`, "error");
});
return () => unsubscribe();
}
}, [db, userId, isAuthReady, addNotification]);
// Fetch Kanban columns when Firebase is ready and userId is available
useEffect(() => {
if (db && userId && isAuthReady) {
const columnsCollectionRef = collection(db, `artifacts/${__app_id}/users/${userId}/kanbanColumns`);
const q = query(columnsCollectionRef, orderBy('order'));
const unsubscribe = onSnapshot(q, async (snapshot) => {
const fetchedColumns = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
if (fetchedColumns.length === 0) {
// If no columns exist, create default ones
const defaultColumns = [
{ name: 'Novo', order: 0 },
{ name: 'Em atendimento', order: 1 },
{ name: 'Qualificado', order: 2 },
{ name: 'Proposta', order: 3 },
{ name: 'Fechado', order: 4 }, // Changed from 'Fechado Ganho'
{ name: 'Perdido', order: 5 } // Changed from 'Fechado Perdido'
];
for (const col of defaultColumns) {
await addDoc(columnsCollectionRef, col);
}
} else {
setKanbanColumns(fetchedColumns);
}
}, (error) => {
console.error("Erro ao buscar colunas Kanban:", error);
addNotification(`Erro ao carregar colunas: ${e.message}`, "error");
});
return () => unsubscribe();
}
}, [db, userId, isAuthReady, addNotification]);
// Fetch available tags
useEffect(() => {
if (db && userId && isAuthReady) {
const tagsCollectionRef = doc(db, `artifacts/${__app_id}/users/${userId}/settings/tags`);
const unsubscribe = onSnapshot(tagsCollectionRef, (docSnap) => {
if (docSnap.exists()) {
setAvailableTags(docSnap.data().allTags || []);
} else {
// Set default tags if none exist
setDoc(tagsCollectionRef, { allTags: ['demo', 'novo'] }, { merge: true });
setAvailableTags(['demo', 'novo']);
}
}, (error) => {
console.error("Erro ao buscar tags:", error);
addNotification(`Erro ao carregar tags: ${e.message}`, "error");
});
return () => unsubscribe();
}
}, [db, userId, isAuthReady, addNotification]);
// Fetch available sources
useEffect(() => {
if (db && userId && isAuthReady) {
const sourcesCollectionRef = doc(db, `artifacts/${__app_id}/users/${userId}/settings/sources`);
const unsubscribe = onSnapshot(sourcesCollectionRef, (docSnap) => {
if (docSnap.exists()) {
setAvailableSources(docSnap.data().allSources || []);
} else {
// Set default sources if none exist
setDoc(sourcesCollectionRef, { allSources: ['Anúncios Google', 'Anúncios Meta', 'Indicação', 'Orgânico', 'Outro'] }, { merge: true });
setAvailableSources(['Anúncios Google', 'Anúncios Meta', 'Indicação', 'Orgânico', 'Outro']);
}
}, (error) => {
console.error("Erro ao buscar origens:", error);
addNotification(`Erro ao carregar origens: ${e.message}`, "error");
});
return () => unsubscribe();
}
}, [db, userId, isAuthReady, addNotification]);
// Fetch custom field definitions
useEffect(() => {
if (db && userId && isAuthReady) {
const customFieldsCollectionRef = collection(db, `artifacts/${__app_id}/users/${userId}/customFieldDefinitions`);
const unsubscribe = onSnapshot(customFieldsCollectionRef, (snapshot) => {
const fetchedDefinitions = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
setCustomFieldDefinitions(fetchedDefinitions);
}, (error) => {
console.error("Erro ao buscar definições de campos personalizados:", error);
addNotification(`Erro ao carregar definições de campos: ${e.message}`, "error");
});
return () => unsubscribe();
}
}, [db, userId, isAuthReady, addNotification]);
// Filtered leads based on current filters
const filteredLeads = useMemo(() => {
return leads.filter(lead => {
const matchesName = filterName === '' || lead.name.toLowerCase().includes(filterName.toLowerCase());
const matchesStatus = filterStatus === 'Todos' || lead.status === filterStatus;
const matchesSource = filterSource === '' || (lead.source && lead.source.toLowerCase().includes(filterSource.toLowerCase()));
const leadDate = lead.createdAt?.toDate ? lead.createdAt.toDate() : null; // Use createdAt for filtering
const start = filterStartDate ? new Date(filterStartDate) : null;
const end = filterEndDate ? new Date(filterEndDate) : null;
const matchesDate = (!start || (leadDate && leadDate >= start)) && (!end || (leadDate && leadDate <= end));
const leadBillingValue = lead.billingValue || 0;
const minVal = filterMinValue === '' ? -Infinity : parseFloat(filterMinValue);
const maxVal = filterMaxValue === '' ? Infinity : parseFloat(filterMaxValue);
const matchesValue = leadBillingValue >= minVal && leadBillingValue <= maxVal;
const matchesTags = filterTags.length === 0 || (lead.tags && filterTags.every(tag => lead.tags.includes(tag)));
return matchesName && matchesStatus && matchesSource && matchesDate && matchesValue && matchesTags;
});
}, [leads, filterStatus, filterSource, filterName, filterStartDate, filterEndDate, filterMinValue, filterMaxValue, filterTags]);
const handleAddLead = async (leadData) => {
if (!db || !userId) {
addNotification("Serviço de banco de dados não disponível.", "error");
return;
}
try {
await addDoc(collection(db, `artifacts/${__app_id}/users/${userId}/leads`), {
...leadData,
createdAt: serverTimestamp(), // Explicitly set creation timestamp
customFieldValues: leadData.customFieldValues || {} // Ensure customFieldValues is initialized
});
addNotification('Lead adicionado com sucesso!', "success");
setSelectedLead(null); // Close the form modal
setView('kanban'); // Go back to kanban after adding
} catch (e) {
console.error("Erro ao adicionar lead: ", e);
addNotification(`Erro ao adicionar lead: ${e.message}`, "error");
}
};
const handleUpdateLead = async (id, leadData) => {
if (!db || !userId) {
addNotification("Serviço de banco de dados não disponível.", "error");
return;
}
try {
const leadRef = doc(db, `artifacts/${__app_id}/users/${userId}/leads`, id);
const currentLead = leads.find(l => l.id === id);
// Check if status is changing to 'Em atendimento' for the first time
if (leadData.status === 'Em atendimento' && (!currentLead || !currentLead.firstResponseAt)) {
leadData.firstResponseAt = serverTimestamp();
}
await updateDoc(leadRef, {
...leadData,
});
addNotification('Lead atualizado com sucesso!', "success");
// If the update was from the detail modal, close it.
if (selectedLead && selectedLead.id === id) {
setSelectedLead(null);
}
setView('kanban'); // Go back to kanban after editing
} catch (e) {
console.error("Erro ao atualizar lead: ", e);
addNotification(`Erro ao atualizar lead: ${e.message}`, "error");
}
};
const handleDeleteLead = async () => {
if (!db || !userId || !leadToDelete) {
addNotification("Serviço de banco de dados não disponível ou lead não selecionado.", "error");
return;
}
try {
const leadRef = doc(db, `artifacts/${__app_id}/users/${userId}/leads`, leadToDelete.id);
await deleteDoc(leadRef);
addNotification('Lead excluído com sucesso!', "success");
setShowConfirmModal(false);
setLeadToDelete(null);
setView('kanban'); // Go back to kanban after deletion
setSelectedLead(null); // Close detail modal if open
} catch (e) {
console.error("Erro ao excluir lead: ", e);
addNotification(`Erro ao excluir lead: ${e.message}`, "error");
}
};
const confirmDelete = (lead) => {
setLeadToDelete(lead);
setShowConfirmModal(true);
};
const cancelDelete = () => {
setShowConfirmModal(false);
setLeadToDelete(null);
};
const navigateTo = (newView, lead = null) => {
setView(newView);
setSelectedLead(lead); // Set selectedLead to open the modal
// No need to clear global message, as it's replaced by notifications
};
const handleStatusChange = async (leadId, newStatus) => {
if (!db || !userId) {
addNotification("Serviço de banco de dados não disponível.", "error");
return;
}
try {
const leadRef = doc(db, `artifacts/${__app_id}/users/${userId}/leads`, leadId);
const currentLead = leads.find(l => l.id === leadId);
const updateData = { status: newStatus };
// Check if status is changing to 'Em atendimento' for the first time
if (newStatus === 'Em atendimento' && (!currentLead || !currentLead.firstResponseAt)) {
updateData.firstResponseAt = serverTimestamp();
}
await updateDoc(leadRef, updateData);
addNotification(`Status do lead atualizado para ${newStatus}!`, "success");
} catch (e) {
console.error("Erro ao atualizar status do lead: ", e);
addNotification(`Erro ao atualizar status: ${e.message}`, "error");
}
};
const handleDownloadLeads = () => {
// Placeholder for download functionality
addNotification('Funcionalidade de Download de Leads em desenvolvimento!', "info");
console.log('Download Leads clicked. Leads data:', leads);
};
// Column Management functions
const handleAddColumn = async (columnName) => {
if (!db || !userId) return;
try {
const newOrder = kanbanColumns.length > 0 ? Math.max(...kanbanColumns.map(c => c.order)) + 1 : 0;
await addDoc(collection(db, `artifacts/${__app_id}/users/${userId}/kanbanColumns`), {
name: columnName,
order: newOrder
});
addNotification('Coluna adicionada com sucesso!', "success");
} catch (e) {
console.error("Erro ao adicionar coluna: ", e);
addNotification(`Erro ao adicionar coluna: ${e.message}`, "error");
}
};
const handleUpdateColumn = async (columnId, newName) => {
if (!db || !userId) return;
try {
const columnRef = doc(db, `artifacts/${__app_id}/users/${userId}/kanbanColumns`, columnId);
await updateDoc(columnRef, { name: newName });
addNotification('Coluna atualizada com sucesso!', "success");
} catch (e) {
console.error("Erro ao atualizar coluna: ", e);
addNotification(`Erro ao atualizar coluna: ${e.message}`, "error");
}
};
const handleReorderColumn = async (columnId, direction) => {
if (!db || !userId) return;
const currentColumnIndex = kanbanColumns.findIndex(col => col.id === columnId);
if (currentColumnIndex === -1) return;
const currentColumn = kanbanColumns[currentColumnIndex];
let targetColumnIndex = -1;
if (direction === 'up' && currentColumnIndex > 0) {
targetColumnIndex = currentColumnIndex - 1;
} else if (direction === 'down' && currentColumnIndex < kanbanColumns.length - 1) {
targetColumnIndex = currentColumnIndex + 1;
}
if (targetColumnIndex !== -1) {
const targetColumn = kanbanColumns[targetColumnIndex];
try {
// Swap orders in Firestore
await updateDoc(doc(db, `artifacts/${__app_id}/users/${userId}/kanbanColumns`, currentColumn.id), { order: targetColumn.order });
await updateDoc(doc(db, `artifacts/${__app_id}/users/${userId}/kanbanColumns`, targetColumn.id), { order: currentColumn.order });
addNotification('Ordem da coluna atualizada!', "success");
} catch (e) {
console.error("Erro ao reordenar coluna: ", e);
addNotification(`Erro ao reordenar coluna: ${e.message}`, "error");
}
}
};
const handleDeleteColumn = async (columnId) => {
if (!db || !userId) return;
const columnToDelete = kanbanColumns.find(col => col.id === columnId);
if (!columnToDelete) return;
// Using a custom modal instead of window.confirm
const confirmAction = async () => {
try {
// Move leads from deleted status to 'Novo'
const leadsToUpdate = leads.filter(lead => lead.status === columnToDelete.name);
const batch = db.batch(); // Firebase batch for multiple updates
for (const lead of leadsToUpdate) {
const leadRef = doc(db, `artifacts/${__app_id}/users/${userId}/leads`, lead.id);
batch.update(leadRef, { status: 'Novo' });
}
await batch.commit();
// Delete the column
await deleteDoc(doc(db, `artifacts/${__app_id}/users/${userId}/kanbanColumns`, columnId));
addNotification('Coluna excluída e leads movidos com sucesso!', "success");
setShowConfirmModal(false); // Close the confirm modal
} catch (e) {
console.error("Erro ao excluir coluna: ", e);
addNotification(`Erro ao excluir coluna: ${e.message}`, "error");
}
};
setLeadToDelete({ id: columnId, name: columnToDelete.name, type: 'column', confirmAction });
setShowConfirmModal(true);
};
// Tag management functions
const handleAddTag = async (tagName) => {
if (!db || !userId) return;
const tagsDocRef = doc(db, `artifacts/${__app_id}/users/${userId}/settings/tags`);
try {
// Use setDoc with merge: true to create the document if it doesn't exist, or update it if it does
await setDoc(tagsDocRef, {
allTags: arrayUnion(tagName)
}, { merge: true });
// No global message here, TagEditor handles local message
} catch (e) {
console.error("Erro ao adicionar tag:", e);
// No global message here, TagEditor handles local message
}
};
const handleRemoveTag = async (tagName) => {
if (!db || !userId) return;
const tagsDocRef = doc(db, `artifacts/${__app_id}/users/${userId}/settings/tags`);
try {
await updateDoc(tagsDocRef, {
allTags: arrayRemove(tagName)
});
// No global message here, TagEditor handles local message
} catch (e) {
console.error("Erro ao remover tag:", e);
// No global message here, TagEditor handles local message
}
};
// Source management functions
const handleAddSource = async (sourceName) => {
if (!db || !userId) return;
const sourcesDocRef = doc(db, `artifacts/${__app_id}/users/${userId}/settings/sources`);
try {
await setDoc(sourcesDocRef, {
allSources: arrayUnion(sourceName)
}, { merge: true });
} catch (e) {
console.error("Erro ao adicionar origem:", e);
}
};
const handleRemoveSource = async (sourceName) => {
if (!db || !userId) return;
const sourcesDocRef = doc(db, `artifacts/${__app_id}/users/${userId}/settings/sources`);
try {
await updateDoc(sourcesDocRef, {
allSources: arrayRemove(sourceName)
});
} catch (e) {
console.error("Erro ao remover origem:", e);
}
};
const handleUpdateSource = async (oldName, newName) => {
if (!db || !userId) return;
const sourcesDocRef = doc(db, `artifacts/${__app_id}/users/${userId}/settings/sources`);
try {
await updateDoc(sourcesDocRef, {
allSources: arrayRemove(oldName)
});
await updateDoc(sourcesDocRef, {
allSources: arrayUnion(newName)
});
// Also update leads that use this source
const leadsToUpdate = leads.filter(lead => lead.source === oldName);
const batch = db.batch();
for (const lead of leadsToUpdate) {
const leadRef = doc(db, `artifacts/${__app_id}/users/${userId}/leads`, lead.id);
batch.update(leadRef, { source: newName });
}
await batch.commit();
} catch (e) {
console.error("Erro ao atualizar origem: ", e);
}
};
// Custom Field Management functions
const handleAddCustomFieldDefinition = async (fieldDefinition) => {
if (!db || !userId) return;
try {
await addDoc(collection(db, `artifacts/${__app_id}/users/${userId}/customFieldDefinitions`), fieldDefinition);
addNotification('Campo personalizado adicionado!', "success");
} catch (e) {
console.error("Erro ao adicionar definição de campo personalizado:", e);
addNotification(`Erro ao adicionar campo personalizado: ${e.message}`, "error");
}
};
const handleUpdateLeadCustomFieldValue = async (leadId, fieldName, value) => {
if (!db || !userId) return;
try {
const leadRef = doc(db, `artifacts/${__app_id}/users/${userId}/leads`, leadId);
await updateDoc(leadRef, {
[`customFieldValues.${fieldName}`]: value
});
// No notification here, as it's a frequent interaction
} catch (e) {
console.error("Erro ao atualizar valor do campo personalizado:", e);
addNotification(`Erro ao atualizar campo: ${e.message}`, "error");
}
};
const handleDeleteCustomFieldDefinition = async (fieldId) => {
if (!db || !userId) return;
try {
await deleteDoc(doc(db, `artifacts/${__app_id}/users/${userId}/customFieldDefinitions`, fieldId));
addNotification('Definição de campo personalizado excluída!', "success");
// Optionally, also remove the field from all leads' customFieldValues
const batch = db.batch();
leads.forEach(lead => {
if (lead.customFieldValues && lead.customFieldValues[fieldId]) {
const leadRef = doc(db, `artifacts/${__app_id}/users/${userId}/leads`, lead.id);
batch.update(leadRef, { [`customFieldValues.${fieldId}`]: deleteField() }); // Use deleteField to remove the field
}
});
await batch.commit();
} catch (e) {
console.error("Erro ao excluir definição de campo personalizado:", e);
addNotification(`Erro ao excluir definição de campo: ${e.message}`, "error");
}
};
if (!isAuthReady) {
return (
);
}
return (
{/* Custom scrollbar styles and global background */}
{/* Reduced mb and pb */}
{/* Reduced mb */}
Rise CRM
{/* User ID moved to a less prominent spot */}
{userId && (
ID do Usuário: {userId}
)}
{/* Summary Dashboard - Always visible, moved closer to top */}
{/* Filter Toggle Button */}
{(view === 'list' || view === 'kanban') && (
setShowFilters(!showFilters)}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white font-semibold rounded-md shadow-md transition duration-300 ease-in-out transform hover:scale-105"
>
{showFilters ? 'Esconder Filtros' : 'Mostrar Filtros'}
)}
{/* Filters - Conditionally rendered */}
{(view === 'list' || view === 'kanban') && showFilters && (
)}
{view === 'list' && (
setSelectedLead(lead)} // Row click opens detail modal
setView={setView} // Pass setView to LeadList
setShowColumnManagement={setShowColumnManagement} // Pass for column management button
setSelectedLead={setSelectedLead} // Pass for new lead button
handleDownloadLeads={handleDownloadLeads} // Pass for download button
currentView={view} // Pass the current view state
setShowTagManagementModal={setShowTagManagementModal} // Pass for tag management button
setShowSourceManagementModal={setShowSourceManagementModal} // Pass for source management button
/>
)}
{view === 'kanban' && (
setSelectedLead(lead)} // Card click opens detail modal
onStatusChange={handleStatusChange}
kanbanColumns={kanbanColumns} // Pass dynamic columns
setView={setView} // Pass setView for toggle button
setShowColumnManagement={setShowColumnManagement} // Pass for column management button
setSelectedLead={setSelectedLead} // Pass for new lead button
handleDownloadLeads={handleDownloadLeads} // Pass for download button
currentView={view} // Pass the current view state
setShowTagManagementModal={setShowTagManagementModal} // Pass for tag management button
setShowSourceManagementModal={setShowSourceManagementModal} // Pass for source management button
/>
)}
{/* LeadForm is now a modal, so it's rendered when selectedLead is 'new' */}
{selectedLead && selectedLead.id === 'new' && (
setSelectedLead(null)} // Close modal
kanbanColumns={kanbanColumns} // Pass columns to form for status dropdown
availableSources={availableSources} // Pass available sources to form
availableTags={availableTags} // Pass available tags to form
addNotification={addNotification} // Pass notification function
/>
)}
{/* LeadDetail is now a modal that handles its own edit state */}
{selectedLead && selectedLead.id !== 'new' && (
setSelectedLead(null)} // Close modal
onUpdateLead={handleUpdateLead} // Pass update function
onDeleteLead={handleDeleteLead} // Pass delete function
kanbanColumns={kanbanColumns}
availableTags={availableTags}
availableSources={availableSources} // Pass available sources to detail
customFieldDefinitions={customFieldDefinitions} // Pass custom field definitions
onAddCustomFieldDefinition={handleAddCustomFieldDefinition} // Pass function to add custom field definition
onUpdateLeadCustomFieldValue={handleUpdateLeadCustomFieldValue} // Pass function to update custom field value
confirmDelete={confirmDelete} // Pass confirmDelete for modal's delete button
addNotification={addNotification} // Pass addNotification for custom field button
/>
)}
{showConfirmModal && (
)}
{showColumnManagement && (
setShowColumnManagement(false)}
onAddColumn={handleAddColumn}
onUpdateColumn={handleUpdateColumn}
onReorderColumn={handleReorderColumn}
onDeleteColumn={handleDeleteColumn}
/>
)}
{showTagManagementModal && (
setShowTagManagementModal(false)}
/>
)}
{showSourceManagementModal && (
setShowSourceManagementModal(false)}
/>
)}
{/* Notifications container */}
{notifications.map(notif => (
))}
);
}
function SummaryDashboard({ leads, kanbanColumns }) {
const totalLeads = leads.length;
const totalSales = leads.filter(lead => lead.status === 'Fechado').length; // Updated to 'Fechado'
const totalLosses = leads.filter(lead => lead.status === 'Perdido').length; // Updated to 'Perdido'
const awaitingContact = leads.filter(lead => lead.status === kanbanColumns.find(col => col.order === 0)?.name || 'Novo').length; // "Novo" is typically the first column
// Calculate average response time
const leadsWithResponseTime = leads.filter(lead => lead.createdAt && lead.firstResponseAt);
const totalResponseTimeMs = leadsWithResponseTime.reduce((sum, lead) => {
const created = lead.createdAt.toDate();
const responded = lead.firstResponseAt.toDate();
return sum + (responded.getTime() - created.getTime());
}, 0);
const averageResponseTimeMs = leadsWithResponseTime.length > 0
? totalResponseTimeMs / leadsWithResponseTime.length
: 0;
// Convert milliseconds to a human-readable format (e.g., days, hours, minutes)
const formatTime = (ms) => {
if (ms === 0) return 'N/A';
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h`;
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
};
// Calculate total billing value for 'Fechado' leads
const totalWonBillingValue = leads
.filter(lead => lead.status === 'Fechado')
.reduce((sum, lead) => sum + (lead.billingValue || 0), 0);
return (
{/* Reduced mb */}
Resumo do CRM
Leads Totais
{totalLeads.toLocaleString('pt-BR')}
Vendas
{totalSales.toLocaleString('pt-BR')}
Perdas
{totalLosses.toLocaleString('pt-BR')}
Aguardando Contato
{awaitingContact.toLocaleString('pt-BR')}
Tempo Médio de Resposta
{formatTime(averageResponseTimeMs)}
Faturamento de Vendas (Fechado)
{totalWonBillingValue.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
);
}
function LeadList({ leads, onView, setView, setShowColumnManagement, setSelectedLead, handleDownloadLeads, currentView, setShowTagManagementModal, setShowSourceManagementModal }) {
const [searchTerm, setSearchTerm] = useState('');
const filteredLeads = leads.filter(lead =>
lead.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(lead.email && lead.email.toLowerCase().includes(searchTerm.toLowerCase())) ||
(lead.phone && unformatPhoneNumber(lead.phone).includes(unformatPhoneNumber(searchTerm))) || // Search unformatted phone
(lead.source && lead.source.toLowerCase().includes(searchTerm.toLowerCase())) ||
(lead.tags && lead.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase())))
);
return (
Lista de Leads
{/* Toggle between Kanban and List View */}
setView(currentView === 'kanban' ? 'list' : 'kanban')}
className="p-2 bg-gray-700 hover:bg-gray-600 text-white rounded-md shadow-md transition duration-300 ease-in-out transform hover:scale-105"
title={currentView === 'kanban' ? 'Mudar para Lista' : 'Mudar para Kanban'}
>
{currentView === 'kanban' ? (
) : (
)}
{/* Button to open Column Management */}
setShowColumnManagement(true)}
className="p-2 bg-gray-700 hover:bg-gray-600 text-white rounded-md shadow-md transition duration-300 ease-in-out transform hover:scale-105"
title="Gerenciar Colunas"
>
{/* Button to open Tag Management Modal */}
setShowTagManagementModal(true)}
className="p-2 bg-gray-700 hover:bg-gray-600 text-white rounded-md shadow-md transition duration-300 ease-in-out transform hover:scale-105"
title="Gerenciar Tags"
>
setSelectedLead({ id: 'new' })} // Open LeadForm as a modal for new lead
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-md shadow-md transition duration-300 ease-in-out transform hover:scale-105"
>
Novo Lead
Baixar Leads
setSearchTerm(e.target.value)}
/>
{filteredLeads.length === 0 ? (
Nenhum lead encontrado com os filtros e busca atuais.
) : (
Nome
Email
Telefone
Status
Origem
Faturamento (R$)
Tags
Data de Criação {/* New column header */}
{filteredLeads.map(lead => (
onView(lead)}
>
{lead.name}
{lead.email}
{formatPhoneNumber(lead.phone) || 'N/A'} {/* Formatted phone number */}
{lead.status}
{lead.source || 'N/A'}
{(lead.billingValue || 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
{lead.tags && lead.tags.map(tag => (
{tag}
))}
{lead.createdAt?.toDate ? lead.createdAt.toDate().toLocaleDateString('pt-BR') : 'N/A'}
))}
)}
);
}
function KanbanBoard({ leads, onView, onStatusChange, kanbanColumns, setView, setShowColumnManagement, setSelectedLead, handleDownloadLeads, currentView, setShowTagManagementModal, setShowSourceManagementModal }) {
const sortedColumns = useMemo(() => {
return [...kanbanColumns].sort((a, b) => a.order - b.order);
}, [kanbanColumns]);
const statusNames = sortedColumns.map(col => col.name);
const handleDragStart = (e, leadId, currentStatus) => {
e.dataTransfer.setData('leadId', leadId);
e.dataTransfer.setData('currentStatus', currentStatus);
};
const handleDragOver = (e) => {
e.preventDefault(); // Necessary to allow dropping
};
const handleDrop = (e, newStatus) => {
e.preventDefault();
const leadId = e.dataTransfer.getData('leadId');
const currentStatus = e.dataTransfer.getData('currentStatus');
if (leadId && currentStatus !== newStatus) {
onStatusChange(leadId, newStatus);
}
};
return (
{/* Main Kanban Board container */}
{/* Fixed Header and Buttons */}
{/* Added sticky, top-0, bg and z-index */}
Kanban de Leads
{/* Toggle between Kanban and List View */}
setView(currentView === 'kanban' ? 'list' : 'kanban')}
className="p-2 bg-gray-700 hover:bg-gray-600 text-white rounded-md shadow-md transition duration-300 ease-in-out transform hover:scale-105"
title={currentView === 'kanban' ? 'Mudar para Lista' : 'Mudar para Kanban'}
>
{currentView === 'kanban' ? (
) : (
)}
{/* Button to open Column Management */}
setShowColumnManagement(true)}
className="p-2 bg-gray-700 hover:bg-gray-600 text-white rounded-md shadow-md transition duration-300 ease-in-out transform hover:scale-105"
title="Gerenciar Colunas"
>
{/* Button to open Tag Management Modal */}
setShowTagManagementModal(true)}
className="p-2 bg-gray-700 hover:bg-gray-600 text-white rounded-md shadow-md transition duration-300 ease-in-out transform hover:scale-105"
title="Gerenciar Tags"
>
setSelectedLead({ id: 'new' })} // Open LeadForm as a modal for new lead
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-md shadow-md transition duration-300 ease-in-out transform hover:scale-105"
>
Novo Lead
Baixar Leads
{/* Scrollable Columns Container */}
{/* Added negative margin and padding to extend to edges */}
{/* Inner flex container for columns */}
{statusNames.map(status => (
handleDrop(e, status)}
>
{status} ({leads.filter(lead => lead.status === status).length.toLocaleString('pt-BR')})
{leads.filter(lead => lead.status === status).length === 0 ? (
Nenhum lead neste status.
) : (
leads.filter(lead => lead.status === status).map(lead => (
))
)}
))}
);
}
function LeadCard({ lead, onView, onStatusChange, allStatuses, onDragStart }) {
return (
onView(lead)}
draggable="true" // Make the card draggable
onDragStart={(e) => onDragStart(e, lead.id, lead.status)} // Handle drag start
>
{lead.name}
Email: {lead.email}
Telefone: {formatPhoneNumber(lead.phone) || 'N/A'}
{/* Formatted phone number */}
Origem: {lead.source || 'N/A'}
Faturamento: {(lead.billingValue || 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
Criado em: {lead.createdAt?.toDate ? lead.createdAt.toDate().toLocaleDateString('pt-BR') : 'N/A'}
{lead.tags && lead.tags.map(tag => (
{tag}
))}
);
}
function LeadForm({ lead, onSave, onCancel, kanbanColumns, availableSources, availableTags, addNotification }) {
const statusOptions = useMemo(() => {
return kanbanColumns.map(col => col.name);
}, [kanbanColumns]);
const initialFormData = useRef(null);
const isMouseDownOnOverlay = useRef(false); // New ref to track mouse down on overlay
const [formData, setFormData] = useState({
name: '',
phone: '',
email: '',
source: availableSources.length > 0 ? availableSources[0] : '', // Default to first available source
notes: '',
billingValue: '',
tags: [],
status: statusOptions.length > 0 ? statusOptions[0] : 'Novo',
customFieldValues: {} // Initialize custom field values
});
useEffect(() => {
const currentData = {
name: lead?.name || '',
phone: unformatPhoneNumber(lead?.phone || ''), // Unformat phone for internal state
email: lead?.email || '',
source: lead?.source || (availableSources.length > 0 ? availableSources[0] : ''),
notes: lead?.notes || '',
billingValue: lead?.billingValue || '',
tags: lead?.tags || [],
status: lead?.status || (statusOptions.length > 0 ? statusOptions[0] : 'Novo'),
customFieldValues: lead?.customFieldValues || {}
};
setFormData(currentData);
initialFormData.current = currentData; // Store initial data for comparison
}, [lead, statusOptions, availableSources]);
const hasUnsavedChanges = useMemo(() => {
return JSON.stringify(formData) !== JSON.stringify(initialFormData.current);
}, [formData]);
const handleChange = (e) => {
const { name, value } = e.target;
if (name === 'phone') {
const cleaned = value.replace(/\D/g, ''); // Only digits
setFormData(prev => ({ ...prev, [name]: cleaned })); // Store unformatted number
} else {
setFormData(prev => ({ ...prev, [name]: value }));
}
};
const handlePhoneBlur = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: unformatPhoneNumber(value) })); // Ensure it's unformatted on blur for storage
};
const handleTagChange = (newTags) => {
setFormData(prev => ({ ...prev, tags: newTags }));
};
const handleSaveAndClose = () => {
const dataToSave = {
...formData,
billingValue: formData.billingValue !== '' ? parseFloat(formData.billingValue) : 0,
phone: unformatPhoneNumber(formData.phone) // Ensure phone is unformatted for saving
};
if (lead && lead.id !== 'new') {
onSave(lead.id, dataToSave);
} else {
onSave(dataToSave);
}
initialFormData.current = formData; // Update initial data after saving
onCancel(); // Close modal after saving
};
const handleDiscardAndClose = () => {
onCancel(); // Just close, discarding changes
};
const handleOverlayMouseDown = (e) => {
if (e.target === e.currentTarget) { // Check if click started directly on the overlay
isMouseDownOnOverlay.current = true;
}
};
const handleOverlayMouseUp = (e) => {
if (isMouseDownOnOverlay.current && e.target === e.currentTarget) { // Check if click ended directly on the overlay
handleCloseAttempt();
}
isMouseDownOnOverlay.current = false; // Reset flag
};
const handleCloseAttempt = () => {
if (hasUnsavedChanges) {
addNotification('Você tem alterações não salvas. Deseja salvar?', 'info');
setShowUnsavedChangesConfirmModal(true);
} else {
onCancel();
}
};
const [showUnsavedChangesConfirmModal, setShowUnsavedChangesConfirmModal] = useState(false);
return (
e.stopPropagation()}> {/* Prevent click from bubbling */}
{lead && lead.id !== 'new' ? 'Editar Lead' : 'Adicionar Novo Lead'}
{showUnsavedChangesConfirmModal && (
setShowUnsavedChangesConfirmModal(false)}
/>
)}
);
}
function LeadDetail({ lead, onClose, onUpdateLead, onDeleteLead, kanbanColumns, availableTags, availableSources, customFieldDefinitions, onAddCustomFieldDefinition, onUpdateLeadCustomFieldValue, confirmDelete, addNotification }) {
const statusOptions = useMemo(() => {
return kanbanColumns.map(col => col.name);
}, [kanbanColumns]);
const initialFormData = useRef(null);
const isMouseDownOnOverlay = useRef(false); // New ref to track mouse down on overlay
const [formData, setFormData] = useState({
name: lead.name || '',
email: lead.email || '',
phone: lead.phone || '',
status: lead.status || (statusOptions.length > 0 ? statusOptions[0] : 'Novo'),
source: lead.source || (availableSources.length > 0 ? availableSources[0] : ''),
notes: lead.notes || '',
billingValue: lead.billingValue || '',
tags: lead.tags || [],
customFieldValues: lead.customFieldValues || {} // Ensure customFieldValues is initialized
});
const [showCustomFieldForm, setShowCustomFieldForm] = useState(false);
const [showUnsavedChangesConfirmModal, setShowUnsavedChangesConfirmModal] = useState(false);
useEffect(() => {
if (lead) {
const currentData = {
name: lead.name || '',
email: lead.email || '',
phone: unformatPhoneNumber(lead.phone || ''), // Unformat phone for internal state
status: lead.status || (statusOptions.length > 0 ? statusOptions[0] : 'Novo'),
source: lead.source || (availableSources.length > 0 ? availableSources[0] : ''),
notes: lead.notes || '',
billingValue: lead.billingValue || '',
tags: lead.tags || [],
customFieldValues: lead.customFieldValues || {}
};
setFormData(currentData);
initialFormData.current = currentData; // Store initial data for comparison
}
}, [lead, statusOptions, availableSources]);
const hasUnsavedChanges = useMemo(() => {
return JSON.stringify(formData) !== JSON.stringify(initialFormData.current);
}, [formData]);
const handleChange = (e) => {
const { name, value } = e.target;
if (name === 'phone') {
const cleaned = value.replace(/\D/g, ''); // Only digits
setFormData(prev => ({ ...prev, [name]: cleaned })); // Store unformatted number
} else {
setFormData(prev => ({ ...prev, [name]: value }));
}
};
const handlePhoneBlur = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: unformatPhoneNumber(value) })); // Ensure it's unformatted on blur for storage
};
const handleTagChange = (newTags) => {
setFormData(prev => ({ ...prev, tags: newTags }));
};
const handleCustomFieldChange = (fieldId, value) => {
setFormData(prev => ({
...prev,
customFieldValues: {
...prev.customFieldValues,
[fieldId]: value
}
}));
onUpdateLeadCustomFieldValue(lead.id, fieldId, value);
};
const handleSaveAndClose = () => {
const dataToSave = {
...formData,
billingValue: formData.billingValue !== '' ? parseFloat(formData.billingValue) : 0,
phone: unformatPhoneNumber(formData.phone) // Ensure phone is unformatted for saving
};
onUpdateLead(lead.id, dataToSave);
initialFormData.current = formData; // Update initial data after saving
onClose(); // Close modal after saving
};
const handleDiscardAndClose = () => {
onClose(); // Just close, discarding changes
};
const handleOverlayMouseDown = (e) => {
if (e.target === e.currentTarget) { // Check if click started directly on the overlay
isMouseDownOnOverlay.current = true;
}
};
const handleOverlayMouseUp = (e) => {
if (isMouseDownOnOverlay.current && e.target === e.currentTarget) { // Check if click ended directly on the overlay
handleCloseAttempt();
}
isMouseDownOnOverlay.current = false; // Reset flag
};
const handleCloseAttempt = () => {
if (hasUnsavedChanges) {
addNotification('Você tem alterações não salvas. Deseja salvar?', 'info');
setShowUnsavedChangesConfirmModal(true);
} else {
onClose();
}
};
return (
e.stopPropagation()}> {/* Prevent click from bubbling */}
{/* Close button at top right */}
{lead.name}
ID: {lead.id}
{/* Scrollable content area */}
{/* Added pr-4 for scrollbar space */}
{/* Editable Fields Section */}
Campos Editáveis
Status:
{statusOptions.map(status => (
{status}
))}
{/* New button for custom field creation */}
setShowCustomFieldForm(true)}
className="w-full px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white font-semibold rounded-md shadow-md transition duration-300 ease-in-out transform hover:scale-105" // Changed to gray
>
Adicionar Campo Personalizado
{/* Custom Fields Section */}
{customFieldDefinitions.length > 0 && (
Campos Personalizados
{customFieldDefinitions.map(fieldDef => (
))}
)}
{/* Lead Information Section (Read-only) */}
Informações do Lead
Detalhes de Contato
{lead.email}
{formatPhoneNumber(lead.phone) || 'N/A'}
{/* Formatted phone number */}
{lead.source || 'N/A'}
Criado em: {lead.createdAt?.toDate ? lead.createdAt.toDate().toLocaleString('pt-BR') : 'N/A'}
Descrição do Projeto
{lead.notes || 'N/A'}
{/* Action Buttons */}
Salvar Alterações
{
handleCloseAttempt(); // Check for unsaved changes before confirming delete
// The confirmDelete modal will be shown after the unsaved changes modal, if applicable
}}
className="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-semibold rounded-md shadow-md transition duration-300 ease-in-out transform hover:scale-105"
>
Excluir Lead
{showCustomFieldForm && (
setShowCustomFieldForm(false)}
addNotification={addNotification}
/>
)}
{showUnsavedChangesConfirmModal && (
setShowUnsavedChangesConfirmModal(false)}
/>
)}
);
}
function CustomFieldForm({ onSave, onClose, addNotification }) {
const initialFormData = useRef(null);
const isMouseDownOnOverlay = useRef(false); // New ref to track mouse down on overlay
const [fieldName, setFieldName] = useState('');
const [fieldType, setFieldType] = useState('texto');
const [options, setOptions] = useState(''); // For multiple choice/checkbox
const [localMessage, setLocalMessage] = useState('');
const [showUnsavedChangesConfirmModal, setShowUnsavedChangesConfirmModal] = useState(false);
useEffect(() => {
// Initialize initialFormData
initialFormData.current = { fieldName: '', fieldType: 'texto', options: '' };
}, []);
const hasUnsavedChanges = useMemo(() => {
const currentData = { fieldName, fieldType, options };
return JSON.stringify(currentData) !== JSON.stringify(initialFormData.current);
}, [fieldName, fieldType, options]);
const handleSubmit = (e) => {
e.preventDefault();
if (fieldName.trim() === '') {
setLocalMessage('O nome do campo não pode estar vazio.');
return;
}
const newField = {
name: fieldName.trim(),
type: fieldType,
};
if (fieldType === 'multipla-escolha' || fieldType === 'caixa-selecao') {
if (options.trim() === '') {
setLocalMessage('As opções não podem estar vazias para este tipo de campo.');
return;
}
newField.options = options.split(',').map(opt => opt.trim()).filter(opt => opt !== '');
}
onSave(newField);
initialFormData.current = { fieldName: '', fieldType: 'texto', options: '' }; // Reset initial data after saving
onClose();
};
const handleSaveAndClose = () => {
handleSubmit({ preventDefault: () => {} }); // Simulate form submission
};
const handleDiscardAndClose = () => {
onClose(); // Just close, discarding changes
};
const handleOverlayMouseDown = (e) => {
if (e.target === e.currentTarget) { // Check if click started directly on the overlay
isMouseDownOnOverlay.current = true;
}
};
const handleOverlayMouseUp = (e) => {
if (isMouseDownOnOverlay.current && e.target === e.currentTarget) { // Check if click ended directly on the overlay
handleCloseAttempt();
}
isMouseDownOnOverlay.current = false; // Reset flag
};
const handleCloseAttempt = () => {
if (hasUnsavedChanges) {
addNotification('Você tem alterações não salvas. Deseja salvar?', 'info');
setShowUnsavedChangesConfirmModal(true);
} else {
onClose();
}
};
return (
e.stopPropagation()}>
Definir Novo Campo Personalizado
Nome do Campo Novo:
{ setFieldName(e.target.value); setLocalMessage(''); }}
className="w-full p-3 bg-gray-700 text-gray-100 border border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
Tipo:
{ setFieldType(e.target.value); setLocalMessage(''); }}
className="w-full p-3 bg-gray-700 text-gray-100 border border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Texto
Número
Valor (R$)
Data
Múltipla Escolha
Caixa de Seleção
{(fieldType === 'multipla-escolha' || fieldType === 'caixa-selecao') && (
Opções (separadas por vírgula):
{ setOptions(e.target.value); setLocalMessage(''); }}
rows="3"
placeholder="Ex: Opção A, Opção B, Opção C"
className="w-full p-3 bg-gray-700 text-gray-100 border border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
)}
{localMessage && (
{localMessage}
)}
Cancelar
Salvar Campo
{showUnsavedChangesConfirmModal && (
setShowUnsavedChangesConfirmModal(false)}
/>
)}
);
}
function ConfirmModal({ message, onConfirm, onCancel }) {
return (
{/* Click outside to close */}
e.stopPropagation()}> {/* Prevent click from bubbling */}
{message}
Cancelar
Confirmar
);
}
function UnsavedChangesConfirmModal({ message, onSave, onDiscard, onCancel }) {
return (
e.stopPropagation()}>
{message}
Descartar
Cancelar
Salvar
);
}
function ColumnManagement({ kanbanColumns, onClose, onAddColumn, onUpdateColumn, onReorderColumn, onDeleteColumn }) {
const [newColumnName, setNewColumnName] = useState('');
const [editingColumnId, setEditingColumnId] = useState(null);
const [editingColumnName, setEditingColumnName] = useState('');
const handleEditClick = (column) => {
setEditingColumnId(column.id);
setEditingColumnName(column.name);
};
const handleSaveEdit = (columnId) => {
if (editingColumnName.trim() === '') {
// Using a custom modal instead of alert
// alert('O nome da coluna não pode estar vazio.');
// For now, I'll keep the alert as per previous code, but recommend a custom modal here
console.error('O nome da coluna não pode estar vazio.');
return;
}
onUpdateColumn(columnId, editingColumnName);
setEditingColumnId(null);
setEditingColumnName('');
};
const handleCancelEdit = () => {
setEditingColumnId(null);
setEditingColumnName('');
};
const handleAdd = () => {
if (newColumnName.trim() === '') {
// Using a custom modal instead of alert
// alert('O nome da nova coluna não pode estar vazio.');
// For now, I'll keep the alert as per previous code, but recommend a custom modal here
console.error('O nome da nova coluna não pode estar vazio.');
return;
}
onAddColumn(newColumnName);
setNewColumnName('');
};
return (
{/* Click outside to close */}
e.stopPropagation()}> {/* Prevent click from bubbling */}
Gerenciar Colunas Kanban
{/* Add New Column */}
{/* Existing Columns List */}
Colunas Existentes
{kanbanColumns.length === 0 ? (
Nenhuma coluna configurada.
) : (
{kanbanColumns.map((column, index) => (
{editingColumnId === column.id ? (
setEditingColumnName(e.target.value)}
className="flex-grow p-2 bg-gray-700 text-gray-100 border border-blue-500 rounded-md focus:outline-none"
/>
) : (
{column.name}
)}
{editingColumnId === column.id ? (
<>
handleSaveEdit(column.id)}
className="p-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition"
title="Salvar"
>
>
) : (
<>
handleEditClick(column)}
className="p-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-md transition"
title="Editar"
>
onReorderColumn(column.id, 'up')}
disabled={index === 0}
className="p-2 bg-gray-600 hover:bg-gray-700 text-white rounded-md disabled:opacity-50 transition"
title="Mover para cima"
>
onReorderColumn(column.id, 'down')}
disabled={index === kanbanColumns.length - 1}
className="p-2 bg-gray-600 hover:bg-gray-700 text-white rounded-md disabled:opacity-50 transition"
title="Mover para baixo"
>
onDeleteColumn(column.id)}
className="p-2 bg-red-600 hover:bg-red-700 text-white rounded-md transition"
title="Excluir"
>
>
)}
))}
)}
Fechar
);
}
function TagManagementModal({ availableTags, onAddTag, onRemoveTag, onClose }) {
return (
{/* Click outside to close */}
e.stopPropagation()}> {/* Prevent click from bubbling */}
Gerenciar Tags
Fechar
);
}
function TagEditor({ availableTags, onAddTag, onRemoveTag }) {
const [newTag, setNewTag] = useState('');
const [localMessage, setLocalMessage] = useState('');
const handleAdd = async () => {
if (newTag.trim() === '') {
setLocalMessage('O nome da tag não pode estar vazio.');
return;
}
if (availableTags.includes(newTag.trim())) {
setLocalMessage(`A tag "${newTag.trim()}" já existe.`);
return;
}
await onAddTag(newTag.trim());
setNewTag('');
setLocalMessage(`Tag "${newTag.trim()}" adicionada!`);
setTimeout(() => setLocalMessage(''), 3000);
};
const handleRemove = async (tagName) => {
await onRemoveTag(tagName);
setLocalMessage(`Tag "${tagName}" removida!`);
setTimeout(() => setLocalMessage(''), 3000);
};
return (
Adicionar/Remover Tags
{
setNewTag(e.target.value);
setLocalMessage('');
}}
className="flex-grow p-2 bg-gray-600 text-gray-100 border border-gray-500 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
Adicionar
{localMessage && (
{localMessage}
)}
{availableTags.map(tag => (
{tag}
handleRemove(tag)}
className="ml-2 text-red-300 hover:text-red-500 transition"
title="Remover tag"
>
))}
);
}
function TagInput({ selectedTags, availableTags, onTagChange, isFilter = false }) {
const [inputValue, setInputValue] = useState('');
const [showSuggestions, setShowSuggestions] = useState(false);
const filteredSuggestions = useMemo(() => {
const lowerInput = inputValue.toLowerCase();
return availableTags.filter(tag =>
tag.toLowerCase().includes(lowerInput) && !selectedTags.includes(tag)
).slice(0, 5); // Limit suggestions to 5
}, [inputValue, availableTags, selectedTags]);
const handleInputChange = (e) => {
setInputValue(e.target.value);
setShowSuggestions(true);
};
const handleAddTag = (tagToAdd) => {
if (!selectedTags.includes(tagToAdd)) {
onTagChange([...selectedTags, tagToAdd]);
}
setInputValue('');
setShowSuggestions(false);
};
const handleRemoveTag = (tagToRemove) => {
onTagChange(selectedTags.filter(tag => tag !== tagToRemove));
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && inputValue.trim() !== '') {
e.preventDefault(); // Prevent form submission
const tagToAdd = inputValue.trim();
if (!selectedTags.includes(tagToAdd) && availableTags.includes(tagToAdd)) {
handleAddTag(tagToAdd);
} else if (!selectedTags.includes(tagToAdd) && !availableTags.includes(tagToAdd)) {
// Optionally allow creating new tags directly from here if desired
// For now, only allow adding existing tags via Enter
// You could call a prop like onCreateNewTag(tagToAdd) here
}
} else if (e.key === 'Backspace' && inputValue === '' && selectedTags.length > 0) {
// Remove last tag if input is empty and backspace is pressed
handleRemoveTag(selectedTags[selectedTags.length - 1]);
}
};
return (
document.getElementById('tag-input-field').focus()}>
{selectedTags.map(tag => (
{tag}
{ e.stopPropagation(); handleRemoveTag(tag); }} // Use onMouseDown
className="ml-1 text-red-300 hover:text-red-500 focus:outline-none"
title="Remover tag"
>
))}
setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 100)} // Delay to allow click on suggestion
placeholder={selectedTags.length === 0 ? "Adicionar tags..." : ""}
className="flex-grow bg-transparent text-gray-100 placeholder-gray-400 focus:outline-none min-w-[100px]"
/>
{showSuggestions && filteredSuggestions.length > 0 && (
{filteredSuggestions.map(tag => (
handleAddTag(tag)} // Use onMouseDown to prevent blur before click
className="p-2 cursor-pointer hover:bg-gray-600 text-gray-200"
>
{tag}
))}
)}
);
}
function SourceManagementModal({ availableSources, onAddSource, onRemoveSource, onUpdateSource, onClose }) {
const [newSourceName, setNewSourceName] = useState('');
const [editingSource, setEditingSource] = useState(null);
const [editingSourceName, setEditingSourceName] = useState('');
const [localMessage, setLocalMessage] = useState('');
const handleAdd = async () => {
if (newSourceName.trim() === '') {
setLocalMessage('O nome da origem não pode estar vazio.');
return;
}
if (availableSources.includes(newSourceName.trim())) {
setLocalMessage(`A origem "${newSourceName.trim()}" já existe.`);
return;
}
await onAddSource(newSourceName.trim());
setNewSourceName('');
setLocalMessage(`Origem "${newSourceName.trim()}" adicionada!`);
setTimeout(() => setLocalMessage(''), 3000);
};
const handleRemove = async (sourceName) => {
await onRemoveSource(sourceName);
setLocalMessage(`Origem "${sourceName}" removida!`);
setTimeout(() => setLocalMessage(''), 3000);
};
const handleEditClick = (source) => {
setEditingSource(source);
setEditingSourceName(source);
};
const handleSaveEdit = async () => {
if (editingSourceName.trim() === '') {
setLocalMessage('O nome da origem não pode estar vazio.');
return;
}
if (editingSource && editingSourceName !== editingSource) {
if (availableSources.includes(editingSourceName.trim())) {
setLocalMessage(`A origem "${editingSourceName.trim()}" já existe.`);
return;
}
await onUpdateSource(editingSource, editingSourceName.trim());
setLocalMessage(`Origem "${editingSource}" atualizada para "${editingSourceName}"!`);
setTimeout(() => setLocalMessage(''), 3000);
}
setEditingSource(null);
setEditingSourceName('');
};
const handleCancelEdit = () => {
setEditingSource(null);
setEditingSourceName('');
};
return (
{/* Click outside to close */}
e.stopPropagation()}> {/* Prevent click from bubbling */}
Gerenciar Origens
Origens Existentes
{availableSources.length === 0 ? (
Nenhuma origem configurada.
) : (
{availableSources.map(source => (
{editingSource === source ? (
setEditingSourceName(e.target.value)}
className="flex-grow p-2 bg-gray-700 text-gray-100 border border-blue-500 rounded-md focus:outline-none"
/>
) : (
{source}
)}
{editingSource === source ? (
<>
>
) : (
<>
handleEditClick(source)}
className="p-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-md transition"
title="Editar"
>
handleRemove(source)}
className="p-2 bg-red-600 hover:bg-red-700 text-white rounded-md transition"
title="Excluir"
>
>
)}
))}
)}
{localMessage && (
{localMessage}
)}
Fechar
);
}
export default App;