Files
ComFi/components/AccountsPayableView.tsx
MMrp89 61d4062be1 feat: Use nullish coalescing for default select values
Replaces `||` with `??` for more precise default value assignment in select components.

Also updates Vite config to set `base` to `/` and disable sourcemaps during build.
2026-02-10 01:26:02 -03:00

319 lines
15 KiB
TypeScript

import React, { useState } from 'react';
import { Search, Plus, DollarSign, Calendar, CheckCircle2, AlertCircle, Trash2, X, Pencil, Sparkles, ChevronDown } from 'lucide-react';
import { Expense } from '../types';
import { useToast } from '../contexts/ToastContext';
import { CustomSelect } from './CustomSelect';
interface AccountsPayableViewProps {
expenses: Expense[];
setExpenses: (expenses: Expense[]) => void;
}
export const AccountsPayableView: React.FC<AccountsPayableViewProps> = ({ expenses, setExpenses }) => {
const { addToast } = useToast();
const [isModalOpen, setIsModalOpen] = useState(false);
const [filterType, setFilterType] = useState<'all' | 'fixed' | 'variable'>('all');
const [newExpense, setNewExpense] = useState<Partial<Expense>>({
type: 'fixed',
status: 'pending',
dueDate: new Date().toISOString().split('T')[0]
});
const [editingId, setEditingId] = useState<string | null>(null);
// Totals Calculation
const totalPayable = expenses.reduce((acc, curr) => acc + curr.amount, 0);
const totalPaid = expenses.filter(e => e.status === 'paid').reduce((acc, curr) => acc + curr.amount, 0);
const totalPending = expenses.filter(e => e.status === 'pending' || e.status === 'overdue').reduce((acc, curr) => acc + curr.amount, 0);
const filteredExpenses = expenses.filter(e => filterType === 'all' ? true : e.type === filterType);
const openEditModal = (expense: Expense) => {
setNewExpense(expense);
setEditingId(expense.id);
setIsModalOpen(true);
};
const openCreateModal = () => {
setNewExpense({
type: 'fixed',
status: 'pending',
dueDate: new Date().toISOString().split('T')[0]
});
setEditingId(null);
setIsModalOpen(true);
}
const handleSaveExpense = () => {
if (!newExpense.title || !newExpense.amount) {
addToast({ type: 'warning', title: 'Campos Obrigatórios', message: 'Preencha o título e valor da despesa.' });
return;
}
if (editingId) {
// Edit existing
setExpenses(expenses.map(e => e.id === editingId ? { ...newExpense, id: editingId, amount: Number(newExpense.amount) } as Expense : e));
addToast({ type: 'success', title: 'Atualizado', message: 'Despesa atualizada com sucesso.' });
} else {
// Create new
const expense: Expense = {
...newExpense,
id: Math.random().toString(36).substr(2, 9),
amount: Number(newExpense.amount)
} as Expense;
setExpenses([...expenses, expense]);
addToast({ type: 'success', title: 'Registrado', message: 'Nova despesa adicionada.' });
}
setIsModalOpen(false);
setEditingId(null);
setNewExpense({ type: 'fixed', status: 'pending', dueDate: new Date().toISOString().split('T')[0] });
};
const handleDelete = (id: string) => {
if(window.confirm("Excluir conta?")) {
setExpenses(expenses.filter(e => e.id !== id));
addToast({ type: 'info', title: 'Excluído', message: 'Despesa removida.' });
}
}
const toggleStatus = (id: string) => {
setExpenses(expenses.map(e => {
if(e.id === id) {
const newStatus = e.status === 'paid' ? 'pending' : 'paid';
if (newStatus === 'paid') addToast({ type: 'success', title: 'Pago!', message: 'Despesa marcada como paga.' });
return { ...e, status: newStatus };
}
return e;
}));
}
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">Total Previsto</p>
<h3 className="text-2xl font-bold text-slate-800">R$ {totalPayable.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">Total Pago</p>
<h3 className="text-2xl font-bold text-green-600">R$ {totalPaid.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-red-50 flex items-center justify-center text-red-600">
<AlertCircle size={24} />
</div>
<div>
<p className="text-slate-400 text-xs font-bold uppercase">A Pagar / Pendente</p>
<h3 className="text-2xl font-bold text-red-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 Pagar</h1>
<p className="text-slate-500">Gerencie despesas fixas e variáveis.</p>
</div>
<button onClick={openCreateModal} className="flex items-center gap-2 px-5 py-3 bg-red-500 text-white rounded-xl shadow-lg shadow-red-200/50 hover:bg-red-600 font-bold transition-all">
<Plus size={20} /> Nova Despesa
</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 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 despesa..."
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={() => setFilterType('all')}
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterType === 'all' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
>
Todas
</button>
<button
onClick={() => setFilterType('fixed')}
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterType === 'fixed' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
>
Fixas
</button>
<button
onClick={() => setFilterType('variable')}
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${filterType === 'variable' ? 'bg-white shadow text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
>
Variáveis
</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</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">
{filteredExpenses.map(expense => (
<tr key={expense.id} className="hover:bg-slate-50 transition-colors group">
<td className="p-4 font-bold text-slate-800">{expense.title}</td>
<td className="p-4">
<span className="px-2 py-1 bg-slate-100 rounded text-xs text-slate-600">{expense.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(expense.dueDate).toLocaleDateString('pt-BR')}
</div>
</td>
<td className="p-4 font-bold text-slate-800">R$ {expense.amount.toLocaleString('pt-BR')}</td>
<td className="p-4 text-center">
<span className={`text-[10px] font-bold uppercase px-2 py-0.5 rounded ${expense.type === 'fixed' ? 'bg-blue-50 text-blue-600' : 'bg-amber-50 text-amber-600'}`}>
{expense.type === 'fixed' ? 'Fixa' : 'Variável'}
</span>
</td>
<td className="p-4 text-center">
<button onClick={() => toggleStatus(expense.id)} className={`px-3 py-1 rounded-full text-xs font-bold border transition-all ${
expense.status === 'paid' ? 'bg-green-50 text-green-600 border-green-200 hover:bg-green-100' :
expense.status === 'overdue' ? 'bg-red-50 text-red-600 border-red-200 hover:bg-red-100' :
'bg-slate-50 text-slate-500 border-slate-200 hover:bg-slate-100'
}`}>
{expense.status === 'paid' ? 'PAGO' : expense.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(expense)} 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(expense.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>
{filteredExpenses.length === 0 && (
<div className="p-10 text-center text-slate-400">
<Sparkles size={32} className="mx-auto mb-2 opacity-20"/>
<p>Nenhuma despesa encontrada.</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 Despesa' : 'Nova Despesa'}</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">Título</label>
<input
type="text"
className={inputClass}
placeholder="Ex: Aluguel Escritório"
value={newExpense.title || ''}
onChange={e => setNewExpense({...newExpense, title: 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={newExpense.amount || ''}
onChange={e => setNewExpense({...newExpense, amount: 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={newExpense.dueDate}
onChange={e => setNewExpense({...newExpense, 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={newExpense.category ?? 'Operacional'}
onChange={(val) => setNewExpense({...newExpense, category: val})}
options={[
{ value: 'Operacional', label: 'Operacional' },
{ value: 'Administrativo', label: 'Administrativo' },
{ value: 'Impostos', label: 'Impostos' },
{ value: 'Marketing', label: 'Marketing' },
{ value: 'Pessoal', label: 'Pessoal / Folha' },
]}
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-800 mb-1">Tipo</label>
<CustomSelect
value={newExpense.type ?? 'fixed'}
onChange={(val) => setNewExpense({...newExpense, type: val})}
options={[
{ value: 'fixed', label: 'Fixa' },
{ value: 'variable', label: 'Variável' },
]}
/>
</div>
</div>
<button onClick={handleSaveExpense} className="w-full py-3 bg-red-500 text-white font-bold rounded-xl mt-4 hover:bg-red-600 shadow-lg shadow-red-200">
{editingId ? 'Salvar Alterações' : 'Registrar Despesa'}
</button>
</div>
</div>
</div>
)}
</div>
);
};