Files
ComFi/components/AccountsReceivableView.tsx
MMrp89 1a57ac7754 feat: Initialize ComFi project with Vite
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.
2026-02-09 20:28:37 -03:00

394 lines
18 KiB
TypeScript

import React, { useState } from 'react';
import { Search, Plus, DollarSign, CheckCircle2, TrendingUp, Trash2, X, Calendar, Pencil, RefreshCw, Sparkles, ChevronDown } from 'lucide-react';
import { Receivable } from '../types';
import { useToast } from '../contexts/ToastContext';
import { useComFi } from '../contexts/ComFiContext';
import { CustomSelect } from './CustomSelect';
interface AccountsReceivableViewProps {
receivables: Receivable[];
setReceivables: React.Dispatch<React.SetStateAction<Receivable[]>>;
}
export const AccountsReceivableView: React.FC<AccountsReceivableViewProps> = ({ receivables, setReceivables }) => {
const { addToast } = useToast();
const { companies } = useComFi(); // Acesso ao CRM para gerar recorrência
const [isModalOpen, setIsModalOpen] = useState(false);
const [filterStatus, setFilterStatus] = useState<'all' | 'paid' | 'pending'>('all');
const [newReceivable, setNewReceivable] = useState<Partial<Receivable>>({
type: 'one-time',
status: 'pending',
dueDate: new Date().toISOString().split('T')[0]
});
const [editingId, setEditingId] = useState<string | null>(null);
// KPI Calculations
const totalReceivable = receivables.reduce((acc, curr) => acc + curr.value, 0);
const totalReceived = receivables.filter(r => r.status === 'paid').reduce((acc, curr) => acc + curr.value, 0);
const totalPending = receivables.filter(r => r.status === 'pending' || r.status === 'overdue').reduce((acc, curr) => acc + curr.value, 0);
const filteredList = receivables.filter(r => filterStatus === 'all' ? true : r.status === (filterStatus === 'paid' ? 'paid' : 'pending'));
// --- ACTIONS ---
const handleGenerateRecurring = () => {
const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
const today = new Date().toISOString().split('T')[0];
let generatedCount = 0;
const newReceivables: Receivable[] = [];
companies.forEach(company => {
if (company.status !== 'active') return;
company.activeServices.forEach(service => {
if (service.billingType === 'recurring') {
// Check duplicates for this month
const exists = receivables.find(r =>
r.companyName === (company.fantasyName || company.name) &&
r.description === service.name &&
r.dueDate.startsWith(currentMonth)
);
if (!exists) {
newReceivables.push({
id: Math.random().toString(36).substr(2, 9),
description: service.name,
companyName: company.fantasyName || company.name,
category: service.category,
value: service.price,
dueDate: today, // Simplificação: gera para hoje ou data padrão de vencimento
status: 'pending',
type: 'recurring'
});
generatedCount++;
}
}
});
});
if (generatedCount > 0) {
setReceivables(prev => [...prev, ...newReceivables]);
addToast({ type: 'success', title: 'Processamento Concluído', message: `${generatedCount} faturas recorrentes foram geradas.` });
} else {
addToast({ type: 'info', title: 'Tudo em dia', message: 'Todas as cobranças recorrentes deste mês já foram geradas.' });
}
};
const handleSave = () => {
if (!newReceivable.description || !newReceivable.value) {
addToast({ type: 'warning', title: 'Dados Incompletos', message: 'Preencha a descrição e o valor.' });
return;
}
if (editingId) {
setReceivables(receivables.map(r => r.id === editingId ? {
...newReceivable,
id: editingId,
value: Number(newReceivable.value),
category: newReceivable.category || 'Outros',
companyName: newReceivable.companyName || 'Avulso'
} as Receivable : r));
addToast({ type: 'success', title: 'Atualizado', message: 'Recebimento atualizado com sucesso.' });
} else {
const item: Receivable = {
...newReceivable,
id: Math.random().toString(36).substr(2, 9),
value: Number(newReceivable.value),
category: newReceivable.category || 'Outros',
companyName: newReceivable.companyName || 'Avulso'
} as Receivable;
setReceivables([...receivables, item]);
addToast({ type: 'success', title: 'Criado', message: 'Novo recebimento registrado.' });
}
setIsModalOpen(false);
setEditingId(null);
setNewReceivable({ type: 'one-time', status: 'pending', dueDate: new Date().toISOString().split('T')[0] });
};
const handleDelete = (id: string) => {
if(window.confirm("Excluir recebimento?")) {
setReceivables(receivables.filter(r => r.id !== id));
addToast({ type: 'info', title: 'Excluído', message: 'Registro removido.' });
}
}
const toggleStatus = (id: string) => {
setReceivables(receivables.map(r => {
if(r.id === id) {
const newStatus = r.status === 'paid' ? 'pending' : 'paid';
if (newStatus === 'paid') addToast({ type: 'success', title: 'Recebido!', message: `Valor de R$ ${r.value} confirmado.` });
return { ...r, status: newStatus };
}
return r;
}));
}
const openEditModal = (item: Receivable) => {
setNewReceivable(item);
setEditingId(item.id);
setIsModalOpen(true);
};
const openCreateModal = () => {
setNewReceivable({
type: 'one-time',
status: 'pending',
dueDate: new Date().toISOString().split('T')[0]
});
setEditingId(null);
setIsModalOpen(true);
}
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-800";
return (
<div className="space-y-6 animate-fade-in">
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-slate-100 flex items-center justify-center text-slate-500">
<DollarSign size={24} />
</div>
<div>
<p className="text-slate-400 text-xs font-bold uppercase">Receita Total Prevista</p>
<h3 className="text-2xl font-bold text-slate-800">R$ {totalReceivable.toLocaleString('pt-BR')}</h3>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-green-50 flex items-center justify-center text-green-600">
<CheckCircle2 size={24} />
</div>
<div>
<p className="text-slate-400 text-xs font-bold uppercase">Recebido</p>
<h3 className="text-2xl font-bold text-green-600">R$ {totalReceived.toLocaleString('pt-BR')}</h3>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] border border-slate-50 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-amber-50 flex items-center justify-center text-amber-600">
<TrendingUp size={24} />
</div>
<div>
<p className="text-slate-400 text-xs font-bold uppercase">A Receber</p>
<h3 className="text-2xl font-bold text-amber-600">R$ {totalPending.toLocaleString('pt-BR')}</h3>
</div>
</div>
</div>
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-800">Contas a Receber</h1>
<p className="text-slate-500">Gestão de faturas, contratos e recebimentos avulsos.</p>
</div>
<div className="flex gap-3">
<button
onClick={handleGenerateRecurring}
className="flex items-center gap-2 px-5 py-3 bg-indigo-500 text-white rounded-xl shadow-lg shadow-indigo-200/50 hover:bg-indigo-600 font-bold transition-all"
title="Gera cobranças baseadas nos serviços ativos do CRM"
>
<RefreshCw size={20} /> <span className="hidden sm:inline">Gerar Mensalidades</span>
</button>
<button onClick={openCreateModal} className="flex items-center gap-2 px-5 py-3 bg-green-500 text-white rounded-xl shadow-lg shadow-green-200/50 hover:bg-green-600 font-bold transition-all">
<Plus size={20} /> <span className="hidden sm:inline">Novo Recebimento</span>
</button>
</div>
</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 flex flex-wrap gap-4 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder="Buscar cliente ou descrição..."
className="w-full pl-10 pr-4 py-2 bg-slate-50 border-none rounded-xl focus:ring-2 focus:ring-primary-500 outline-none text-slate-600"
/>
</div>
<div className="flex gap-2 bg-slate-50 p-1 rounded-xl">
<button
onClick={() => setFilterStatus('all')}
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterStatus === 'all' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
>
Todos
</button>
<button
onClick={() => setFilterStatus('paid')}
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterStatus === 'paid' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
>
Recebidos
</button>
<button
onClick={() => setFilterStatus('pending')}
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterStatus === 'pending' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
>
Pendentes
</button>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-slate-50/50">
<tr>
<th className="p-4 text-xs font-bold uppercase text-slate-500">Descrição / Cliente</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500">Categoria</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500">Vencimento</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500">Valor</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500 text-center">Tipo</th>
<th className="p-4 text-xs font-bold uppercase text-slate-500 text-center">Status</th>
<th className="p-4"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{filteredList.map(item => (
<tr key={item.id} className="hover:bg-slate-50 transition-colors group">
<td className="p-4">
<div className="font-bold text-slate-800">{item.description}</div>
<div className="text-xs text-slate-400">{item.companyName}</div>
</td>
<td className="p-4">
<span className="px-2 py-1 bg-slate-100 rounded text-xs text-slate-600">{item.category}</span>
</td>
<td className="p-4 text-sm text-slate-600">
<div className="flex items-center gap-2">
<Calendar size={14} className="text-slate-400"/>
{new Date(item.dueDate).toLocaleDateString('pt-BR')}
</div>
</td>
<td className="p-4 font-bold text-slate-800">R$ {item.value.toLocaleString('pt-BR')}</td>
<td className="p-4 text-center">
<span className={`text-[10px] font-bold uppercase px-2 py-0.5 rounded ${item.type === 'recurring' ? 'bg-indigo-50 text-indigo-600' : 'bg-slate-100 text-slate-600'}`}>
{item.type === 'recurring' ? 'Mensal' : 'Avulso'}
</span>
</td>
<td className="p-4 text-center">
<button onClick={() => toggleStatus(item.id)} className={`px-3 py-1 rounded-full text-xs font-bold border transition-all ${
item.status === 'paid' ? 'bg-green-50 text-green-600 border-green-200 hover:bg-green-100' :
item.status === 'overdue' ? 'bg-red-50 text-red-600 border-red-200 hover:bg-red-100' :
'bg-amber-50 text-amber-600 border-amber-200 hover:bg-amber-100'
}`}>
{item.status === 'paid' ? 'RECEBIDO' : item.status === 'overdue' ? 'ATRASADO' : 'PENDENTE'}
</button>
</td>
<td className="p-4 text-right">
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => openEditModal(item)} className="p-2 text-slate-400 hover:text-primary-500 hover:bg-primary-50 rounded-lg transition-colors">
<Pencil size={18} />
</button>
<button onClick={() => handleDelete(item.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>
{filteredList.length === 0 && (
<div className="p-10 text-center text-slate-400">
<Sparkles size={32} className="mx-auto mb-2 opacity-20"/>
<p>Nenhum lançamento encontrado.</p>
</div>
)}
</div>
</div>
{/* Modal */}
{isModalOpen && (
<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={() => setIsModalOpen(false)}></div>
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-up flex flex-col max-h-[90vh]">
<div className="flex justify-between items-center px-6 py-4 border-b border-slate-100 flex-shrink-0">
<h3 className="font-bold text-slate-800 text-lg">{editingId ? 'Editar Recebimento' : 'Novo Recebimento'}</h3>
<button onClick={() => setIsModalOpen(false)}><X size={20} className="text-slate-400 hover:text-slate-600"/></button>
</div>
<div className="p-6 space-y-4 overflow-y-auto">
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Descrição</label>
<input
type="text"
className={inputClass}
placeholder="Ex: Consultoria Extra"
value={newReceivable.description || ''}
onChange={e => setNewReceivable({...newReceivable, description: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Cliente / Empresa</label>
<input
type="text"
className={inputClass}
placeholder="Nome do cliente"
value={newReceivable.companyName || ''}
onChange={e => setNewReceivable({...newReceivable, companyName: e.target.value})}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Valor (R$)</label>
<input
type="number"
className={inputClass}
placeholder="0,00"
value={newReceivable.value || ''}
onChange={e => setNewReceivable({...newReceivable, value: Number(e.target.value)})}
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Vencimento</label>
<input
type="date"
className={inputClass}
value={newReceivable.dueDate}
onChange={e => setNewReceivable({...newReceivable, dueDate: e.target.value})}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Categoria</label>
<CustomSelect
value={newReceivable.category || 'Serviços'}
onChange={(val) => setNewReceivable({...newReceivable, category: val})}
options={[
{ value: 'Serviços', label: 'Serviços' },
{ value: 'Produtos', label: 'Produtos' },
{ value: 'Reembolso', label: 'Reembolso' },
{ value: 'Outros', label: 'Outros' }
]}
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Tipo</label>
<CustomSelect
value={newReceivable.type}
onChange={(val) => setNewReceivable({...newReceivable, type: val})}
options={[
{ value: 'one-time', label: 'Avulso' },
{ value: 'recurring', label: 'Recorrente' }
]}
/>
</div>
</div>
<button onClick={handleSave} className="w-full py-3 bg-green-500 text-white font-bold rounded-xl mt-4 hover:bg-green-600 shadow-lg shadow-green-200">
{editingId ? 'Salvar Alterações' : 'Salvar Recebimento'}
</button>
</div>
</div>
</div>
)}
</div>
);
};