HEFESTO v1.0 - Sistema de Controle Orçamentário para Facilities
- Backend NestJS com 12 módulos - Frontend React com dashboard e gestão - Manuais técnico e de negócios (MD + PDF) - Workflow de aprovação com alçadas - RBAC com 6 perfis de acesso
This commit is contained in:
254
frontend/src/pages/Orcamentos.tsx
Normal file
254
frontend/src/pages/Orcamentos.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Wallet,
|
||||
Search,
|
||||
Filter,
|
||||
Plus,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Calendar
|
||||
} from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
import { Orcamento } from '../types'
|
||||
|
||||
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' },
|
||||
}
|
||||
|
||||
const mockOrcamentos: Orcamento[] = [
|
||||
{ id: 1, ano: 2024, mes: 1, categoria: 'Manutenção Predial', valor_previsto: 50000, valor_realizado: 45000, status: 'dentro_limite' },
|
||||
{ id: 2, ano: 2024, mes: 1, categoria: 'Limpeza', valor_previsto: 30000, valor_realizado: 28500, status: 'dentro_limite' },
|
||||
{ id: 3, ano: 2024, mes: 1, categoria: 'Segurança', valor_previsto: 25000, valor_realizado: 26500, status: 'alerta' },
|
||||
{ id: 4, ano: 2024, mes: 1, categoria: 'Jardinagem', valor_previsto: 10000, valor_realizado: 12500, status: 'excedido' },
|
||||
{ id: 5, ano: 2024, mes: 2, categoria: 'Manutenção Predial', valor_previsto: 55000, valor_realizado: 42000, status: 'disponivel' },
|
||||
{ id: 6, ano: 2024, mes: 2, categoria: 'Utilities', valor_previsto: 35000, valor_realizado: 33000, status: 'dentro_limite' },
|
||||
]
|
||||
|
||||
export default function Orcamentos() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [orcamentos, setOrcamentos] = useState<Orcamento[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedYear, setSelectedYear] = useState(2024)
|
||||
const [selectedMonth, setSelectedMonth] = useState(0) // 0 = todos
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrcamentos()
|
||||
}, [])
|
||||
|
||||
const fetchOrcamentos = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/orcamento')
|
||||
setOrcamentos(data.length > 0 ? data : mockOrcamentos)
|
||||
} catch (err) {
|
||||
console.error('Error fetching orcamentos:', err)
|
||||
setOrcamentos(mockOrcamentos)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredOrcamentos = orcamentos.filter(orc => {
|
||||
const matchesSearch = orc.categoria.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 + orc.valor_previsto, 0)
|
||||
const totalRealizado = filteredOrcamentos.reduce((acc, orc) => acc + orc.valor_realizado, 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) => {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value)
|
||||
}
|
||||
|
||||
const getPercentage = (realizado: number, previsto: number) => {
|
||||
return ((realizado / previsto) * 100).toFixed(1)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
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">
|
||||
{/* Header */}
|
||||
<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 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>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<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 Previsto</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>
|
||||
|
||||
{/* Filters */}
|
||||
<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={2024}>2024</option>
|
||||
<option value={2023}>2023</option>
|
||||
<option value={2022}>2022</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>
|
||||
|
||||
{/* Table */}
|
||||
<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">Período</th>
|
||||
<th className="table-cell text-right">Previsto</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredOrcamentos.map((orcamento) => {
|
||||
const percentage = Number(getPercentage(orcamento.valor_realizado, orcamento.valor_previsto))
|
||||
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">{orcamento.categoria}</span>
|
||||
</div>
|
||||
</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(orcamento.valor_previsto)}
|
||||
</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[orcamento.status]?.class || 'badge-neutral'}>
|
||||
{statusConfig[orcamento.status]?.label || orcamento.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
||||
<span className="text-sm text-gray">
|
||||
Mostrando {filteredOrcamentos.length} de {orcamentos.length} registros
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray disabled:opacity-50" disabled>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button className="px-3 py-1 rounded-lg bg-primary text-white text-sm">1</button>
|
||||
<button className="px-3 py-1 rounded-lg hover:bg-gray-100 text-gray text-sm">2</button>
|
||||
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user