Setup project structure, dependencies, and basic configuration for the ComFi application. Includes initial setup for Vite, React, TypeScript, Tailwind CSS, and essential development tools. Defines core types and provides a basic README for local development.
354 lines
18 KiB
TypeScript
354 lines
18 KiB
TypeScript
|
|
import React, { useState } from 'react';
|
|
import { Search, Plus, User, Briefcase, Pencil, X, Sparkles, Trash2 } from 'lucide-react';
|
|
import { Client, Service } from '../types';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { CustomSelect } from './CustomSelect';
|
|
|
|
interface ManagementViewProps {
|
|
type: 'clients' | 'services';
|
|
clientsData: Client[];
|
|
setClientsData: (data: Client[]) => void;
|
|
servicesData: Service[];
|
|
setServicesData: (data: Service[]) => void;
|
|
}
|
|
|
|
// --- MODAIS ---
|
|
|
|
const ClientModal: React.FC<{ isOpen: boolean; onClose: () => void; client: Client | null; onSave: (client: Client) => void }> = ({ isOpen, onClose, client, onSave }) => {
|
|
const [formData, setFormData] = useState<Partial<Client>>(
|
|
client || { name: '', company: '', email: '', phone: '', address: '', status: 'active', value: 0 }
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (client) setFormData(client);
|
|
else setFormData({ name: '', company: '', email: '', phone: '', address: '', status: 'active', value: 0 });
|
|
}, [client]);
|
|
|
|
if (!isOpen) return null;
|
|
const inputClass = "w-full p-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none text-slate-700 text-sm transition-all shadow-sm placeholder:text-slate-400";
|
|
const labelClass = "block text-xs font-bold text-slate-600 mb-1.5 uppercase tracking-wide";
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={onClose}></div>
|
|
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg animate-scale-up flex flex-col max-h-[90vh]">
|
|
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-white flex-shrink-0">
|
|
<h3 className="text-lg font-bold text-slate-800">{client ? 'Editar Cliente' : 'Novo Cliente'}</h3>
|
|
<button onClick={onClose}><X size={20} className="text-slate-400 hover:text-slate-600" /></button>
|
|
</div>
|
|
<div className="p-6 space-y-5 overflow-y-auto">
|
|
<div>
|
|
<label className={labelClass}>Nome do Contato</label>
|
|
<input type="text" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} className={inputClass} placeholder="Ex: João Silva"/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Empresa</label>
|
|
<input type="text" value={formData.company} onChange={e => setFormData({...formData, company: e.target.value})} className={inputClass} placeholder="Ex: Empresa X"/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelClass}>Email</label>
|
|
<input type="email" value={formData.email} onChange={e => setFormData({...formData, email: e.target.value})} className={inputClass} />
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Telefone</label>
|
|
<input type="text" value={formData.phone} onChange={e => setFormData({...formData, phone: e.target.value})} className={inputClass} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-3 flex-shrink-0 rounded-b-2xl">
|
|
<button onClick={onClose} className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-200 rounded-xl transition-colors">Cancelar</button>
|
|
<button onClick={() => onSave(formData as Client)} className="px-6 py-2 bg-primary-500 text-white font-bold rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200">Salvar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ServiceModal: React.FC<{ isOpen: boolean; onClose: () => void; service: Service | null; onSave: (service: Service) => void }> = ({ isOpen, onClose, service, onSave }) => {
|
|
const [formData, setFormData] = useState<Partial<Service>>(
|
|
service || { name: '', category: 'Consultoria', price: 0, active: true, description: '', billingType: 'one-time' }
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (service) setFormData(service);
|
|
else setFormData({ name: '', category: 'Consultoria', price: 0, active: true, description: '', billingType: 'one-time' });
|
|
}, [service]);
|
|
|
|
if (!isOpen) return null;
|
|
const inputClass = "w-full p-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none text-slate-700 text-sm transition-all shadow-sm placeholder:text-slate-400";
|
|
const labelClass = "block text-xs font-bold text-slate-600 mb-1.5 uppercase tracking-wide";
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={onClose}></div>
|
|
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg animate-scale-up flex flex-col max-h-[90vh]">
|
|
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-white flex-shrink-0">
|
|
<h3 className="text-lg font-bold text-slate-800">{service ? 'Editar Serviço' : 'Novo Serviço'}</h3>
|
|
<button onClick={onClose}><X size={20} className="text-slate-400 hover:text-slate-600" /></button>
|
|
</div>
|
|
<div className="p-6 space-y-5 overflow-y-auto">
|
|
<div>
|
|
<label className={labelClass}>Nome do Serviço</label>
|
|
<input type="text" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} className={inputClass} placeholder="Ex: Consultoria SEO"/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelClass}>Preço (R$)</label>
|
|
<input type="number" value={formData.price} onChange={e => setFormData({...formData, price: Number(e.target.value)})} className={inputClass} />
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Cobrança</label>
|
|
<CustomSelect
|
|
value={formData.billingType || 'one-time'}
|
|
onChange={(val) => setFormData({...formData, billingType: val})}
|
|
options={[
|
|
{ value: 'one-time', label: 'Pontual (Única)' },
|
|
{ value: 'recurring', label: 'Assinatura (Mensal)' }
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className={labelClass}>Categoria</label>
|
|
<CustomSelect
|
|
value={formData.category || 'Consultoria'}
|
|
onChange={(val) => setFormData({...formData, category: val})}
|
|
options={[
|
|
{ value: 'Consultoria', label: 'Consultoria' },
|
|
{ value: 'TI', label: 'TI / Desenvolvimento' },
|
|
{ value: 'Marketing', label: 'Marketing / Design' },
|
|
{ value: 'Outro', label: 'Outro' }
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className={labelClass}>Descrição</label>
|
|
<textarea rows={3} value={formData.description} onChange={e => setFormData({...formData, description: e.target.value})} className={inputClass} placeholder="O que está incluso..."></textarea>
|
|
</div>
|
|
</div>
|
|
<div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-3 flex-shrink-0 rounded-b-2xl">
|
|
<button onClick={onClose} className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-200 rounded-xl transition-colors">Cancelar</button>
|
|
<button onClick={() => onSave(formData as Service)} className="px-6 py-2 bg-primary-500 text-white font-bold rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200">Salvar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
|
|
export const ManagementView: React.FC<ManagementViewProps> = ({ type, clientsData, setClientsData, servicesData, setServicesData }) => {
|
|
const { addToast } = useToast();
|
|
|
|
const [isClientModalOpen, setIsClientModalOpen] = useState(false);
|
|
const [editingClient, setEditingClient] = useState<Client | null>(null);
|
|
|
|
const [isServiceModalOpen, setIsServiceModalOpen] = useState(false);
|
|
const [editingService, setEditingService] = useState<Service | null>(null);
|
|
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
|
|
// --- SERVICE ACTIONS ---
|
|
const handleSaveService = (serviceData: Service) => {
|
|
if (!serviceData.name || serviceData.price < 0) {
|
|
addToast({ type: 'warning', title: 'Dados Inválidos', message: 'Verifique o nome e o preço.' });
|
|
return;
|
|
}
|
|
|
|
if (editingService) {
|
|
setServicesData(servicesData.map(s => s.id === serviceData.id ? serviceData : s));
|
|
addToast({ type: 'success', title: 'Serviço Atualizado' });
|
|
} else {
|
|
const newService = { ...serviceData, id: Math.random().toString(36).substr(2, 9) };
|
|
setServicesData([...servicesData, newService]);
|
|
addToast({ type: 'success', title: 'Serviço Criado' });
|
|
}
|
|
setIsServiceModalOpen(false);
|
|
setEditingService(null);
|
|
};
|
|
|
|
const handleDeleteService = (id: string) => {
|
|
if (window.confirm('Excluir este serviço?')) {
|
|
setServicesData(servicesData.filter(s => s.id !== id));
|
|
addToast({ type: 'info', title: 'Serviço Removido' });
|
|
}
|
|
};
|
|
|
|
// --- CLIENT ACTIONS ---
|
|
const handleSaveClient = (clientData: Client) => {
|
|
if (!clientData.name) {
|
|
addToast({ type: 'warning', title: 'Nome Obrigatório' });
|
|
return;
|
|
}
|
|
|
|
if(editingClient) {
|
|
setClientsData(clientsData.map(c => c.id === clientData.id ? clientData : c));
|
|
addToast({ type: 'success', title: 'Cliente Atualizado' });
|
|
} else {
|
|
setClientsData([...clientsData, {...clientData, id: Math.random().toString().substr(2, 9)}]);
|
|
addToast({ type: 'success', title: 'Cliente Cadastrado' });
|
|
}
|
|
setIsClientModalOpen(false);
|
|
setEditingClient(null);
|
|
};
|
|
|
|
const handleDeleteClient = (id: string) => {
|
|
if (window.confirm('Excluir este cliente?')) {
|
|
setClientsData(clientsData.filter(c => c.id !== id));
|
|
addToast({ type: 'info', title: 'Cliente Removido' });
|
|
}
|
|
};
|
|
|
|
// --- FILTERING ---
|
|
const filteredServices = servicesData.filter(s => s.name.toLowerCase().includes(searchTerm.toLowerCase()) || s.category.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
const filteredClients = clientsData.filter(c => c.name.toLowerCase().includes(searchTerm.toLowerCase()) || c.company.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
|
|
return (
|
|
<div className="space-y-6 animate-fade-in relative pb-10">
|
|
<ClientModal
|
|
isOpen={isClientModalOpen}
|
|
onClose={() => setIsClientModalOpen(false)}
|
|
client={editingClient}
|
|
onSave={handleSaveClient}
|
|
/>
|
|
<ServiceModal
|
|
isOpen={isServiceModalOpen}
|
|
onClose={() => setIsServiceModalOpen(false)}
|
|
service={editingService}
|
|
onSave={handleSaveService}
|
|
/>
|
|
|
|
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-800">
|
|
{type === 'clients' ? 'Base de Clientes (Legado)' : 'Catálogo de Serviços'}
|
|
</h1>
|
|
<p className="text-slate-500">
|
|
{type === 'clients' ? 'Gestão simples de contatos.' : 'Gerencie preços e portfólio.'}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
if (type === 'clients') { setEditingClient(null); setIsClientModalOpen(true); }
|
|
else { setEditingService(null); setIsServiceModalOpen(true); }
|
|
}}
|
|
className="flex items-center gap-2 px-5 py-2.5 bg-primary-500 text-white rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200/50 transition-all font-bold"
|
|
>
|
|
<Plus size={18} />
|
|
{type === 'clients' ? 'Novo Cliente' : 'Novo Serviço'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
|
{/* Toolbar */}
|
|
<div className="p-4 border-b border-slate-100">
|
|
<div className="relative max-w-md">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
|
<input
|
|
type="text"
|
|
placeholder={type === 'clients' ? "Buscar cliente..." : "Buscar serviço..."}
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border-none rounded-xl focus:ring-2 focus:ring-primary-500 outline-none text-slate-600"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table Content */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead className="bg-slate-50/50">
|
|
<tr>
|
|
<th className="p-4 text-xs font-bold uppercase text-slate-500 tracking-wide">{type === 'clients' ? 'Nome / Empresa' : 'Serviço'}</th>
|
|
<th className="p-4 text-xs font-bold uppercase text-slate-500 tracking-wide">{type === 'clients' ? 'Contato' : 'Preço'}</th>
|
|
<th className="p-4 text-xs font-bold uppercase text-slate-500 tracking-wide">{type === 'clients' ? 'Status' : 'Tipo / Categoria'}</th>
|
|
<th className="p-4 text-right"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-50">
|
|
{type === 'services' && filteredServices.map(service => (
|
|
<tr key={service.id} className="hover:bg-slate-50 transition-colors group">
|
|
<td className="p-4">
|
|
<div className="font-bold text-slate-800">{service.name}</div>
|
|
{service.description && <div className="text-xs text-slate-400 line-clamp-1">{service.description}</div>}
|
|
</td>
|
|
<td className="p-4 font-bold text-slate-700">R$ {service.price.toLocaleString('pt-BR')}</td>
|
|
<td className="p-4">
|
|
<div className="flex gap-2">
|
|
<span className={`px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wide ${service.billingType === 'recurring' ? 'bg-indigo-50 text-indigo-600' : 'bg-slate-100 text-slate-600'}`}>
|
|
{service.billingType === 'recurring' ? 'Assinatura' : 'Pontual'}
|
|
</span>
|
|
<span className="px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wide bg-slate-100 text-slate-500">
|
|
{service.category}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="p-4 text-right">
|
|
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button onClick={() => { setEditingService(service); setIsServiceModalOpen(true); }} className="p-2 text-slate-400 hover:text-primary-500 hover:bg-primary-50 rounded-lg transition-colors">
|
|
<Pencil size={18} />
|
|
</button>
|
|
<button onClick={() => handleDeleteService(service.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors">
|
|
<Trash2 size={18} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
|
|
{type === 'clients' && filteredClients.map(client => (
|
|
<tr key={client.id} className="hover:bg-slate-50 transition-colors group">
|
|
<td className="p-4">
|
|
<div className="font-bold text-slate-800">{client.name}</div>
|
|
<div className="text-xs text-slate-400">{client.company}</div>
|
|
</td>
|
|
<td className="p-4 text-sm text-slate-600">
|
|
<div>{client.email}</div>
|
|
<div className="text-xs text-slate-400">{client.phone}</div>
|
|
</td>
|
|
<td className="p-4">
|
|
<span className="px-2 py-1 rounded text-[10px] font-bold uppercase bg-green-50 text-green-600">
|
|
Ativo
|
|
</span>
|
|
</td>
|
|
<td className="p-4 text-right">
|
|
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button onClick={() => { setEditingClient(client); setIsClientModalOpen(true); }} className="p-2 text-slate-400 hover:text-primary-500 hover:bg-primary-50 rounded-lg transition-colors">
|
|
<Pencil size={18} />
|
|
</button>
|
|
<button onClick={() => handleDeleteClient(client.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors">
|
|
<Trash2 size={18} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
{/* Empty State */}
|
|
{((type === 'services' && filteredServices.length === 0) || (type === 'clients' && filteredClients.length === 0)) && (
|
|
<div className="p-10 text-center text-slate-400 flex flex-col items-center">
|
|
<Sparkles size={32} className="mb-2 opacity-20"/>
|
|
<p>Nenhum registro encontrado.</p>
|
|
<button
|
|
onClick={() => {
|
|
if (type === 'clients') { setEditingClient(null); setIsClientModalOpen(true); }
|
|
else { setEditingService(null); setIsServiceModalOpen(true); }
|
|
}}
|
|
className="mt-4 text-sm font-bold text-primary-500 hover:underline"
|
|
>
|
|
Criar primeiro registro
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|