Introduce manual chunking for vendor dependencies to improve load times. Update Node.js engine requirement to >=18.0.0. Refactor `AccountsReceivableView` by removing commented-out code and unnecessary variables. Add a development server port configuration.
191 lines
9.7 KiB
TypeScript
191 lines
9.7 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();
|
|
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);
|
|
|
|
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'));
|
|
|
|
const handleGenerateRecurring = () => {
|
|
const currentMonth = new Date().toISOString().slice(0, 7);
|
|
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') {
|
|
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,
|
|
status: 'pending',
|
|
type: 'recurring'
|
|
});
|
|
generatedCount++;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
if (generatedCount > 0) {
|
|
setReceivables(prev => [...prev, ...newReceivables]);
|
|
addToast({ type: 'success', title: 'Processamento Concluído', message: `${generatedCount} faturas recorrentes geradas.` });
|
|
} else {
|
|
addToast({ type: 'info', title: 'Tudo em dia', message: 'Cobranças mensais já processadas.' });
|
|
}
|
|
};
|
|
|
|
const handleSave = () => {
|
|
if (!newReceivable.description || !newReceivable.value) {
|
|
addToast({ type: 'warning', title: 'Dados Incompletos' });
|
|
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));
|
|
} 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]);
|
|
}
|
|
|
|
setIsModalOpen(false);
|
|
setEditingId(null);
|
|
};
|
|
|
|
const toggleStatus = (id: string) => {
|
|
setReceivables(receivables.map(r => r.id === id ? { ...r, status: r.status === 'paid' ? 'pending' : 'paid' } : r));
|
|
};
|
|
|
|
const inputClass = "w-full p-3 bg-white border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-primary-500 text-slate-800";
|
|
|
|
return (
|
|
<div className="space-y-6 animate-fade-in">
|
|
<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">Previsto</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 justify-between items-center">
|
|
<h1 className="text-2xl font-bold text-slate-800">Contas a Receber</h1>
|
|
<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 font-bold"><RefreshCw size={18} /> Gerar Mensalidades</button>
|
|
<button onClick={() => { setEditingId(null); setIsModalOpen(true); }} className="flex items-center gap-2 px-5 py-3 bg-green-500 text-white rounded-xl font-bold"><Plus size={18} /> Novo</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
|
<table className="w-full text-left">
|
|
<thead className="bg-slate-50">
|
|
<tr>
|
|
<th className="p-4 text-xs font-bold uppercase text-slate-500">Descrição</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">Status</th>
|
|
<th className="p-4"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredList.map(item => (
|
|
<tr key={item.id} className="border-b border-slate-50">
|
|
<td className="p-4 font-bold text-slate-800">{item.description}<div className="text-xs text-slate-400">{item.companyName}</div></td>
|
|
<td className="p-4 font-bold">R$ {item.value.toLocaleString('pt-BR')}</td>
|
|
<td className="p-4 text-center">
|
|
<button onClick={() => toggleStatus(item.id)} className={`px-3 py-1 rounded-full text-xs font-bold border ${item.status === 'paid' ? 'bg-green-50 text-green-600 border-green-100' : 'bg-amber-50 text-amber-600 border-amber-100'}`}>
|
|
{item.status === 'paid' ? 'PAGO' : 'PENDENTE'}
|
|
</button>
|
|
</td>
|
|
<td className="p-4 text-right">
|
|
<button onClick={() => { setNewReceivable(item); setEditingId(item.id); setIsModalOpen(true); }} className="p-2 text-slate-400 hover:text-primary-500"><Pencil size={18} /></button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{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 p-6 w-full max-w-md animate-scale-up">
|
|
<h3 className="font-bold text-slate-800 text-lg mb-4">Lançamento de Receita</h3>
|
|
<div className="space-y-4">
|
|
<input type="text" className={inputClass} placeholder="Descrição" value={newReceivable.description ?? ''} onChange={e => setNewReceivable({...newReceivable, description: e.target.value})} />
|
|
<input type="number" className={inputClass} placeholder="Valor" value={newReceivable.value ?? ''} onChange={e => setNewReceivable({...newReceivable, value: Number(e.target.value)})} />
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<CustomSelect
|
|
value={newReceivable.category ?? 'Serviços'}
|
|
onChange={val => setNewReceivable({...newReceivable, category: val})}
|
|
options={[{value:'Serviços', label:'Serviços'}, {value:'Produtos', label:'Produtos'}, {value:'Outros', label:'Outros'}]}
|
|
/>
|
|
<CustomSelect
|
|
value={newReceivable.type ?? 'one-time'}
|
|
onChange={val => setNewReceivable({...newReceivable, type: val})}
|
|
options={[{value:'one-time', label:'Avulso'}, {value:'recurring', label:'Mensal'}]}
|
|
/>
|
|
</div>
|
|
<button onClick={handleSave} className="w-full py-3 bg-primary-500 text-white font-bold rounded-xl shadow-lg">Salvar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|