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.
This commit is contained in:
MMrp89
2026-02-09 20:28:37 -03:00
parent 1e6a56d866
commit 1a57ac7754
28 changed files with 6070 additions and 8 deletions

View File

@@ -0,0 +1,452 @@
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>
);
};