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.
453 lines
23 KiB
TypeScript
453 lines
23 KiB
TypeScript
|
|
import React, { useState, useRef } from 'react';
|
|
import { Proposal, ProposalItem } from '../types';
|
|
import { useComFi } from '../contexts/ComFiContext';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { CustomSelect } from './CustomSelect';
|
|
import {
|
|
FileText, Plus, Trash2, Printer, Edit2, CheckCircle2,
|
|
Send, X, Search, ChevronLeft, Building2, Calendar, DollarSign, Save
|
|
} from 'lucide-react';
|
|
|
|
export const ProposalsView: React.FC = () => {
|
|
const { proposals, setProposals, companies, services, tenant } = useComFi();
|
|
const { addToast } = useToast();
|
|
const [viewMode, setViewMode] = useState<'list' | 'create' | 'edit'>('list');
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
|
|
// Form State
|
|
const [currentProposal, setCurrentProposal] = useState<Partial<Proposal>>({
|
|
items: [],
|
|
issueDate: new Date().toISOString().split('T')[0],
|
|
validUntil: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
|
status: 'draft',
|
|
notes: 'Validade da proposta: 15 dias.\nPagamento: 50% entrada, 50% na entrega.'
|
|
});
|
|
|
|
// Items Management
|
|
const [newItemServiceId, setNewItemServiceId] = useState('');
|
|
|
|
const calculateTotal = (items: ProposalItem[]) => items.reduce((acc, item) => acc + item.total, 0);
|
|
|
|
const handleAddItem = () => {
|
|
if (!newItemServiceId) return;
|
|
const service = services.find(s => s.id === newItemServiceId);
|
|
if (!service) return;
|
|
|
|
const newItem: ProposalItem = {
|
|
id: Math.random().toString(36).substr(2, 9),
|
|
serviceId: service.id,
|
|
description: service.name,
|
|
quantity: 1,
|
|
unitPrice: service.price,
|
|
total: service.price
|
|
};
|
|
|
|
const updatedItems = [...(currentProposal.items || []), newItem];
|
|
setCurrentProposal({
|
|
...currentProposal,
|
|
items: updatedItems,
|
|
totalValue: calculateTotal(updatedItems)
|
|
});
|
|
setNewItemServiceId('');
|
|
};
|
|
|
|
const handleRemoveItem = (itemId: string) => {
|
|
const updatedItems = (currentProposal.items || []).filter(i => i.id !== itemId);
|
|
setCurrentProposal({
|
|
...currentProposal,
|
|
items: updatedItems,
|
|
totalValue: calculateTotal(updatedItems)
|
|
});
|
|
};
|
|
|
|
const handleUpdateItem = (itemId: string, field: 'quantity' | 'unitPrice', value: number) => {
|
|
const updatedItems = (currentProposal.items || []).map(item => {
|
|
if (item.id === itemId) {
|
|
const updatedItem = { ...item, [field]: value };
|
|
updatedItem.total = updatedItem.quantity * updatedItem.unitPrice;
|
|
return updatedItem;
|
|
}
|
|
return item;
|
|
});
|
|
setCurrentProposal({
|
|
...currentProposal,
|
|
items: updatedItems,
|
|
totalValue: calculateTotal(updatedItems)
|
|
});
|
|
};
|
|
|
|
const handleSave = () => {
|
|
if (!currentProposal.clientId || !currentProposal.items?.length) {
|
|
addToast({ type: 'warning', title: 'Dados Incompletos', message: 'Selecione um cliente e adicione itens.' });
|
|
return;
|
|
}
|
|
|
|
const client = companies.find(c => c.id === currentProposal.clientId);
|
|
const proposalToSave: Proposal = {
|
|
...currentProposal,
|
|
id: currentProposal.id || Math.random().toString(36).substr(2, 9),
|
|
number: currentProposal.number || `PROP-${new Date().getFullYear()}-${Math.floor(Math.random() * 1000)}`,
|
|
clientName: client?.fantasyName || client?.name || 'Cliente',
|
|
clientEmail: client?.email,
|
|
totalValue: calculateTotal(currentProposal.items || [])
|
|
} as Proposal;
|
|
|
|
if (viewMode === 'edit') {
|
|
setProposals(proposals.map(p => p.id === proposalToSave.id ? proposalToSave : p));
|
|
addToast({ type: 'success', title: 'Proposta Atualizada' });
|
|
} else {
|
|
setProposals([...proposals, proposalToSave]);
|
|
addToast({ type: 'success', title: 'Proposta Criada' });
|
|
}
|
|
setViewMode('list');
|
|
};
|
|
|
|
const handleDelete = (id: string) => {
|
|
if (window.confirm('Excluir esta proposta?')) {
|
|
setProposals(proposals.filter(p => p.id !== id));
|
|
addToast({ type: 'info', title: 'Proposta Removida' });
|
|
}
|
|
};
|
|
|
|
const openCreate = () => {
|
|
setCurrentProposal({
|
|
items: [],
|
|
issueDate: new Date().toISOString().split('T')[0],
|
|
validUntil: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
|
status: 'draft',
|
|
notes: 'Validade da proposta: 15 dias.\nPagamento: 50% entrada, 50% na entrega.'
|
|
});
|
|
setViewMode('create');
|
|
};
|
|
|
|
const openEdit = (proposal: Proposal) => {
|
|
setCurrentProposal(proposal);
|
|
setViewMode('edit');
|
|
};
|
|
|
|
const handlePrint = () => {
|
|
window.print();
|
|
};
|
|
|
|
// --- RENDER ---
|
|
|
|
if (viewMode === 'create' || viewMode === 'edit') {
|
|
const client = companies.find(c => c.id === currentProposal.clientId);
|
|
|
|
return (
|
|
<div className="space-y-6 animate-fade-in relative">
|
|
{/* Print Styles */}
|
|
<style>{`
|
|
@media print {
|
|
body * { visibility: hidden; }
|
|
#proposal-document, #proposal-document * { visibility: visible; }
|
|
#proposal-document { position: absolute; left: 0; top: 0; width: 100%; margin: 0; padding: 0; background: white; box-shadow: none; border: none; }
|
|
.no-print { display: none !important; }
|
|
}
|
|
`}</style>
|
|
|
|
{/* Header / Actions */}
|
|
<div className="flex justify-between items-center no-print">
|
|
<button onClick={() => setViewMode('list')} className="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-colors text-slate-600 font-medium">
|
|
<ChevronLeft size={18} /> Voltar
|
|
</button>
|
|
<div className="flex gap-2">
|
|
<button onClick={handlePrint} className="flex items-center gap-2 px-4 py-2 bg-slate-800 text-white rounded-xl hover:bg-slate-900 font-bold transition-all shadow-lg">
|
|
<Printer size={18} /> Imprimir / PDF
|
|
</button>
|
|
<button onClick={handleSave} className="flex items-center gap-2 px-6 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 font-bold transition-all shadow-lg shadow-primary-200">
|
|
<Save size={18} /> Salvar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
|
|
{/* Editor Panel (Left) */}
|
|
<div className="lg:col-span-1 space-y-6 no-print">
|
|
<div className="bg-white p-6 rounded-[2rem] shadow-sm border border-slate-100">
|
|
<h3 className="font-bold text-slate-800 text-lg mb-4 flex items-center gap-2"><Building2 size={18}/> Cliente & Detalhes</h3>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Cliente</label>
|
|
<CustomSelect
|
|
value={currentProposal.clientId || ''}
|
|
onChange={(val) => setCurrentProposal({...currentProposal, clientId: val})}
|
|
placeholder="Selecione o Cliente"
|
|
options={companies.map(c => ({ value: c.id, label: c.fantasyName || c.name }))}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Emissão</label>
|
|
<input type="date" className="w-full p-3 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-200" value={currentProposal.issueDate} onChange={e => setCurrentProposal({...currentProposal, issueDate: e.target.value})} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Validade</label>
|
|
<input type="date" className="w-full p-3 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-200" value={currentProposal.validUntil} onChange={e => setCurrentProposal({...currentProposal, validUntil: e.target.value})} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Status</label>
|
|
<CustomSelect
|
|
value={currentProposal.status || 'draft'}
|
|
onChange={(val) => setCurrentProposal({...currentProposal, status: val})}
|
|
options={[
|
|
{ value: 'draft', label: 'Rascunho' },
|
|
{ value: 'sent', label: 'Enviado' },
|
|
{ value: 'accepted', label: 'Aceito' },
|
|
{ value: 'rejected', label: 'Rejeitado' }
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white p-6 rounded-[2rem] shadow-sm border border-slate-100">
|
|
<h3 className="font-bold text-slate-800 text-lg mb-4 flex items-center gap-2"><Plus size={18}/> Adicionar Item</h3>
|
|
<div className="flex gap-2 mb-2">
|
|
<div className="flex-1">
|
|
<CustomSelect
|
|
value={newItemServiceId}
|
|
onChange={setNewItemServiceId}
|
|
placeholder="Selecione um serviço..."
|
|
options={services.map(s => ({ value: s.id, label: `${s.name} (R$ ${s.price})` }))}
|
|
/>
|
|
</div>
|
|
<button onClick={handleAddItem} className="bg-slate-800 text-white p-3 rounded-xl hover:bg-slate-900 transition-colors"><Plus size={20}/></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white p-6 rounded-[2rem] shadow-sm border border-slate-100">
|
|
<h3 className="font-bold text-slate-800 text-lg mb-4">Notas & Observações</h3>
|
|
<textarea
|
|
className="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-200 h-32"
|
|
value={currentProposal.notes}
|
|
onChange={e => setCurrentProposal({...currentProposal, notes: e.target.value})}
|
|
placeholder="Termos de pagamento, prazos, etc."
|
|
></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Preview Panel (Right) - The "Paper" */}
|
|
<div className="lg:col-span-2">
|
|
<div id="proposal-document" className="bg-white w-full max-w-[210mm] mx-auto min-h-[297mm] p-[15mm] shadow-2xl rounded-sm text-slate-800 relative">
|
|
|
|
{/* Header Documento */}
|
|
<div className="flex justify-between items-start border-b-2 border-primary-500 pb-8 mb-8">
|
|
<div>
|
|
{tenant.logo ? (
|
|
<img src={tenant.logo} className="h-16 object-contain mb-2" alt="Logo" />
|
|
) : (
|
|
<h1 className="text-3xl font-bold text-slate-900">{tenant.name}</h1>
|
|
)}
|
|
<p className="text-sm text-slate-500 max-w-xs">{tenant.address}</p>
|
|
<p className="text-sm text-slate-500">{tenant.email} | {tenant.phone}</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<h2 className="text-4xl font-bold text-slate-200 uppercase tracking-widest">Proposta</h2>
|
|
<p className="text-lg font-bold text-primary-600 mt-2">{currentProposal.number || 'RASCUNHO'}</p>
|
|
<p className="text-sm text-slate-500 mt-1">Data: {new Date(currentProposal.issueDate || '').toLocaleDateString('pt-BR')}</p>
|
|
<p className="text-sm text-slate-500">Válido até: {new Date(currentProposal.validUntil || '').toLocaleDateString('pt-BR')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info Cliente */}
|
|
<div className="mb-10">
|
|
<h3 className="text-xs font-bold text-slate-400 uppercase mb-2">Preparado para:</h3>
|
|
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100">
|
|
{client ? (
|
|
<>
|
|
<h4 className="text-xl font-bold text-slate-800">{client.fantasyName || client.name}</h4>
|
|
<p className="text-sm text-slate-600">{client.address} - {client.city}</p>
|
|
<p className="text-sm text-slate-600">CNPJ: {client.cnpj}</p>
|
|
<p className="text-sm text-slate-600">{client.email}</p>
|
|
</>
|
|
) : (
|
|
<p className="text-slate-400 italic">Selecione um cliente...</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Items Table */}
|
|
<div className="mb-8">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr className="border-b-2 border-slate-100">
|
|
<th className="py-3 text-xs font-bold text-slate-500 uppercase w-1/2">Descrição / Serviço</th>
|
|
<th className="py-3 text-xs font-bold text-slate-500 uppercase text-center">Qtd</th>
|
|
<th className="py-3 text-xs font-bold text-slate-500 uppercase text-right">Valor Unit.</th>
|
|
<th className="py-3 text-xs font-bold text-slate-500 uppercase text-right">Total</th>
|
|
<th className="py-3 w-8 no-print"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(currentProposal.items || []).map((item) => (
|
|
<tr key={item.id} className="border-b border-slate-50 group">
|
|
<td className="py-4">
|
|
<span className="font-bold text-slate-700 block">{item.description}</span>
|
|
</td>
|
|
<td className="py-4 text-center">
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
className="w-16 text-center bg-transparent border border-transparent hover:border-slate-200 rounded p-1 outline-none focus:border-primary-300 transition-colors"
|
|
value={item.quantity}
|
|
onChange={(e) => handleUpdateItem(item.id, 'quantity', Number(e.target.value))}
|
|
/>
|
|
</td>
|
|
<td className="py-4 text-right">
|
|
<div className="flex justify-end items-center gap-1">
|
|
<span className="text-xs text-slate-400">R$</span>
|
|
<input
|
|
type="number"
|
|
className="w-24 text-right bg-transparent border border-transparent hover:border-slate-200 rounded p-1 outline-none focus:border-primary-300 transition-colors"
|
|
value={item.unitPrice}
|
|
onChange={(e) => handleUpdateItem(item.id, 'unitPrice', Number(e.target.value))}
|
|
/>
|
|
</div>
|
|
</td>
|
|
<td className="py-4 text-right font-bold text-slate-800">
|
|
R$ {item.total.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
|
|
</td>
|
|
<td className="py-4 text-right no-print">
|
|
<button onClick={() => handleRemoveItem(item.id)} className="text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"><Trash2 size={16}/></button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{(currentProposal.items || []).length === 0 && (
|
|
<tr><td colSpan={5} className="py-8 text-center text-slate-300 italic">Nenhum item adicionado</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Totals */}
|
|
<div className="flex justify-end mb-12">
|
|
<div className="w-1/2">
|
|
<div className="flex justify-between items-center py-2 border-b border-slate-100">
|
|
<span className="text-slate-500 font-medium">Subtotal</span>
|
|
<span className="text-slate-800 font-bold">R$ {calculateTotal(currentProposal.items || []).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center py-4">
|
|
<span className="text-lg text-slate-800 font-bold">Total Geral</span>
|
|
<span className="text-2xl text-primary-600 font-bold">R$ {calculateTotal(currentProposal.items || []).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer Notes */}
|
|
<div className="border-t-2 border-slate-100 pt-6">
|
|
<h4 className="text-sm font-bold text-slate-800 mb-2">Termos e Condições</h4>
|
|
<p className="text-sm text-slate-500 whitespace-pre-line">{currentProposal.notes}</p>
|
|
</div>
|
|
|
|
{/* Signature Area */}
|
|
<div className="absolute bottom-[15mm] left-[15mm] right-[15mm] flex justify-between mt-20">
|
|
<div className="w-1/3 border-t border-slate-300 pt-2 text-center">
|
|
<p className="text-xs font-bold text-slate-600">{tenant.name}</p>
|
|
<p className="text-[10px] text-slate-400">Assinatura do Emissor</p>
|
|
</div>
|
|
<div className="w-1/3 border-t border-slate-300 pt-2 text-center">
|
|
<p className="text-xs font-bold text-slate-600">{client?.name || 'Cliente'}</p>
|
|
<p className="text-[10px] text-slate-400">De acordo</p>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// LIST VIEW
|
|
return (
|
|
<div className="space-y-6 animate-fade-in">
|
|
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-800">Propostas Comerciais</h1>
|
|
<p className="text-slate-500">Crie orçamentos e gerencie negociações.</p>
|
|
</div>
|
|
<button onClick={openCreate} 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} /> Nova Proposta
|
|
</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 gap-4">
|
|
<div className="relative max-w-md flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar proposta..."
|
|
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 */}
|
|
<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">Número</th>
|
|
<th className="p-4 text-xs font-bold uppercase text-slate-500">Cliente</th>
|
|
<th className="p-4 text-xs font-bold uppercase text-slate-500">Emissã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 className="divide-y divide-slate-50">
|
|
{proposals.filter(p => p.clientName.toLowerCase().includes(searchTerm.toLowerCase()) || p.number.toLowerCase().includes(searchTerm.toLowerCase())).map(prop => (
|
|
<tr key={prop.id} className="hover:bg-slate-50 transition-colors group">
|
|
<td className="p-4 font-bold text-slate-800">{prop.number}</td>
|
|
<td className="p-4 text-sm text-slate-700">{prop.clientName}</td>
|
|
<td className="p-4 text-sm text-slate-500">
|
|
<div className="flex items-center gap-2">
|
|
<Calendar size={14} className="text-slate-400"/>
|
|
{new Date(prop.issueDate).toLocaleDateString('pt-BR')}
|
|
</div>
|
|
</td>
|
|
<td className="p-4 font-bold text-slate-700">R$ {prop.totalValue.toLocaleString('pt-BR')}</td>
|
|
<td className="p-4 text-center">
|
|
<span className={`px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wide ${
|
|
prop.status === 'accepted' ? 'bg-green-50 text-green-600' :
|
|
prop.status === 'sent' ? 'bg-blue-50 text-blue-600' :
|
|
prop.status === 'rejected' ? 'bg-red-50 text-red-600' :
|
|
'bg-slate-100 text-slate-500'
|
|
}`}>
|
|
{prop.status === 'accepted' ? 'Aceito' : prop.status === 'sent' ? 'Enviado' : prop.status === 'rejected' ? 'Rejeitado' : 'Rascunho'}
|
|
</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={() => openEdit(prop)} className="p-2 text-slate-400 hover:text-primary-500 hover:bg-primary-50 rounded-lg transition-colors" title="Editar / Visualizar">
|
|
<Edit2 size={18} />
|
|
</button>
|
|
<button onClick={() => handleDelete(prop.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors" title="Excluir">
|
|
<Trash2 size={18} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{proposals.length === 0 && (
|
|
<div className="p-10 text-center text-slate-400">
|
|
<FileText size={32} className="mx-auto mb-2 opacity-20"/>
|
|
<p>Nenhuma proposta registrada.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|