395 lines
19 KiB
TypeScript
395 lines
19 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import {
|
|
Wallet, Search, Plus, TrendingUp, TrendingDown,
|
|
ChevronLeft, ChevronRight, Loader2, Calendar, Edit2, Trash2
|
|
} from 'lucide-react'
|
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
|
import api from '../services/api'
|
|
import { Orcamento } from '../types'
|
|
import { useLookups } from '../hooks/useLookups'
|
|
import Modal from '../components/Modal'
|
|
|
|
const statusConfig: Record<string, { label: string; class: string }> = {
|
|
'dentro_limite': { label: 'Dentro do Limite', class: 'badge-success' },
|
|
'alerta': { label: 'Alerta', class: 'badge-warning' },
|
|
'excedido': { label: 'Excedido', class: 'badge-error' },
|
|
'disponivel': { label: 'Disponível', class: 'badge-info' },
|
|
}
|
|
|
|
function computeStatus(orc: Orcamento): string {
|
|
const planejado = orc.valor_planejado || orc.valor_previsto || 0
|
|
const realizado = orc.valor_realizado || 0
|
|
if (planejado === 0) return 'disponivel'
|
|
const pct = realizado / planejado
|
|
if (pct > 1) return 'excedido'
|
|
if (pct > 0.85) return 'alerta'
|
|
if (realizado === 0) return 'disponivel'
|
|
return 'dentro_limite'
|
|
}
|
|
|
|
function getValorPlanejado(orc: Orcamento): number {
|
|
return orc.valor_planejado ?? orc.valor_previsto ?? 0
|
|
}
|
|
|
|
const emptyForm = { categoria_id: '', centro_custo_id: '', ano: 2026, mes: 1, valor_planejado: 0, tipo_periodo: 'mensal' }
|
|
|
|
export default function Orcamentos() {
|
|
const [loading, setLoading] = useState(true)
|
|
const [orcamentos, setOrcamentos] = useState<any[]>([])
|
|
const [searchTerm, setSearchTerm] = useState('')
|
|
const [selectedYear, setSelectedYear] = useState(2026)
|
|
const [selectedMonth, setSelectedMonth] = useState(0)
|
|
const [showModal, setShowModal] = useState(false)
|
|
const [editId, setEditId] = useState<string | null>(null)
|
|
const [form, setForm] = useState(emptyForm)
|
|
const [saving, setSaving] = useState(false)
|
|
const [page, setPage] = useState(1)
|
|
const perPage = 20
|
|
const { categoriaMap, centrosCustoMap, categorias, centrosCusto, loading: lookupsLoading } = useLookups()
|
|
|
|
// Capex/Opex chart data
|
|
const [investData, setInvestData] = useState<any[]>([])
|
|
|
|
useEffect(() => { fetchOrcamentos(); fetchInvestData() }, [])
|
|
|
|
const fetchOrcamentos = async () => {
|
|
try {
|
|
const { data } = await api.get('/orcamento')
|
|
setOrcamentos(data)
|
|
} catch (err) {
|
|
console.error('Error fetching orcamentos:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const fetchInvestData = async () => {
|
|
try {
|
|
const { data } = await api.get('/orcamento/resumo-investimento', { params: { ano: 2026 } })
|
|
setInvestData(data)
|
|
} catch (err) {
|
|
console.error('Error fetching invest data:', err)
|
|
}
|
|
}
|
|
|
|
const filteredOrcamentos = orcamentos.filter(orc => {
|
|
const catName = categoriaMap[orc.categoria_id] || orc.categoria || orc.categoria_id || ''
|
|
const matchesSearch = searchTerm === '' || catName.toLowerCase().includes(searchTerm.toLowerCase())
|
|
const matchesYear = orc.ano === selectedYear
|
|
const matchesMonth = selectedMonth === 0 || orc.mes === selectedMonth
|
|
return matchesSearch && matchesYear && matchesMonth
|
|
})
|
|
|
|
const totalPrevisto = filteredOrcamentos.reduce((acc, orc) => acc + getValorPlanejado(orc), 0)
|
|
const totalRealizado = filteredOrcamentos.reduce((acc, orc) => acc + (orc.valor_realizado || 0), 0)
|
|
const economia = totalPrevisto - totalRealizado
|
|
|
|
const months = [
|
|
'Todos', 'Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho',
|
|
'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'
|
|
]
|
|
|
|
const formatCurrency = (value: number) =>
|
|
new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value)
|
|
|
|
const getPercentage = (realizado: number, previsto: number) => {
|
|
if (!previsto) return '0.0'
|
|
return ((realizado / previsto) * 100).toFixed(1)
|
|
}
|
|
|
|
const openNew = () => {
|
|
setEditId(null)
|
|
setForm(emptyForm)
|
|
setShowModal(true)
|
|
}
|
|
|
|
const openEdit = (orc: Orcamento) => {
|
|
setEditId(orc.id)
|
|
setForm({ categoria_id: orc.categoria_id, centro_custo_id: orc.centro_custo_id, ano: orc.ano, mes: orc.mes, valor_planejado: getValorPlanejado(orc), tipo_periodo: orc.tipo_periodo || 'mensal' })
|
|
setShowModal(true)
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
setSaving(true)
|
|
try {
|
|
const payload: any = { ...form }
|
|
payload.valor_anual = payload.tipo_periodo === 'mensal' ? payload.valor_planejado * 12 : payload.valor_planejado
|
|
if (editId) {
|
|
await api.patch(`/orcamento/${editId}`, payload)
|
|
} else {
|
|
await api.post('/orcamento', payload)
|
|
}
|
|
setShowModal(false)
|
|
fetchOrcamentos()
|
|
} catch (err) {
|
|
console.error('Error saving:', err)
|
|
alert('Erro ao salvar orçamento')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm('Tem certeza que deseja excluir este orçamento?')) return
|
|
try {
|
|
await api.delete(`/orcamento/${id}`)
|
|
fetchOrcamentos()
|
|
} catch (err) {
|
|
console.error('Error deleting:', err)
|
|
alert('Erro ao excluir')
|
|
}
|
|
}
|
|
|
|
if (loading || lookupsLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-96">
|
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 animate-fade-in">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl sm:text-3xl font-bold text-text">Orçamentos</h1>
|
|
<p className="text-gray mt-1">Gerencie e acompanhe os orçamentos de facilities</p>
|
|
</div>
|
|
<button onClick={openNew} className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
|
|
<Plus className="w-5 h-5" />
|
|
Novo Orçamento
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div className="card bg-gradient-to-br from-primary to-accent text-white">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-white/80 text-sm">Total Planejado</p>
|
|
<p className="text-2xl font-bold mt-1">{formatCurrency(totalPrevisto)}</p>
|
|
</div>
|
|
<Wallet className="w-10 h-10 opacity-80" />
|
|
</div>
|
|
</div>
|
|
<div className="card bg-gradient-to-br from-secondary to-secondary-light text-white">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-white/80 text-sm">Total Realizado</p>
|
|
<p className="text-2xl font-bold mt-1">{formatCurrency(totalRealizado)}</p>
|
|
</div>
|
|
<TrendingDown className="w-10 h-10 opacity-80" />
|
|
</div>
|
|
</div>
|
|
<div className={`card ${economia >= 0 ? 'bg-gradient-to-br from-green-500 to-emerald-500' : 'bg-gradient-to-br from-red-500 to-rose-500'} text-white`}>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-white/80 text-sm">{economia >= 0 ? 'Economia' : 'Excedente'}</p>
|
|
<p className="text-2xl font-bold mt-1">{formatCurrency(Math.abs(economia))}</p>
|
|
</div>
|
|
<TrendingUp className="w-10 h-10 opacity-80" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Capex vs Opex Charts */}
|
|
{investData.length > 0 && (
|
|
<div className="card">
|
|
<h2 className="text-lg font-semibold text-text mb-4">Orçamento por Tipo de Investimento</h2>
|
|
<div className="h-64">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={investData} barGap={8}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
|
|
<XAxis dataKey="tipo" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }}
|
|
tickFormatter={(v) => `R$ ${(v / 1000).toFixed(0)}k`} />
|
|
<Tooltip contentStyle={{ backgroundColor: '#fff', border: '1px solid #E0E0E0', borderRadius: '12px' }}
|
|
formatter={(value: number) => formatCurrency(value)} />
|
|
<Legend />
|
|
<Bar dataKey="planejado" fill="#1A237E" radius={[4, 4, 0, 0]} name="Planejado" />
|
|
<Bar dataKey="realizado" fill="#E65100" radius={[4, 4, 0, 0]} name="Realizado" />
|
|
<Bar dataKey="economia" fill="#4CAF50" radius={[4, 4, 0, 0]} name="Economia" />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="card">
|
|
<div className="flex flex-col lg:flex-row gap-4">
|
|
<div className="flex-1 relative">
|
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
|
<input type="text" placeholder="Buscar por categoria..." value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)} className="input-field pl-12" />
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<select value={selectedYear} onChange={(e) => setSelectedYear(Number(e.target.value))} className="input-field w-32">
|
|
<option value={2026}>2026</option><option value={2025}>2025</option><option value={2024}>2024</option>
|
|
</select>
|
|
<select value={selectedMonth} onChange={(e) => setSelectedMonth(Number(e.target.value))} className="input-field w-40">
|
|
{months.map((month, index) => (<option key={index} value={index}>{month}</option>))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card !p-0 overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="table-header">
|
|
<tr>
|
|
<th className="table-cell">Categoria</th>
|
|
<th className="table-cell">Tipo Investimento</th>
|
|
<th className="table-cell">Centro de Custo</th>
|
|
<th className="table-cell">Período</th>
|
|
<th className="table-cell text-right">Planejado</th>
|
|
<th className="table-cell text-center">Período</th>
|
|
<th className="table-cell text-right">Valor Anual</th>
|
|
<th className="table-cell text-right">Realizado</th>
|
|
<th className="table-cell text-center">% Utilizado</th>
|
|
<th className="table-cell text-center">Status</th>
|
|
<th className="table-cell text-center">Ações</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredOrcamentos.slice((page - 1) * perPage, page * perPage).map((orcamento) => {
|
|
const planejado = getValorPlanejado(orcamento)
|
|
const percentage = Number(getPercentage(orcamento.valor_realizado, planejado))
|
|
const status = computeStatus(orcamento)
|
|
const tipoInvest = orcamento.tipo_investimento || ''
|
|
return (
|
|
<tr key={orcamento.id} className="table-row">
|
|
<td className="table-cell">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
<Wallet className="w-5 h-5 text-primary" />
|
|
</div>
|
|
<span className="font-medium">{categoriaMap[orcamento.categoria_id] || orcamento.categoria || '-'}</span>
|
|
</div>
|
|
</td>
|
|
<td className="table-cell">
|
|
{tipoInvest ? (
|
|
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold ${
|
|
tipoInvest === 'Capex'
|
|
? 'bg-blue-100 text-blue-700'
|
|
: tipoInvest === 'Opex'
|
|
? 'bg-orange-100 text-orange-700'
|
|
: 'bg-gray-100 text-gray-600'
|
|
}`}>
|
|
{tipoInvest}
|
|
</span>
|
|
) : (
|
|
<span className="text-gray text-sm">-</span>
|
|
)}
|
|
</td>
|
|
<td className="table-cell">
|
|
<span>{centrosCustoMap[orcamento.centro_custo_id] || '-'}</span>
|
|
</td>
|
|
<td className="table-cell">
|
|
<div className="flex items-center gap-2">
|
|
<Calendar className="w-4 h-4 text-gray" />
|
|
<span>{months[orcamento.mes]}/{orcamento.ano}</span>
|
|
</div>
|
|
</td>
|
|
<td className="table-cell text-right font-medium">{formatCurrency(planejado)}</td>
|
|
<td className="table-cell text-center">
|
|
<span className="badge badge-info text-xs">{orcamento.tipo_periodo === 'anual' ? 'Anual' : 'Mensal'}</span>
|
|
</td>
|
|
<td className="table-cell text-right font-medium">
|
|
{formatCurrency(orcamento.valor_anual || (orcamento.tipo_periodo === 'anual' ? planejado : planejado * 12))}
|
|
</td>
|
|
<td className="table-cell text-right font-medium">{formatCurrency(orcamento.valor_realizado)}</td>
|
|
<td className="table-cell">
|
|
<div className="flex items-center justify-center gap-2">
|
|
<div className="w-24 h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
<div className={`h-full rounded-full transition-all ${percentage > 100 ? 'bg-red-500' : percentage > 85 ? 'bg-amber-500' : 'bg-green-500'}`}
|
|
style={{ width: `${Math.min(percentage, 100)}%` }} />
|
|
</div>
|
|
<span className="text-sm font-medium w-14 text-right">{percentage}%</span>
|
|
</div>
|
|
</td>
|
|
<td className="table-cell text-center">
|
|
<span className={statusConfig[status]?.class || 'badge-neutral'}>{statusConfig[status]?.label || status}</span>
|
|
</td>
|
|
<td className="table-cell">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<button onClick={() => openEdit(orcamento)} className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors">
|
|
<Edit2 className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={() => handleDelete(orcamento.id)} className="p-2 rounded-lg hover:bg-red-50 text-gray hover:text-red-500 transition-colors">
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
|
<span className="text-sm text-gray">Pág {page}/{Math.ceil(filteredOrcamentos.length / perPage) || 1} — {filteredOrcamentos.length} registros</span>
|
|
<div className="flex gap-2">
|
|
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} className="btn-outline !py-1 !px-3 text-sm disabled:opacity-30">
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={() => setPage(p => Math.min(Math.ceil(filteredOrcamentos.length / perPage), p + 1))} disabled={page >= Math.ceil(filteredOrcamentos.length / perPage)} className="btn-outline !py-1 !px-3 text-sm disabled:opacity-30">
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Modal open={showModal} onClose={() => setShowModal(false)} title={editId ? 'Editar Orçamento' : 'Novo Orçamento'} onSubmit={handleSubmit} submitLabel={editId ? 'Salvar' : 'Criar Orçamento'} loading={saving}>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text mb-2">Categoria</label>
|
|
<select value={form.categoria_id} onChange={(e) => setForm({ ...form, categoria_id: e.target.value })} className="input-field" required>
|
|
<option value="">Selecione...</option>
|
|
{categorias.map(c => <option key={c.id} value={c.id}>{c.nome}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text mb-2">Centro de Custo</label>
|
|
<select value={form.centro_custo_id} onChange={(e) => setForm({ ...form, centro_custo_id: e.target.value })} className="input-field" required>
|
|
<option value="">Selecione...</option>
|
|
{centrosCusto.map(c => <option key={c.id} value={c.id}>{c.nome}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text mb-2">Ano</label>
|
|
<select value={form.ano} onChange={(e) => setForm({ ...form, ano: Number(e.target.value) })} className="input-field">
|
|
<option value={2024}>2024</option><option value={2025}>2025</option><option value={2026}>2026</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text mb-2">Mês</label>
|
|
<select value={form.mes} onChange={(e) => setForm({ ...form, mes: Number(e.target.value) })} className="input-field">
|
|
{months.slice(1).map((m, i) => <option key={i+1} value={i+1}>{m}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text mb-2">Tipo do Período</label>
|
|
<div className="flex gap-4">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input type="radio" name="tipo_periodo" value="mensal" checked={form.tipo_periodo === 'mensal'} onChange={() => setForm({ ...form, tipo_periodo: 'mensal' })} className="accent-primary" />
|
|
<span className="text-sm">Mensal</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input type="radio" name="tipo_periodo" value="anual" checked={form.tipo_periodo === 'anual'} onChange={() => setForm({ ...form, tipo_periodo: 'anual' })} className="accent-primary" />
|
|
<span className="text-sm">Anual</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text mb-2">Valor Planejado</label>
|
|
<input type="number" step="0.01" value={form.valor_planejado} onChange={(e) => setForm({ ...form, valor_planejado: Number(e.target.value) })} className="input-field" required />
|
|
{form.tipo_periodo === 'mensal' && form.valor_planejado > 0 && (
|
|
<p className="text-xs text-gray mt-1">Valor anual calculado: {formatCurrency(form.valor_planejado * 12)}</p>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|