Major update: ESG, KPIs, metas, alertas, auditoria, documentos, importação, relatórios, subcategorias, dashboard orçamentos
This commit is contained in:
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="HEFESTO - Sistema de Controle Orçamentário para Facilities" />
|
||||
<title>HEFESTO - Controle Orçamentário</title>
|
||||
<meta name="description" content="Nexus Facilities - Sistema de Controle Orçamentário para Facilities" />
|
||||
<title>Nexus Facilities</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
@@ -8,7 +8,14 @@ import Orcamentos from './pages/Orcamentos'
|
||||
import OrdensServico from './pages/OrdensServico'
|
||||
import Fornecedores from './pages/Fornecedores'
|
||||
import Relatorios from './pages/Relatorios'
|
||||
import ESG from './pages/ESG'
|
||||
import KPIs from './pages/KPIs'
|
||||
import Auditoria from './pages/Auditoria'
|
||||
import Importacao from './pages/Importacao'
|
||||
import Metas from './pages/Metas'
|
||||
import AlertasConfig from './pages/AlertasConfig'
|
||||
import Usuarios from './pages/Usuarios'
|
||||
import Configuracao from './pages/Configuracao'
|
||||
|
||||
interface PrivateRouteProps {
|
||||
children: React.ReactNode;
|
||||
@@ -31,6 +38,13 @@ export default function App() {
|
||||
<Route path="ordens-servico" element={<OrdensServico />} />
|
||||
<Route path="fornecedores" element={<Fornecedores />} />
|
||||
<Route path="relatorios" element={<Relatorios />} />
|
||||
<Route path="esg" element={<ESG />} />
|
||||
<Route path="kpis" element={<KPIs />} />
|
||||
<Route path="auditoria" element={<Auditoria />} />
|
||||
<Route path="importacao" element={<Importacao />} />
|
||||
<Route path="metas" element={<Metas />} />
|
||||
<Route path="alertas-config" element={<AlertasConfig />} />
|
||||
<Route path="configuracao" element={<Configuracao />} />
|
||||
<Route path="usuarios" element={<Usuarios />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
|
||||
@@ -14,7 +14,12 @@ import {
|
||||
Flame,
|
||||
ChevronLeft,
|
||||
Bell,
|
||||
Search
|
||||
Search,
|
||||
Leaf,
|
||||
Shield,
|
||||
Upload,
|
||||
Target,
|
||||
Settings
|
||||
} from 'lucide-react'
|
||||
import { User } from '../types'
|
||||
|
||||
@@ -32,6 +37,13 @@ const navItems: NavItem[] = [
|
||||
{ path: '/app/ordens-servico', label: 'Ordens de Serviço', icon: <ClipboardList className="w-5 h-5" /> },
|
||||
{ path: '/app/fornecedores', label: 'Fornecedores', icon: <Building2 className="w-5 h-5" /> },
|
||||
{ path: '/app/relatorios', label: 'Relatórios', icon: <BarChart3 className="w-5 h-5" /> },
|
||||
{ path: '/app/esg', label: 'ESG', icon: <Leaf className="w-5 h-5" /> },
|
||||
{ path: '/app/kpis', label: 'KPIs', icon: <BarChart3 className="w-5 h-5" /> },
|
||||
{ path: '/app/metas', label: 'Metas', icon: <Target className="w-5 h-5" /> },
|
||||
{ path: '/app/auditoria', label: 'Auditoria', icon: <Shield className="w-5 h-5" /> },
|
||||
{ path: '/app/importacao', label: 'Importação', icon: <Upload className="w-5 h-5" /> },
|
||||
{ path: '/app/alertas-config', label: 'Alertas', icon: <Bell className="w-5 h-5" /> },
|
||||
{ path: '/app/configuracao', label: 'Configuração', icon: <Settings className="w-5 h-5" /> },
|
||||
{ path: '/app/usuarios', label: 'Usuários', icon: <Users className="w-5 h-5" />, adminOnly: true },
|
||||
]
|
||||
|
||||
@@ -74,7 +86,7 @@ export default function Layout() {
|
||||
<Flame className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
{sidebarOpen && (
|
||||
<span className="font-bold text-white text-xl tracking-tight">HEFESTO</span>
|
||||
<span className="font-bold text-white text-lg tracking-tight">Nexus Facilities</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
@@ -153,7 +165,7 @@ export default function Layout() {
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center">
|
||||
<Flame className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-white text-xl">HEFESTO</span>
|
||||
<span className="font-bold text-white text-lg">Nexus Facilities</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
|
||||
40
frontend/src/components/Modal.tsx
Normal file
40
frontend/src/components/Modal.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
children: ReactNode
|
||||
onSubmit?: () => void
|
||||
submitLabel?: string
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function Modal({ open, onClose, title, children, onSubmit, submitLabel = 'Salvar', loading }: ModalProps) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4" style={{ zIndex: 9999 }}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl animate-fade-in max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-6 border-b border-border bg-gradient-to-r from-primary to-accent rounded-t-2xl">
|
||||
<h2 className="text-xl font-semibold text-white">{title}</h2>
|
||||
<button onClick={onClose} className="p-2 rounded-lg hover:bg-white/20 text-white">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
{onSubmit && (
|
||||
<div className="flex gap-3 p-6 pt-0">
|
||||
<button type="button" onClick={onClose} className="btn-ghost flex-1">Cancelar</button>
|
||||
<button type="button" onClick={onSubmit} disabled={loading} className="btn-primary flex-1">
|
||||
{loading ? 'Salvando...' : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
frontend/src/hooks/useLookups.ts
Normal file
62
frontend/src/hooks/useLookups.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import api from '../services/api'
|
||||
|
||||
export type LookupMap = Record<string, string>
|
||||
|
||||
interface Lookups {
|
||||
categoriaMap: LookupMap
|
||||
centrosCustoMap: LookupMap
|
||||
locaisMap: LookupMap
|
||||
fornecedoresMap: LookupMap
|
||||
categorias: { id: string; nome: string }[]
|
||||
centrosCusto: { id: string; nome: string }[]
|
||||
locais: { id: string; nome: string }[]
|
||||
fornecedores: { id: string; nome: string }[]
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
function toMap(items: any[], nameField: string = 'nome'): LookupMap {
|
||||
const map: LookupMap = {}
|
||||
for (const item of items) {
|
||||
map[item.id] = item[nameField] || item.razao_social || item.nome_fantasia || item.id
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function useLookups(): Lookups {
|
||||
const [categoriaMap, setCategoriaMap] = useState<LookupMap>({})
|
||||
const [centrosCustoMap, setCentrosCustoMap] = useState<LookupMap>({})
|
||||
const [locaisMap, setLocaisMap] = useState<LookupMap>({})
|
||||
const [fornecedoresMap, setFornecedoresMap] = useState<LookupMap>({})
|
||||
const [categorias, setCategorias] = useState<{ id: string; nome: string }[]>([])
|
||||
const [centrosCusto, setCentrosCusto] = useState<{ id: string; nome: string }[]>([])
|
||||
const [locais, setLocais] = useState<{ id: string; nome: string }[]>([])
|
||||
const [fornecedores, setFornecedores] = useState<{ id: string; nome: string }[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.get('/categorias').catch(() => ({ data: [] })),
|
||||
api.get('/centros-custo').catch(() => ({ data: [] })),
|
||||
api.get('/locais').catch(() => ({ data: [] })),
|
||||
api.get('/fornecedores').catch(() => ({ data: [] })),
|
||||
]).then(([catRes, ccRes, locRes, fornRes]) => {
|
||||
const cats = catRes.data || []
|
||||
const ccs = ccRes.data || []
|
||||
const locs = locRes.data || []
|
||||
const forns = fornRes.data || []
|
||||
|
||||
setCategorias(cats.map((c: any) => ({ id: c.id, nome: c.nome })))
|
||||
setCentrosCusto(ccs.map((c: any) => ({ id: c.id, nome: c.nome })))
|
||||
setLocais(locs.map((l: any) => ({ id: l.id, nome: l.nome })))
|
||||
setFornecedores(forns.map((f: any) => ({ id: f.id, nome: f.razao_social || f.nome_fantasia || f.nome })))
|
||||
|
||||
setCategoriaMap(toMap(cats))
|
||||
setCentrosCustoMap(toMap(ccs))
|
||||
setLocaisMap(toMap(locs))
|
||||
setFornecedoresMap(toMap(forns, 'razao_social'))
|
||||
}).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return { categoriaMap, centrosCustoMap, locaisMap, fornecedoresMap, categorias, centrosCusto, locais, fornecedores, loading }
|
||||
}
|
||||
178
frontend/src/pages/AlertasConfig.tsx
Normal file
178
frontend/src/pages/AlertasConfig.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Bell, Loader2, Plus, X, Zap, CheckCircle, AlertTriangle } from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
|
||||
interface AlertConfig {
|
||||
id: number
|
||||
tipo: string
|
||||
limite_percentual: number
|
||||
centro_custo: string
|
||||
ativo: boolean
|
||||
criado_em?: string
|
||||
}
|
||||
|
||||
export default function AlertasConfig() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [configs, setConfigs] = useState<AlertConfig[]>([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
const [verifyResult, setVerifyResult] = useState<any>(null)
|
||||
const [form, setForm] = useState({ tipo: 'orcamento_excedido', limite_percentual: 80, centro_custo: '' })
|
||||
|
||||
useEffect(() => { fetchData() }, [])
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await api.get('/alertas/configs')
|
||||
setConfigs(Array.isArray(data) ? data : data?.configs || [])
|
||||
} catch (err) {
|
||||
console.error('Error fetching alert configs:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.post('/alertas/configurar', form)
|
||||
setShowForm(false)
|
||||
setForm({ tipo: 'orcamento_excedido', limite_percentual: 80, centro_custo: '' })
|
||||
fetchData()
|
||||
} catch (err) {
|
||||
console.error('Error creating alert config:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async () => {
|
||||
setVerifying(true)
|
||||
setVerifyResult(null)
|
||||
try {
|
||||
const { data } = await api.post('/alertas/verificar')
|
||||
setVerifyResult(data)
|
||||
} catch (err) {
|
||||
console.error('Error verifying alerts:', err)
|
||||
} finally {
|
||||
setVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (config: AlertConfig) => {
|
||||
try {
|
||||
await api.put(`/alertas/configs/${config.id}`, { ...config, ativo: !config.ativo })
|
||||
setConfigs(configs.map(c => c.id === config.id ? { ...c, ativo: !c.ativo } : c))
|
||||
} catch (err) {
|
||||
console.error('Error toggling alert:', err)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<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 flex items-center gap-2">
|
||||
<Bell className="w-8 h-8 text-primary" /> Configuração de Alertas
|
||||
</h1>
|
||||
<p className="text-gray mt-1">Configure alertas inteligentes para monitoramento proativo.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handleVerify} disabled={verifying} className="btn-outline text-sm flex items-center gap-1">
|
||||
{verifying ? <Loader2 className="w-4 h-4 animate-spin" /> : <Zap className="w-4 h-4" />} Verificar Agora
|
||||
</button>
|
||||
<button onClick={() => setShowForm(true)} className="btn-primary text-sm flex items-center gap-1">
|
||||
<Plus className="w-4 h-4" /> Novo Alerta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verify Result */}
|
||||
{verifyResult && (
|
||||
<div className="card border border-blue-200 bg-blue-50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle className="w-5 h-5 text-blue-500" />
|
||||
<span className="font-medium text-text">Verificação concluída</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray">
|
||||
{verifyResult.alertas_disparados ?? verifyResult.total ?? 0} alertas disparados.
|
||||
{verifyResult.mensagem && ` ${verifyResult.mensagem}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Form */}
|
||||
{showForm && (
|
||||
<div className="card border-2 border-primary/20">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-text">Novo Alerta</h3>
|
||||
<button onClick={() => setShowForm(false)} className="p-1 rounded hover:bg-gray-100"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
<select value={form.tipo} onChange={e => setForm({...form, tipo: e.target.value})} className="input-field">
|
||||
<option value="orcamento_excedido">Orçamento Excedido</option>
|
||||
<option value="demanda_atrasada">Demanda Atrasada</option>
|
||||
<option value="os_vencida">OS Vencida</option>
|
||||
<option value="contrato_vencendo">Contrato Vencendo</option>
|
||||
</select>
|
||||
<input type="number" placeholder="Limite %" value={form.limite_percentual} onChange={e => setForm({...form, limite_percentual: Number(e.target.value)})} className="input-field" />
|
||||
<input placeholder="Centro de Custo (opcional)" value={form.centro_custo} onChange={e => setForm({...form, centro_custo: e.target.value})} className="input-field" />
|
||||
</div>
|
||||
<button onClick={handleCreate} disabled={saving} className="btn-primary mt-4 flex items-center gap-2 disabled:opacity-50">
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />} Criar Alerta
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configs Table */}
|
||||
{configs.length > 0 ? (
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="table-header">
|
||||
<th className="table-cell text-left">Tipo</th>
|
||||
<th className="table-cell text-left">Limite %</th>
|
||||
<th className="table-cell text-left">Centro de Custo</th>
|
||||
<th className="table-cell text-center">Ativo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{configs.map(config => (
|
||||
<tr key={config.id} className="table-row">
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className={`w-4 h-4 ${config.ativo ? 'text-amber-500' : 'text-gray-300'}`} />
|
||||
<span className="text-sm font-medium capitalize">{config.tipo.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell text-sm">{config.limite_percentual}%</td>
|
||||
<td className="table-cell text-sm text-gray">{config.centro_custo || '—'}</td>
|
||||
<td className="table-cell text-center">
|
||||
<button
|
||||
onClick={() => handleToggle(config)}
|
||||
className={`w-12 h-6 rounded-full transition-all relative ${config.ativo ? 'bg-green-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-all ${config.ativo ? 'left-6' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card text-center py-12">
|
||||
<Bell className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray">Nenhum alerta configurado</h3>
|
||||
<p className="text-sm text-gray-light mt-1">Configure alertas para receber notificações proativas.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
156
frontend/src/pages/Auditoria.tsx
Normal file
156
frontend/src/pages/Auditoria.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Shield, Loader2, Download, Search, FileText, AlertTriangle, CheckCircle } from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
|
||||
interface AuditLog {
|
||||
id: number
|
||||
timestamp: string
|
||||
usuario: string
|
||||
entidade: string
|
||||
acao: string
|
||||
detalhes: string
|
||||
}
|
||||
|
||||
interface ComplianceReport {
|
||||
total_logs: number
|
||||
acoes_criticas: number
|
||||
usuarios_ativos: number
|
||||
entidades_auditadas: number
|
||||
}
|
||||
|
||||
export default function Auditoria() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [compliance, setCompliance] = useState<ComplianceReport | null>(null)
|
||||
const [entity, setEntity] = useState('')
|
||||
const [action, setAction] = useState('')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
|
||||
useEffect(() => { fetchData() }, [entity, action, dateFrom, dateTo])
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: any = {}
|
||||
if (entity) params.entity = entity
|
||||
if (action) params.action = action
|
||||
if (dateFrom) params.date_from = dateFrom
|
||||
if (dateTo) params.date_to = dateTo
|
||||
const [logsRes, compRes] = await Promise.all([
|
||||
api.get('/audit/logs', { params }),
|
||||
api.get('/audit/compliance-report')
|
||||
])
|
||||
setLogs(logsRes.data?.logs || logsRes.data || [])
|
||||
setCompliance(compRes.data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching audit data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = (format: string) => {
|
||||
const token = localStorage.getItem('token')
|
||||
window.open(`/api/audit/export?format=${format}&token=${token}`, '_blank')
|
||||
}
|
||||
|
||||
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">
|
||||
<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 flex items-center gap-2">
|
||||
<Shield className="w-8 h-8 text-secondary" /> Auditoria & Compliance
|
||||
</h1>
|
||||
<p className="text-gray mt-1">Registro de ações e conformidade do sistema.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => handleExport('csv')} className="btn-outline text-sm flex items-center gap-1">
|
||||
<Download className="w-4 h-4" /> CSV
|
||||
</button>
|
||||
<button onClick={() => handleExport('json')} className="btn-outline text-sm flex items-center gap-1">
|
||||
<Download className="w-4 h-4" /> JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compliance Summary */}
|
||||
{compliance && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ title: 'Total de Logs', value: compliance.total_logs, icon: <FileText className="w-5 h-5" />, color: 'from-blue-500 to-blue-600' },
|
||||
{ title: 'Ações Críticas', value: compliance.acoes_criticas, icon: <AlertTriangle className="w-5 h-5" />, color: 'from-red-500 to-red-600' },
|
||||
{ title: 'Usuários Ativos', value: compliance.usuarios_ativos, icon: <CheckCircle className="w-5 h-5" />, color: 'from-green-500 to-green-600' },
|
||||
{ title: 'Entidades Auditadas', value: compliance.entidades_auditadas, icon: <Shield className="w-5 h-5" />, color: 'from-purple-500 to-purple-600' },
|
||||
].map((c, i) => (
|
||||
<div key={i} className="card">
|
||||
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${c.color} flex items-center justify-center text-white mb-3`}>
|
||||
{c.icon}
|
||||
</div>
|
||||
<p className="text-sm text-gray">{c.title}</p>
|
||||
<p className="text-2xl font-bold text-text">{c.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<input type="text" placeholder="Filtrar entidade..." value={entity} onChange={e => setEntity(e.target.value)} className="input-field text-sm" />
|
||||
<input type="text" placeholder="Filtrar ação..." value={action} onChange={e => setAction(e.target.value)} className="input-field text-sm" />
|
||||
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} className="input-field text-sm" />
|
||||
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} className="input-field text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
<div className="card overflow-x-auto">
|
||||
{Array.isArray(logs) && logs.length > 0 ? (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="table-header">
|
||||
<th className="table-cell text-left">Timestamp</th>
|
||||
<th className="table-cell text-left">Usuário</th>
|
||||
<th className="table-cell text-left">Entidade</th>
|
||||
<th className="table-cell text-left">Ação</th>
|
||||
<th className="table-cell text-left">Detalhes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map(log => (
|
||||
<tr key={log.id} className="table-row">
|
||||
<td className="table-cell text-sm">{new Date(log.timestamp).toLocaleString('pt-BR')}</td>
|
||||
<td className="table-cell text-sm font-medium">{log.usuario}</td>
|
||||
<td className="table-cell text-sm">{log.entidade}</td>
|
||||
<td className="table-cell">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||
log.acao === 'DELETE' ? 'badge-error' :
|
||||
log.acao === 'CREATE' ? 'badge-success' :
|
||||
log.acao === 'UPDATE' ? 'badge-warning' : 'badge-info'
|
||||
}`}>{log.acao}</span>
|
||||
</td>
|
||||
<td className="table-cell text-sm text-gray max-w-xs truncate">{log.detalhes}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray">Nenhum log encontrado</h3>
|
||||
<p className="text-sm text-gray-light mt-1">Ajuste os filtros para visualizar registros de auditoria.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
432
frontend/src/pages/Configuracao.tsx
Normal file
432
frontend/src/pages/Configuracao.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Settings, Plus, Edit2, Trash2, Loader2, X, Save } from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
type Tab = 'categorias' | 'centros_custo' | 'subcategorias' | 'locais'
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'categorias', label: 'Categorias' },
|
||||
{ key: 'centros_custo', label: 'Centros de Custo' },
|
||||
{ key: 'subcategorias', label: 'Subcategorias' },
|
||||
{ key: 'locais', label: 'Locais' },
|
||||
]
|
||||
|
||||
export default function Configuracao() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('categorias')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [items, setItems] = useState<any[]>([])
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editId, setEditId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<any>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
// For subcategorias
|
||||
const [categorias, setCategorias] = useState<any[]>([])
|
||||
|
||||
const endpoints: Record<Tab, string> = {
|
||||
categorias: '/categorias',
|
||||
centros_custo: '/centros-custo',
|
||||
subcategorias: '/subcategorias',
|
||||
locais: '/locais',
|
||||
}
|
||||
|
||||
useEffect(() => { fetchItems(); fetchCategorias() }, [activeTab])
|
||||
|
||||
const fetchItems = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await api.get(endpoints[activeTab])
|
||||
setItems(data)
|
||||
} catch { setItems([]) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const fetchCategorias = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/categorias')
|
||||
setCategorias(data)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const getEmptyForm = (): any => {
|
||||
switch (activeTab) {
|
||||
case 'categorias': return { nome: '', criticidade_padrao: 'media', sla_dias: 30, tipo_investimento: '', tipo_manutencao: '', impacto_ambiental_esperado: '', potencial_geracao_residuos: '' }
|
||||
case 'centros_custo': return { codigo: '', nome: '' }
|
||||
case 'subcategorias': return { nome: '', categoria_id: '' }
|
||||
case 'locais': return { nome: '', endereco: '', tipo_operacao_local: '', classificacao_impacto_ambiental: '', praticas_sustentaveis: [] }
|
||||
}
|
||||
}
|
||||
|
||||
const openNew = () => { setEditId(null); setForm(getEmptyForm()); setShowModal(true) }
|
||||
|
||||
const openEdit = (item: any) => {
|
||||
setEditId(item.id)
|
||||
switch (activeTab) {
|
||||
case 'categorias':
|
||||
setForm({ nome: item.nome, criticidade_padrao: item.criticidade_padrao || 'media', sla_dias: item.sla_dias || 30, tipo_investimento: item.tipo_investimento || '', tipo_manutencao: item.tipo_manutencao || '', impacto_ambiental_esperado: item.impacto_ambiental_esperado || '', potencial_geracao_residuos: item.potencial_geracao_residuos || '' })
|
||||
break
|
||||
case 'centros_custo':
|
||||
setForm({ codigo: item.codigo || '', nome: item.nome })
|
||||
break
|
||||
case 'subcategorias':
|
||||
setForm({ nome: item.nome, categoria_id: item.categoria_id || '' })
|
||||
break
|
||||
case 'locais':
|
||||
setForm({ nome: item.nome, endereco: item.endereco || '', tipo_operacao_local: item.tipo_operacao_local || '', classificacao_impacto_ambiental: item.classificacao_impacto_ambiental || '', praticas_sustentaveis: item.praticas_sustentaveis || [] })
|
||||
break
|
||||
}
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editId) {
|
||||
await api.patch(`${endpoints[activeTab]}/${editId}`, form)
|
||||
} else {
|
||||
await api.post(endpoints[activeTab], form)
|
||||
}
|
||||
setShowModal(false)
|
||||
fetchItems()
|
||||
} catch (err) {
|
||||
alert('Erro ao salvar')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Tem certeza que deseja excluir?')) return
|
||||
try {
|
||||
await api.delete(`${endpoints[activeTab]}/${id}`)
|
||||
fetchItems()
|
||||
} catch { alert('Erro ao excluir') }
|
||||
}
|
||||
|
||||
const catMap: Record<string, string> = {}
|
||||
categorias.forEach(c => { catMap[c.id] = c.nome })
|
||||
|
||||
const renderFormFields = () => {
|
||||
switch (activeTab) {
|
||||
case 'categorias':
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Nome</label>
|
||||
<input type="text" value={form.nome} onChange={e => setForm({ ...form, nome: e.target.value })} className="input-field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Tipo de Investimento *</label>
|
||||
<select value={form.tipo_investimento} onChange={e => setForm({ ...form, tipo_investimento: e.target.value })} className="input-field" required>
|
||||
<option value="">Selecione...</option>
|
||||
<option value="capex">Capex</option>
|
||||
<option value="opex">Opex</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Criticidade Padrão</label>
|
||||
<select value={form.criticidade_padrao} onChange={e => setForm({ ...form, criticidade_padrao: e.target.value })} className="input-field">
|
||||
<option value="baixa">Baixa</option>
|
||||
<option value="media">Média</option>
|
||||
<option value="alta">Alta</option>
|
||||
<option value="critica">Crítica</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">SLA (dias)</label>
|
||||
<input type="number" value={form.sla_dias} onChange={e => setForm({ ...form, sla_dias: Number(e.target.value) })} className="input-field" />
|
||||
</div>
|
||||
<div className="col-span-full border-t border-border pt-3 mt-2">
|
||||
<p className="text-sm font-semibold mb-3" style={{ color: '#1A7A4C' }}>🌿 Campos ESG</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Tipo de Manutenção</label>
|
||||
<select value={form.tipo_manutencao} onChange={e => setForm({ ...form, tipo_manutencao: e.target.value })} className="input-field">
|
||||
<option value="">Selecione...</option>
|
||||
<option value="Preventiva">Preventiva</option>
|
||||
<option value="Corretiva">Corretiva</option>
|
||||
<option value="Emergencial">Emergencial</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Impacto Ambiental Esperado</label>
|
||||
<select value={form.impacto_ambiental_esperado} onChange={e => setForm({ ...form, impacto_ambiental_esperado: e.target.value })} className="input-field">
|
||||
<option value="">Selecione...</option>
|
||||
<option value="Baixo">Baixo</option>
|
||||
<option value="Médio">Médio</option>
|
||||
<option value="Alto">Alto</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Potencial Geração Resíduos</label>
|
||||
<select value={form.potencial_geracao_residuos} onChange={e => setForm({ ...form, potencial_geracao_residuos: e.target.value })} className="input-field">
|
||||
<option value="">Selecione...</option>
|
||||
<option value="Baixo">Baixo</option>
|
||||
<option value="Médio">Médio</option>
|
||||
<option value="Alto">Alto</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
case 'centros_custo':
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Código</label>
|
||||
<input type="text" value={form.codigo} onChange={e => setForm({ ...form, codigo: e.target.value })} className="input-field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Nome</label>
|
||||
<input type="text" value={form.nome} onChange={e => setForm({ ...form, nome: e.target.value })} className="input-field" required />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
case 'subcategorias':
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Nome</label>
|
||||
<input type="text" value={form.nome} onChange={e => setForm({ ...form, nome: e.target.value })} className="input-field" required />
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
case 'locais':
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Nome</label>
|
||||
<input type="text" value={form.nome} onChange={e => setForm({ ...form, nome: e.target.value })} className="input-field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Endereço</label>
|
||||
<input type="text" value={form.endereco} onChange={e => setForm({ ...form, endereco: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div className="col-span-full border-t border-border pt-3 mt-2">
|
||||
<p className="text-sm font-semibold mb-3" style={{ color: '#1A7A4C' }}>🌿 Campos ESG</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Tipo de Operação</label>
|
||||
<select value={form.tipo_operacao_local} onChange={e => setForm({ ...form, tipo_operacao_local: e.target.value })} className="input-field">
|
||||
<option value="">Selecione...</option>
|
||||
<option value="Administrativo">Administrativo</option>
|
||||
<option value="Industrial">Industrial</option>
|
||||
<option value="Logístico">Logístico</option>
|
||||
<option value="Comercial">Comercial</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Classificação Impacto Ambiental</label>
|
||||
<select value={form.classificacao_impacto_ambiental} onChange={e => setForm({ ...form, classificacao_impacto_ambiental: e.target.value })} className="input-field">
|
||||
<option value="">Selecione...</option>
|
||||
<option value="Baixo">Baixo</option>
|
||||
<option value="Médio">Médio</option>
|
||||
<option value="Alto">Alto</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Práticas Sustentáveis</label>
|
||||
<div className="space-y-2">
|
||||
{['Coleta Seletiva', 'Reuso de Água', 'Energia Renovável', 'Compostagem', 'Redução de Plástico'].map(p => (
|
||||
<label key={p} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={(form.praticas_sustentaveis || []).includes(p)}
|
||||
onChange={e => {
|
||||
const curr = form.praticas_sustentaveis || []
|
||||
setForm({ ...form, praticas_sustentaveis: e.target.checked ? [...curr, p] : curr.filter((x: string) => x !== p) })
|
||||
}}
|
||||
className="rounded border-gray-300 text-green-600" />
|
||||
{p}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const renderColumns = () => {
|
||||
switch (activeTab) {
|
||||
case 'categorias':
|
||||
return (
|
||||
<>
|
||||
<th className="table-cell">Nome</th>
|
||||
<th className="table-cell">Tipo Invest.</th>
|
||||
<th className="table-cell">Criticidade</th>
|
||||
<th className="table-cell">SLA</th>
|
||||
<th className="table-cell">Manutenção</th>
|
||||
<th className="table-cell">Impacto Amb.</th>
|
||||
<th className="table-cell text-center">Ações</th>
|
||||
</>
|
||||
)
|
||||
case 'centros_custo':
|
||||
return (
|
||||
<>
|
||||
<th className="table-cell">Código</th>
|
||||
<th className="table-cell">Nome</th>
|
||||
<th className="table-cell text-center">Ações</th>
|
||||
</>
|
||||
)
|
||||
case 'subcategorias':
|
||||
return (
|
||||
<>
|
||||
<th className="table-cell">Nome</th>
|
||||
<th className="table-cell">Categoria</th>
|
||||
<th className="table-cell text-center">Ações</th>
|
||||
</>
|
||||
)
|
||||
case 'locais':
|
||||
return (
|
||||
<>
|
||||
<th className="table-cell">Nome</th>
|
||||
<th className="table-cell">Endereço</th>
|
||||
<th className="table-cell">Operação</th>
|
||||
<th className="table-cell">Impacto Amb.</th>
|
||||
<th className="table-cell text-center">Ações</th>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const renderRow = (item: any) => {
|
||||
switch (activeTab) {
|
||||
case 'categorias':
|
||||
return (
|
||||
<>
|
||||
<td className="table-cell font-medium">{item.nome}</td>
|
||||
<td className="table-cell">
|
||||
{item.tipo_investimento ? (
|
||||
<span className={`badge ${item.tipo_investimento === 'capex' ? 'badge-info' : 'badge-warning'}`}>
|
||||
{item.tipo_investimento === 'capex' ? 'Capex' : 'Opex'}
|
||||
</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="table-cell">{item.criticidade_padrao || '-'}</td>
|
||||
<td className="table-cell">{item.sla_dias} dias</td>
|
||||
<td className="table-cell">
|
||||
{item.tipo_manutencao ? (
|
||||
<span className={`badge ${item.tipo_manutencao === 'Emergencial' ? 'badge-error' : item.tipo_manutencao === 'Corretiva' ? 'badge-warning' : 'badge-success'}`}>
|
||||
{item.tipo_manutencao}
|
||||
</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
{item.impacto_ambiental_esperado ? (
|
||||
<span className={`badge ${item.impacto_ambiental_esperado === 'Alto' ? 'badge-error' : item.impacto_ambiental_esperado === 'Médio' ? 'badge-warning' : 'badge-success'}`}>
|
||||
{item.impacto_ambiental_esperado}
|
||||
</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
</>
|
||||
)
|
||||
case 'centros_custo':
|
||||
return (
|
||||
<>
|
||||
<td className="table-cell font-mono">{item.codigo}</td>
|
||||
<td className="table-cell font-medium">{item.nome}</td>
|
||||
</>
|
||||
)
|
||||
case 'subcategorias':
|
||||
return (
|
||||
<>
|
||||
<td className="table-cell font-medium">{item.nome}</td>
|
||||
<td className="table-cell">{catMap[item.categoria_id] || '-'}</td>
|
||||
</>
|
||||
)
|
||||
case 'locais':
|
||||
return (
|
||||
<>
|
||||
<td className="table-cell font-medium">{item.nome}</td>
|
||||
<td className="table-cell">{item.endereco || '-'}</td>
|
||||
<td className="table-cell">{item.tipo_operacao_local || '-'}</td>
|
||||
<td className="table-cell">
|
||||
{item.classificacao_impacto_ambiental ? (
|
||||
<span className={`badge ${item.classificacao_impacto_ambiental === 'Alto' ? 'badge-error' : item.classificacao_impacto_ambiental === 'Médio' ? 'badge-warning' : 'badge-success'}`}>
|
||||
{item.classificacao_impacto_ambiental}
|
||||
</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 flex items-center gap-3">
|
||||
<Settings className="w-8 h-8 text-primary" /> Configuração
|
||||
</h1>
|
||||
<p className="text-gray mt-1">Gerencie as tabelas de apoio do sistema</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 Registro
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-border overflow-x-auto">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-4 py-3 text-sm font-medium transition-all border-b-2 whitespace-nowrap ${
|
||||
activeTab === tab.key
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-gray hover:text-text'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-48"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>
|
||||
) : (
|
||||
<div className="card !p-0 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="table-header">
|
||||
<tr>{renderColumns()}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => (
|
||||
<tr key={item.id} className="table-row">
|
||||
{renderRow(item)}
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button onClick={() => openEdit(item)} 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(item.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>
|
||||
{items.length === 0 && (
|
||||
<div className="text-center py-12"><p className="text-gray">Nenhum registro encontrado</p></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal open={showModal} onClose={() => setShowModal(false)} title={editId ? 'Editar' : 'Novo Registro'} onSubmit={handleSubmit} submitLabel={editId ? 'Salvar' : 'Criar'} loading={saving}>
|
||||
{renderFormFields()}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Wallet,
|
||||
TrendingUp,
|
||||
@@ -9,60 +9,76 @@ import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
ArrowUpRight,
|
||||
Loader2
|
||||
Loader2,
|
||||
Leaf,
|
||||
Shield,
|
||||
Search,
|
||||
X,
|
||||
Filter
|
||||
} from 'lucide-react'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend, LineChart, Line } from 'recharts'
|
||||
import api from '../services/api'
|
||||
import { User } from '../types'
|
||||
import { User, DashboardIndicadores } from '../types'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
interface StatsCard {
|
||||
title: string;
|
||||
value: string | number;
|
||||
change?: string;
|
||||
changeType?: 'positive' | 'negative' | 'neutral';
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
statusKey: string;
|
||||
}
|
||||
|
||||
const monthlyData = [
|
||||
{ name: 'Jan', previsto: 4000, realizado: 3800 },
|
||||
{ name: 'Fev', previsto: 3500, realizado: 3200 },
|
||||
{ name: 'Mar', previsto: 4200, realizado: 4100 },
|
||||
{ name: 'Abr', previsto: 3800, realizado: 3900 },
|
||||
{ name: 'Mai', previsto: 4500, realizado: 4200 },
|
||||
{ name: 'Jun', previsto: 4000, realizado: 3700 },
|
||||
]
|
||||
|
||||
const categoryData = [
|
||||
{ name: 'Manutenção', value: 35, color: '#E65100' },
|
||||
{ name: 'Limpeza', value: 25, color: '#1A237E' },
|
||||
{ name: 'Segurança', value: 20, color: '#FF8F00' },
|
||||
{ name: 'Outros', value: 20, color: '#757575' },
|
||||
]
|
||||
|
||||
const recentActivities = [
|
||||
{ id: 1, action: 'Nova demanda criada', description: 'Manutenção ar condicionado - Bloco A', time: 'Há 2 horas', status: 'pending' },
|
||||
{ id: 2, action: 'Ordem de serviço aprovada', description: 'OS-2024-0156 - Troca de lâmpadas', time: 'Há 4 horas', status: 'success' },
|
||||
{ id: 3, action: 'Fornecedor cadastrado', description: 'Tech Solutions Ltda', time: 'Há 6 horas', status: 'info' },
|
||||
{ id: 4, action: 'Orçamento atualizado', description: 'Categoria: Manutenção Predial', time: 'Há 8 horas', status: 'warning' },
|
||||
]
|
||||
type DashTab = 'geral' | 'esg'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [stats, setStats] = useState<any>(null)
|
||||
const [stats, setStats] = useState<DashboardIndicadores | null>(null)
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<DashTab>('geral')
|
||||
const [esgData, setEsgData] = useState<any>(null)
|
||||
const [esgLoading, setEsgLoading] = useState(false)
|
||||
|
||||
// Chart filter state
|
||||
const [chartFilter, setChartFilter] = useState<'todos' | 'centro_custo' | 'categoria'>('todos')
|
||||
const [chartFilterId, setChartFilterId] = useState('')
|
||||
const [chartData, setChartData] = useState<any[]>([])
|
||||
const [centrosCusto, setCentrosCusto] = useState<{id:string;nome:string}[]>([])
|
||||
const [categorias, setCategorias] = useState<{id:string;nome:string}[]>([])
|
||||
|
||||
// Category chart data (from backend)
|
||||
const [categoryData, setCategoryData] = useState<any[]>([])
|
||||
|
||||
// Card drill-down modal
|
||||
const [drillModal, setDrillModal] = useState(false)
|
||||
const [drillTitle, setDrillTitle] = useState('')
|
||||
const [drillData, setDrillData] = useState<any[]>([])
|
||||
const [drillLoading, setDrillLoading] = useState(false)
|
||||
|
||||
// Search
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<any>(null)
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const userData = localStorage.getItem('user')
|
||||
if (userData) {
|
||||
setUser(JSON.parse(userData))
|
||||
}
|
||||
if (userData) setUser(JSON.parse(userData))
|
||||
fetchDashboard()
|
||||
fetchLookups()
|
||||
fetchCategoryData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'esg' && !esgData) fetchEsg()
|
||||
}, [activeTab])
|
||||
|
||||
useEffect(() => {
|
||||
fetchChartData()
|
||||
}, [chartFilter, chartFilterId])
|
||||
|
||||
const fetchDashboard = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/dashboard')
|
||||
const { data } = await api.get('/dashboard/indicadores')
|
||||
setStats(data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching dashboard:', err)
|
||||
@@ -71,39 +87,93 @@ export default function Dashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLookups = async () => {
|
||||
try {
|
||||
const [catRes, ccRes] = await Promise.all([
|
||||
api.get('/categorias').catch(() => ({ data: [] })),
|
||||
api.get('/centros-custo').catch(() => ({ data: [] })),
|
||||
])
|
||||
setCategorias((catRes.data || []).map((c: any) => ({ id: c.id, nome: c.nome })))
|
||||
setCentrosCusto((ccRes.data || []).map((c: any) => ({ id: c.id, nome: c.nome })))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const fetchChartData = async () => {
|
||||
try {
|
||||
const params: any = { ano: 2026 }
|
||||
if (chartFilter === 'centro_custo' && chartFilterId) params.centro_custo_id = chartFilterId
|
||||
if (chartFilter === 'categoria' && chartFilterId) params.categoria_id = chartFilterId
|
||||
const { data } = await api.get('/dashboard/consumo-orcamento', { params })
|
||||
setChartData(data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching chart data:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCategoryData = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/dashboard/categorias-quantidade')
|
||||
setCategoryData(data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching category data:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchEsg = async () => {
|
||||
setEsgLoading(true)
|
||||
try {
|
||||
const { data } = await api.get('/dashboard/esg')
|
||||
setEsgData(data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching ESG:', err)
|
||||
} finally {
|
||||
setEsgLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCardClick = async (statusKey: string, title: string) => {
|
||||
setDrillTitle(title)
|
||||
setDrillModal(true)
|
||||
setDrillLoading(true)
|
||||
try {
|
||||
const { data } = await api.get('/dashboard/demandas-detalhe', { params: { status: statusKey } })
|
||||
setDrillData(data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching drill-down:', err)
|
||||
setDrillData([])
|
||||
} finally {
|
||||
setDrillLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = useCallback(async (term: string) => {
|
||||
if (term.trim().length < 2) {
|
||||
setSearchResults(null)
|
||||
return
|
||||
}
|
||||
setSearchLoading(true)
|
||||
try {
|
||||
const { data } = await api.get('/dashboard/busca', { params: { q: term } })
|
||||
setSearchResults(data)
|
||||
} catch {
|
||||
setSearchResults(null)
|
||||
} finally {
|
||||
setSearchLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => handleSearch(searchTerm), 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchTerm, handleSearch])
|
||||
|
||||
const formatCurrency = (v: number) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v || 0)
|
||||
|
||||
const statsCards: StatsCard[] = [
|
||||
{
|
||||
title: 'Orçamento Total',
|
||||
value: stats?.total_orcamento ? `${(stats.total_orcamento / 1000).toFixed(0)}K` : '0',
|
||||
change: '+12%',
|
||||
changeType: 'positive',
|
||||
icon: <Wallet className="w-6 h-6" />,
|
||||
color: 'from-primary to-accent'
|
||||
},
|
||||
{
|
||||
title: 'Total Gasto',
|
||||
value: stats?.total_gasto ? `${(stats.total_gasto / 1000).toFixed(0)}K` : '0',
|
||||
change: '-5%',
|
||||
changeType: 'positive',
|
||||
icon: <TrendingDown className="w-6 h-6" />,
|
||||
color: 'from-secondary to-secondary-light'
|
||||
},
|
||||
{
|
||||
title: 'Economia',
|
||||
value: stats?.economia ? `${(stats.economia / 1000).toFixed(0)}K` : '0',
|
||||
change: '+8%',
|
||||
changeType: 'positive',
|
||||
icon: <TrendingUp className="w-6 h-6" />,
|
||||
color: 'from-green-500 to-emerald-500'
|
||||
},
|
||||
{
|
||||
title: 'Pendências',
|
||||
value: stats?.pendencias || stats?.demandas_pendentes || '0',
|
||||
change: '-3',
|
||||
changeType: 'neutral',
|
||||
icon: <Clock className="w-6 h-6" />,
|
||||
color: 'from-amber-500 to-orange-500'
|
||||
},
|
||||
{ title: 'Demandas Abertas', value: stats?.demandas_abertas ?? 0, icon: <FileText className="w-6 h-6" />, color: 'from-primary to-accent', statusKey: 'abertas' },
|
||||
{ title: 'Em Cotação', value: stats?.em_cotacao ?? 0, icon: <Wallet className="w-6 h-6" />, color: 'from-secondary to-secondary-light', statusKey: 'em_cotacao' },
|
||||
{ title: 'Em Aprovação', value: stats?.em_aprovacao ?? 0, icon: <Clock className="w-6 h-6" />, color: 'from-amber-500 to-orange-500', statusKey: 'em_aprovacao' },
|
||||
{ title: 'OS Ativas', value: stats?.os_ativas ?? 0, icon: <TrendingUp className="w-6 h-6" />, color: 'from-green-500 to-emerald-500', statusKey: 'concluidas' },
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
@@ -114,9 +184,17 @@ export default function Dashboard() {
|
||||
)
|
||||
}
|
||||
|
||||
const manutencaoPieData = esgData ? [
|
||||
{ name: 'Preventiva', value: esgData.total_preventivas, color: '#1A7A4C' },
|
||||
{ name: 'Corretiva', value: esgData.total_corretivas, color: '#FF8F00' },
|
||||
{ name: 'Emergencial', value: esgData.total_emergenciais, color: '#E53935' },
|
||||
] : []
|
||||
|
||||
const filterOptions = chartFilter === 'centro_custo' ? centrosCusto : chartFilter === 'categoria' ? categorias : []
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Welcome header */}
|
||||
{/* Welcome header + Search */}
|
||||
<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">
|
||||
@@ -124,174 +202,359 @@ export default function Dashboard() {
|
||||
</h1>
|
||||
<p className="text-gray mt-1">Aqui está o resumo das suas operações de facilities.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray">
|
||||
<span>Última atualização:</span>
|
||||
<span className="font-medium text-text">Agora</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6">
|
||||
{statsCards.map((card, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="card group hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${card.color} flex items-center justify-center text-white shadow-lg`}>
|
||||
{card.icon}
|
||||
</div>
|
||||
{card.change && (
|
||||
<span className={`text-xs font-semibold px-2 py-1 rounded-full ${
|
||||
card.changeType === 'positive' ? 'bg-green-100 text-green-700' :
|
||||
card.changeType === 'negative' ? 'bg-red-100 text-red-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
{card.change}
|
||||
</span>
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-light" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar demandas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="input-field pl-10 pr-8 !py-2 text-sm"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button onClick={() => { setSearchTerm(''); setSearchResults(null) }} className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<X className="w-4 h-4 text-gray" />
|
||||
</button>
|
||||
)}
|
||||
{/* Search Results Dropdown */}
|
||||
{searchResults && (
|
||||
<div className="absolute top-full mt-1 left-0 right-0 bg-white rounded-xl shadow-xl border border-border z-50 max-h-80 overflow-y-auto">
|
||||
{searchResults.demandas?.length > 0 ? (
|
||||
<div className="p-2">
|
||||
<p className="text-xs text-gray px-2 py-1 font-semibold">Demandas</p>
|
||||
{searchResults.demandas.map((d: any) => (
|
||||
<div key={d.id} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<FileText className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text truncate">{d.numero ? `#${d.numero} - ` : ''}{d.titulo}</p>
|
||||
<p className="text-xs text-gray">{d.categoria} · {d.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-gray">Nenhum resultado encontrado</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray text-sm mb-1">{card.title}</p>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-text">{card.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* Bar Chart */}
|
||||
<div className="lg:col-span-2 card">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text">Orçamento vs Realizado</h2>
|
||||
<p className="text-sm text-gray">Comparativo mensal</p>
|
||||
</div>
|
||||
<button className="text-sm text-primary hover:underline flex items-center gap-1">
|
||||
Ver detalhes
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={monthlyData} barGap={8}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #E0E0E0',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="previsto" fill="#1A237E" radius={[4, 4, 0, 0]} name="Previsto" />
|
||||
<Bar dataKey="realizado" fill="#E65100" radius={[4, 4, 0, 0]} name="Realizado" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pie Chart */}
|
||||
<div className="card">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-text">Por Categoria</h2>
|
||||
<p className="text-sm text-gray">Distribuição de gastos</p>
|
||||
</div>
|
||||
<div className="h-60">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={categoryData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={4}
|
||||
dataKey="value"
|
||||
>
|
||||
{categoryData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
formatter={(value) => <span className="text-sm text-gray">{value}</span>}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats & Activity */}
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* Quick Stats */}
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-text mb-4">Resumo Rápido</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-card rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<span className="font-medium text-text">Demandas Abertas</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-text">{stats?.demandas_pendentes || 12}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-card rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<span className="font-medium text-text">OS Concluídas</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-text">{stats?.ordens_concluidas || 48}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-card rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center">
|
||||
<Building2 className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<span className="font-medium text-text">Fornecedores Ativos</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-text">{stats?.fornecedores_ativos || 15}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-gray-100 p-1 rounded-xl">
|
||||
<button onClick={() => setActiveTab('geral')}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-all ${activeTab === 'geral' ? 'bg-white text-primary shadow-sm' : 'text-gray hover:text-text'}`}>
|
||||
<TrendingUp className="w-4 h-4" /> Geral
|
||||
</button>
|
||||
<button onClick={() => setActiveTab('esg')}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-all ${activeTab === 'esg' ? 'bg-white shadow-sm' : 'text-gray hover:text-text'}`}
|
||||
style={activeTab === 'esg' ? { color: '#1A7A4C' } : {}}>
|
||||
<Leaf className="w-4 h-4" /> ESG
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="lg:col-span-2 card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-text">Atividade Recente</h2>
|
||||
<button className="text-sm text-primary hover:underline">Ver todas</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{recentActivities.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start gap-4 p-3 rounded-xl hover:bg-card transition-colors">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||
activity.status === 'success' ? 'bg-green-100' :
|
||||
activity.status === 'warning' ? 'bg-amber-100' :
|
||||
activity.status === 'info' ? 'bg-blue-100' :
|
||||
'bg-gray-100'
|
||||
}`}>
|
||||
{activity.status === 'success' ? <CheckCircle2 className="w-5 h-5 text-green-600" /> :
|
||||
activity.status === 'warning' ? <AlertCircle className="w-5 h-5 text-amber-600" /> :
|
||||
activity.status === 'info' ? <Building2 className="w-5 h-5 text-blue-600" /> :
|
||||
<Clock className="w-5 h-5 text-gray-600" />}
|
||||
{activeTab === 'geral' ? (
|
||||
<>
|
||||
{/* Stats Cards - clickable */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6">
|
||||
{statsCards.map((card, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="card group hover:shadow-lg transition-all duration-300 cursor-pointer"
|
||||
onClick={() => handleCardClick(card.statusKey, card.title)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${card.color} flex items-center justify-center text-white shadow-lg`}>
|
||||
{card.icon}
|
||||
</div>
|
||||
<ArrowUpRight className="w-4 h-4 text-gray-light opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-text">{activity.action}</p>
|
||||
<p className="text-sm text-gray truncate">{activity.description}</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-light whitespace-nowrap">{activity.time}</span>
|
||||
<p className="text-gray text-sm mb-1">{card.title}</p>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-text">{card.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 card">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text">Orçamento vs Realizado</h2>
|
||||
<p className="text-sm text-gray">Comparativo mensal</p>
|
||||
</div>
|
||||
{/* Filter Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-gray" />
|
||||
<select
|
||||
value={chartFilter}
|
||||
onChange={(e) => { setChartFilter(e.target.value as any); setChartFilterId('') }}
|
||||
className="input-field !py-1.5 !px-3 text-sm w-auto"
|
||||
>
|
||||
<option value="todos">Todos</option>
|
||||
<option value="centro_custo">Centro de Custo</option>
|
||||
<option value="categoria">Categoria</option>
|
||||
</select>
|
||||
{chartFilter !== 'todos' && (
|
||||
<select
|
||||
value={chartFilterId}
|
||||
onChange={(e) => setChartFilterId(e.target.value)}
|
||||
className="input-field !py-1.5 !px-3 text-sm w-auto max-w-[180px]"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{filterOptions.map(o => <option key={o.id} value={o.id}>{o.nome}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} barGap={8}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#fff', border: '1px solid #E0E0E0', borderRadius: '12px', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}
|
||||
formatter={(value: number) => formatCurrency(value)} />
|
||||
<Bar dataKey="planejado" fill="#1A237E" radius={[4, 4, 0, 0]} name="Planejado" />
|
||||
<Bar dataKey="realizado" fill="#E65100" radius={[4, 4, 0, 0]} name="Realizado" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-text">Por Categoria</h2>
|
||||
<p className="text-sm text-gray">Quantidade de demandas</p>
|
||||
</div>
|
||||
<div className="h-60">
|
||||
{categoryData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={categoryData} cx="50%" cy="50%" innerRadius={50} outerRadius={80} paddingAngle={4} dataKey="value" label={({ name, value }) => `${value}`}>
|
||||
{categoryData.map((entry: any, index: number) => (<Cell key={`cell-${index}`} fill={entry.color} />))}
|
||||
</Pie>
|
||||
<Legend verticalAlign="bottom" iconType="circle" iconSize={8} formatter={(value) => <span className="text-sm text-gray">{value}</span>} />
|
||||
<Tooltip formatter={(value: number, name: string) => [`${value} demandas`, name]} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray text-sm">Sem dados</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats & Activity */}
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-text mb-4">Resumo Rápido</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-card rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center"><FileText className="w-5 h-5 text-blue-600" /></div>
|
||||
<span className="font-medium text-text">Demandas Abertas</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-text">{stats?.demandas_abertas ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-card rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-amber-100 flex items-center justify-center"><AlertCircle className="w-5 h-5 text-amber-600" /></div>
|
||||
<span className="font-medium text-text">Pendentes</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-text">{stats?.pendentes ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-card rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-red-100 flex items-center justify-center"><AlertCircle className="w-5 h-5 text-red-600" /></div>
|
||||
<span className="font-medium text-text">Alertas</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-text">{stats?.alertas ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-2 card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-text">Atividade Recente</h2>
|
||||
</div>
|
||||
<div className="space-y-4 text-center py-8 text-gray text-sm">
|
||||
<Clock className="w-10 h-10 mx-auto text-gray-300" />
|
||||
<p>Atividades recentes aparecerão aqui.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* ESG Tab */
|
||||
esgLoading ? (
|
||||
<div className="flex items-center justify-center h-48"><Loader2 className="w-8 h-8 animate-spin" style={{ color: '#1A7A4C' }} /></div>
|
||||
) : esgData ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="card border-l-4" style={{ borderLeftColor: '#1A7A4C' }}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ backgroundColor: '#1A7A4C20' }}>
|
||||
<Leaf className="w-5 h-5" style={{ color: '#1A7A4C' }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray">Preventivas</p>
|
||||
<p className="text-2xl font-bold" style={{ color: '#1A7A4C' }}>{esgData.pct_preventivas}%</p>
|
||||
<p className="text-xs text-gray">{esgData.total_preventivas} demandas</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card border-l-4 border-l-amber-500">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-amber-50 flex items-center justify-center">
|
||||
<AlertCircle className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray">Corretivas</p>
|
||||
<p className="text-2xl font-bold text-amber-600">{esgData.pct_corretivas}%</p>
|
||||
<p className="text-xs text-gray">{esgData.total_corretivas} demandas</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card border-l-4 border-l-red-500">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-red-50 flex items-center justify-center">
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray">Emergenciais</p>
|
||||
<p className="text-2xl font-bold text-red-600">{esgData.pct_emergenciais}%</p>
|
||||
<p className="text-xs text-gray">{esgData.total_emergenciais} demandas</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card border-l-4" style={{ borderLeftColor: '#1A7A4C' }}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ backgroundColor: '#1A7A4C20' }}>
|
||||
<Shield className="w-5 h-5" style={{ color: '#1A7A4C' }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray">Fornecedores ESG+</p>
|
||||
<p className="text-2xl font-bold" style={{ color: '#1A7A4C' }}>{esgData.pct_fornecedores_esg_bom}%</p>
|
||||
<p className="text-xs text-gray">{esgData.fornecedores_esg_intermediario_avancado}/{esgData.total_fornecedores}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-text mb-4">Tipo de Manutenção</h2>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={manutencaoPieData} cx="50%" cy="50%" innerRadius={50} outerRadius={80} paddingAngle={4} dataKey="value">
|
||||
{manutencaoPieData.map((entry: any, index: number) => (<Cell key={`cell-${index}`} fill={entry.color} />))}
|
||||
</Pie>
|
||||
<Legend verticalAlign="bottom" iconType="circle" iconSize={8} formatter={(value) => <span className="text-sm text-gray">{value}</span>} />
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-text mb-4">Evolução Manutenção Preventiva</h2>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={esgData.evolucao_preventiva}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="preventivas" fill="#1A7A4C" radius={[4, 4, 0, 0]} name="Preventivas" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
<h2 className="text-lg font-semibold text-text">Demandas com Alto Impacto Ambiental</h2>
|
||||
<span className="badge badge-error">{esgData.demandas_alto_impacto_count}</span>
|
||||
</div>
|
||||
{esgData.demandas_alto_impacto.length === 0 ? (
|
||||
<p className="text-gray text-sm">Nenhuma demanda com alto impacto ambiental.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{esgData.demandas_alto_impacto.map((d: any) => (
|
||||
<div key={d.id} className="flex items-center gap-3 p-3 rounded-lg bg-red-50">
|
||||
<FileText className="w-5 h-5 text-red-500" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-text">{d.numero ? `#${d.numero} - ` : ''}{d.titulo}</p>
|
||||
</div>
|
||||
<span className="badge badge-error text-xs">{d.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{esgData.fornecedores_esg_basico > 0 && (
|
||||
<div className="card border-l-4 border-l-amber-500 bg-amber-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="w-6 h-6 text-amber-600" />
|
||||
<div>
|
||||
<p className="font-semibold text-text">Atenção: Fornecedores ESG Básico</p>
|
||||
<p className="text-sm text-gray">{esgData.fornecedores_esg_basico} fornecedor(es) com classificação ESG Básico.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="card text-center py-12">
|
||||
<Leaf className="w-16 h-16 mx-auto mb-4" style={{ color: '#1A7A4C20' }} />
|
||||
<p className="text-gray">Dados ESG não disponíveis.</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Drill-down Modal */}
|
||||
{drillModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4" style={{ zIndex: 9999 }}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-2xl shadow-2xl animate-fade-in max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between p-6 border-b border-border bg-gradient-to-r from-primary to-accent rounded-t-2xl">
|
||||
<h2 className="text-xl font-semibold text-white">{drillTitle}</h2>
|
||||
<button onClick={() => setDrillModal(false)} className="p-2 rounded-lg hover:bg-white/20 text-white">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
{drillLoading ? (
|
||||
<div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 animate-spin text-primary" /></div>
|
||||
) : drillData.length === 0 ? (
|
||||
<p className="text-center text-gray py-8">Nenhuma demanda encontrada.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{drillData.map((d: any) => (
|
||||
<div key={d.id} className="flex items-center gap-4 p-4 rounded-xl border border-border hover:bg-gray-50 transition-colors">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-text">{d.numero ? `#${d.numero} - ` : ''}{d.titulo}</p>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray">
|
||||
<span>{d.categoria}</span>
|
||||
<span>·</span>
|
||||
<span>{d.data ? new Date(d.data).toLocaleDateString('pt-BR') : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<span className="badge badge-info text-xs">{d.status}</span>
|
||||
{d.valor_estimado != null && (
|
||||
<p className="text-sm font-medium text-text mt-1">{formatCurrency(d.valor_estimado)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,44 +1,54 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
FileText,
|
||||
Search,
|
||||
Plus,
|
||||
Filter,
|
||||
Eye,
|
||||
Edit2,
|
||||
Trash2,
|
||||
X,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
ChevronDown
|
||||
FileText, Search, Plus, Eye, Edit2, Trash2, X, Loader2,
|
||||
AlertCircle, Clock, CheckCircle2, ChevronDown, PlayCircle,
|
||||
Upload, Download, Paperclip, File, ClipboardList, DollarSign
|
||||
} from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
import { Demanda } from '../types'
|
||||
import { Demanda, Subcategoria, DocumentoFile, OrdemServico } from '../types'
|
||||
import { useLookups } from '../hooks/useLookups'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const statusConfig: Record<string, { label: string; class: string; icon: React.ReactNode }> = {
|
||||
'aberta': { label: 'Aberta', class: 'badge-warning', icon: <Clock className="w-3 h-3" /> },
|
||||
'rascunho': { label: 'Rascunho', class: 'badge-neutral', icon: <Clock className="w-3 h-3" /> },
|
||||
'em_escopo': { label: 'Em Escopo', class: 'badge-info', icon: <AlertCircle className="w-3 h-3" /> },
|
||||
'em_cotacao': { label: 'Em Cotação', class: 'badge-info', icon: <AlertCircle className="w-3 h-3" /> },
|
||||
'propostas_recebidas': { label: 'Propostas Recebidas', class: 'badge-info', icon: <AlertCircle className="w-3 h-3" /> },
|
||||
'em_aprovacao': { label: 'Em Aprovação', class: 'badge-warning', icon: <Clock className="w-3 h-3" /> },
|
||||
'aprovada': { label: 'Aprovada', class: 'badge-success', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||
'em_execucao': { label: 'Em Execução', class: 'badge-info', icon: <PlayCircle className="w-3 h-3" /> },
|
||||
'concluida': { label: 'Concluída', class: 'badge-neutral', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||
'cancelada': { label: 'Cancelada', class: 'badge-error', icon: <X className="w-3 h-3" /> },
|
||||
'pendente': { label: 'Pendente', class: 'badge-warning', icon: <Clock className="w-3 h-3" /> },
|
||||
'em_analise': { label: 'Em Análise', class: 'badge-info', icon: <AlertCircle className="w-3 h-3" /> },
|
||||
'aprovada': { label: 'Aprovada', class: 'badge-success', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||
'rejeitada': { label: 'Rejeitada', class: 'badge-error', icon: <X className="w-3 h-3" /> },
|
||||
'concluida': { label: 'Concluída', class: 'badge-neutral', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||
}
|
||||
|
||||
const prioridadeConfig: Record<string, { label: string; class: string }> = {
|
||||
const criticidadeConfig: Record<string, { label: string; class: string }> = {
|
||||
'baixa': { label: 'Baixa', class: 'text-gray bg-gray-100' },
|
||||
'media': { label: 'Média', class: 'text-amber-700 bg-amber-100' },
|
||||
'alta': { label: 'Alta', class: 'text-red-700 bg-red-100' },
|
||||
'urgente': { label: 'Urgente', class: 'text-red-700 bg-red-200 animate-pulse' },
|
||||
'critica': { label: 'Crítica', class: 'text-red-700 bg-red-200 animate-pulse' },
|
||||
}
|
||||
|
||||
const mockDemandas: Demanda[] = [
|
||||
{ id: 1, titulo: 'Manutenção Ar Condicionado', descricao: 'Ar condicionado do bloco A não está funcionando', status: 'pendente', prioridade: 'alta', solicitante_id: 1, solicitante_nome: 'Maria Silva', data_criacao: '2024-01-15' },
|
||||
{ id: 2, titulo: 'Troca de Lâmpadas', descricao: 'Lâmpadas queimadas no corredor do 3º andar', status: 'em_analise', prioridade: 'media', solicitante_id: 2, solicitante_nome: 'João Santos', data_criacao: '2024-01-14' },
|
||||
{ id: 3, titulo: 'Vazamento Banheiro', descricao: 'Vazamento na torneira do banheiro masculino', status: 'aprovada', prioridade: 'urgente', solicitante_id: 3, solicitante_nome: 'Ana Oliveira', data_criacao: '2024-01-13' },
|
||||
{ id: 4, titulo: 'Pintura Sala Reunião', descricao: 'Paredes da sala de reunião precisam de pintura', status: 'concluida', prioridade: 'baixa', solicitante_id: 1, solicitante_nome: 'Maria Silva', data_criacao: '2024-01-10' },
|
||||
{ id: 5, titulo: 'Reparo Elevador', descricao: 'Elevador social com barulho estranho', status: 'pendente', prioridade: 'alta', solicitante_id: 4, solicitante_nome: 'Carlos Lima', data_criacao: '2024-01-16' },
|
||||
]
|
||||
const tipoDocIcon: Record<string, string> = {
|
||||
'planta': '📋',
|
||||
'foto': '📸',
|
||||
'laudo': '📄',
|
||||
'outro': '📎',
|
||||
}
|
||||
|
||||
const osStatusConfig: Record<string, { label: string; class: string }> = {
|
||||
'emitida': { label: 'Emitida', class: 'badge-warning' },
|
||||
'em_cotacao': { label: 'Em Cotação', class: 'badge-info' },
|
||||
'em_execucao': { label: 'Em Execução', class: 'badge-info' },
|
||||
'concluida': { label: 'Concluída', class: 'badge-success' },
|
||||
'cancelada': { label: 'Cancelada', class: 'badge-error' },
|
||||
}
|
||||
|
||||
const emptyForm = { titulo: '', descricao: '', criticidade: 'media', categoria_id: '', subcategoria_id: '', local_id: '', centro_custo_id: '', data_desejada: '', valor_estimado: '', impacto_ambiental_demanda: '', justificativa_manutencao_emergencial: '' }
|
||||
|
||||
export default function Demandas() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -46,265 +56,519 @@ export default function Demandas() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('todos')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [selectedDemanda, setSelectedDemanda] = useState<Demanda | null>(null)
|
||||
const [formData, setFormData] = useState({ titulo: '', descricao: '', prioridade: 'media' })
|
||||
const [showDetail, setShowDetail] = useState<Demanda | null>(null)
|
||||
const [editId, setEditId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState(emptyForm)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [subcategorias, setSubcategorias] = useState<Subcategoria[]>([])
|
||||
const [subcategoriaMap, setSubcategoriaMap] = useState<Record<string, string>>({})
|
||||
const [allSubcategorias, setAllSubcategorias] = useState<Subcategoria[]>([])
|
||||
// Documents
|
||||
const [detailDocs, setDetailDocs] = useState<DocumentoFile[]>([])
|
||||
const [docsLoading, setDocsLoading] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadTipo, setUploadTipo] = useState('outro')
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [docCounts, setDocCounts] = useState<Record<string, number>>({})
|
||||
// OS linked
|
||||
const [detailOS, setDetailOS] = useState<OrdemServico[]>([])
|
||||
const [osLoading, setOsLoading] = useState(false)
|
||||
const [showOS, setShowOS] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchDemandas()
|
||||
}, [])
|
||||
const { categoriaMap, centrosCustoMap, locaisMap, categorias, centrosCusto, locais, fornecedoresMap, loading: lookupsLoading } = useLookups()
|
||||
|
||||
useEffect(() => { fetchDemandas(); fetchAllSubcategorias() }, [])
|
||||
|
||||
const fetchDemandas = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/demandas')
|
||||
setDemandas(data.length > 0 ? data : mockDemandas)
|
||||
setDemandas(data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching demandas:', err)
|
||||
setDemandas(mockDemandas)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAllSubcategorias = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/subcategorias')
|
||||
setAllSubcategorias(data)
|
||||
const map: Record<string, string> = {}
|
||||
data.forEach((s: Subcategoria) => { map[s.id] = s.nome })
|
||||
setSubcategoriaMap(map)
|
||||
} catch (err) {
|
||||
console.error('Error fetching subcategorias:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSubcategorias = async (categoriaId: string) => {
|
||||
if (!categoriaId) { setSubcategorias([]); return }
|
||||
try {
|
||||
const { data } = await api.get(`/subcategorias?categoria_id=${categoriaId}`)
|
||||
setSubcategorias(data)
|
||||
} catch (err) {
|
||||
setSubcategorias([])
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDetailDocs = async (demandaId: string) => {
|
||||
setDocsLoading(true)
|
||||
try {
|
||||
const { data } = await api.get(`/demandas/${demandaId}/documentos`)
|
||||
setDetailDocs(data)
|
||||
} catch { setDetailDocs([]) }
|
||||
finally { setDocsLoading(false) }
|
||||
}
|
||||
|
||||
const fetchDetailOS = async (demandaId: string) => {
|
||||
setOsLoading(true)
|
||||
try {
|
||||
const { data } = await api.get(`/ordens-servico/by-demanda/${demandaId}`)
|
||||
setDetailOS(data)
|
||||
} catch { setDetailOS([]) }
|
||||
finally { setOsLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (demandas.length === 0) return
|
||||
const fetchCounts = async () => {
|
||||
const counts: Record<string, number> = {}
|
||||
await Promise.all(demandas.map(async (d) => {
|
||||
try {
|
||||
const { data } = await api.get(`/demandas/${d.id}/documentos`)
|
||||
counts[d.id] = data.length
|
||||
} catch { counts[d.id] = 0 }
|
||||
}))
|
||||
setDocCounts(counts)
|
||||
}
|
||||
fetchCounts()
|
||||
}, [demandas])
|
||||
|
||||
const filteredDemandas = demandas.filter(demanda => {
|
||||
const matchesSearch = demanda.titulo.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
demanda.descricao.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesSearch = (demanda.titulo || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(demanda.descricao || '').toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesStatus = filterStatus === 'todos' || demanda.status === filterStatus
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
const handleOpenModal = (demanda?: Demanda) => {
|
||||
if (demanda) {
|
||||
setSelectedDemanda(demanda)
|
||||
setFormData({ titulo: demanda.titulo, descricao: demanda.descricao, prioridade: demanda.prioridade })
|
||||
} else {
|
||||
setSelectedDemanda(null)
|
||||
setFormData({ titulo: '', descricao: '', prioridade: 'media' })
|
||||
}
|
||||
const openNew = () => { setEditId(null); setForm(emptyForm); setSubcategorias([]); setShowModal(true) }
|
||||
const openEdit = (d: Demanda) => {
|
||||
setEditId(d.id)
|
||||
setForm({
|
||||
titulo: d.titulo, descricao: d.descricao, criticidade: d.criticidade || d.prioridade || 'media',
|
||||
categoria_id: d.categoria_id || '', subcategoria_id: d.subcategoria_id || '',
|
||||
local_id: d.local_id || '', centro_custo_id: d.centro_custo_id || '',
|
||||
data_desejada: d.data_desejada || '',
|
||||
valor_estimado: d.valor_estimado ? String(d.valor_estimado) : '',
|
||||
impacto_ambiental_demanda: (d as any).impacto_ambiental_demanda || '',
|
||||
justificativa_manutencao_emergencial: (d as any).justificativa_manutencao_emergencial || '',
|
||||
})
|
||||
if (d.categoria_id) fetchSubcategorias(d.categoria_id)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowModal(false)
|
||||
setSelectedDemanda(null)
|
||||
setFormData({ titulo: '', descricao: '', prioridade: 'media' })
|
||||
const handleCategoriaChange = (categoriaId: string) => {
|
||||
setForm({ ...form, categoria_id: categoriaId, subcategoria_id: '' })
|
||||
fetchSubcategorias(categoriaId)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// Mock save
|
||||
if (selectedDemanda) {
|
||||
setDemandas(demandas.map(d => d.id === selectedDemanda.id ? { ...d, ...formData } : d))
|
||||
} else {
|
||||
const newDemanda: Demanda = {
|
||||
id: Date.now(),
|
||||
...formData,
|
||||
status: 'pendente',
|
||||
solicitante_id: 1,
|
||||
solicitante_nome: 'Usuário Atual',
|
||||
data_criacao: new Date().toISOString().split('T')[0]
|
||||
const handleSubmit = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload: any = { ...form }
|
||||
payload.valor_estimado = payload.valor_estimado ? Number(payload.valor_estimado) : null
|
||||
if (editId) {
|
||||
await api.patch(`/demandas/${editId}`, payload)
|
||||
} else {
|
||||
await api.post('/demandas', { ...payload, status: 'rascunho' })
|
||||
}
|
||||
setDemandas([newDemanda, ...demandas])
|
||||
setShowModal(false)
|
||||
fetchDemandas()
|
||||
} catch (err) {
|
||||
console.error('Error saving:', err)
|
||||
alert('Erro ao salvar demanda')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
handleCloseModal()
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('pt-BR')
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Tem certeza que deseja excluir esta demanda?')) return
|
||||
try {
|
||||
await api.delete(`/demandas/${id}`)
|
||||
fetchDemandas()
|
||||
} catch (err) { alert('Erro ao excluir') }
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
const handleChangeOSStatus = async (osId: string, status: string) => {
|
||||
try {
|
||||
await api.post(`/ordens-servico/${osId}/status`, { status })
|
||||
if (showDetail) fetchDetailOS(showDetail.id)
|
||||
} catch { alert('Erro ao alterar status da OS') }
|
||||
}
|
||||
|
||||
const handleFileUpload = async (files: FileList | null) => {
|
||||
if (!files || !showDetail) return
|
||||
setUploading(true)
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('tipo', uploadTipo)
|
||||
await api.post(`/demandas/${showDetail.id}/documentos`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
fetchDetailDocs(showDetail.id)
|
||||
setDocCounts(prev => ({ ...prev, [showDetail.id]: (prev[showDetail.id] || 0) + files.length }))
|
||||
} catch (err) {
|
||||
alert('Erro ao fazer upload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDocDownload = (doc: DocumentoFile) => {
|
||||
window.open(`/api/documentos/${doc.id}/download`, '_blank')
|
||||
}
|
||||
|
||||
const handleDocDelete = async (doc: DocumentoFile) => {
|
||||
if (!confirm(`Excluir ${doc.nome_arquivo}?`)) return
|
||||
try {
|
||||
await api.delete(`/documentos/${doc.id}`)
|
||||
if (showDetail) {
|
||||
fetchDetailDocs(showDetail.id)
|
||||
setDocCounts(prev => ({ ...prev, [showDetail.id]: Math.max(0, (prev[showDetail.id] || 1) - 1) }))
|
||||
}
|
||||
} catch { alert('Erro ao excluir documento') }
|
||||
}
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
handleFileUpload(e.dataTransfer.files)
|
||||
}, [showDetail, uploadTipo])
|
||||
|
||||
const formatDate = (dateStr?: string | null) => {
|
||||
if (!dateStr) return '-'
|
||||
try { return new Date(dateStr).toLocaleDateString('pt-BR') } catch { return '-' }
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const formatCurrency = (v: number | null | undefined) => {
|
||||
if (v == null) return '-'
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v)
|
||||
}
|
||||
|
||||
const getCriticidade = (d: Demanda) => d.criticidade || d.prioridade || ''
|
||||
const topStatuses = ['aberta', 'em_cotacao', 'em_aprovacao', 'em_execucao']
|
||||
|
||||
const openDetail = (d: Demanda) => {
|
||||
setShowDetail(d)
|
||||
setShowOS(false)
|
||||
fetchDetailDocs(d.id)
|
||||
fetchDetailOS(d.id)
|
||||
}
|
||||
|
||||
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">
|
||||
{/* 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">Demandas</h1>
|
||||
<p className="text-gray mt-1">Gerencie as solicitações de facilities</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleOpenModal()}
|
||||
className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Nova Demanda
|
||||
<button onClick={openNew} className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
|
||||
<Plus className="w-5 h-5" />Nova Demanda
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{Object.entries(statusConfig).slice(0, 4).map(([key, config]) => {
|
||||
{topStatuses.map((key) => {
|
||||
const config = statusConfig[key]
|
||||
if (!config) return null
|
||||
const count = demandas.filter(d => d.status === key).length
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilterStatus(filterStatus === key ? 'todos' : key)}
|
||||
className={`card text-left transition-all ${filterStatus === key ? 'ring-2 ring-primary' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{config.icon}
|
||||
<span className="text-sm text-gray">{config.label}</span>
|
||||
</div>
|
||||
<button key={key} onClick={() => setFilterStatus(filterStatus === key ? 'todos' : key)}
|
||||
className={`card text-left transition-all ${filterStatus === key ? 'ring-2 ring-primary' : ''}`}>
|
||||
<div className="flex items-center gap-2 mb-2">{config.icon}<span className="text-sm text-gray">{config.label}</span></div>
|
||||
<p className="text-2xl font-bold text-text">{count}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card">
|
||||
<div className="flex flex-col sm: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 demandas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="input-field pl-12"
|
||||
/>
|
||||
<input type="text" placeholder="Buscar demandas..." value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)} className="input-field pl-12" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="input-field appearance-none pr-10 w-full sm:w-48"
|
||||
>
|
||||
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="input-field appearance-none pr-10 w-full sm:w-48">
|
||||
<option value="todos">Todos os status</option>
|
||||
{Object.entries(statusConfig).map(([key, config]) => (
|
||||
<option key={key} value={key}>{config.label}</option>
|
||||
))}
|
||||
{Object.entries(statusConfig).map(([key, config]) => (<option key={key} value={key}>{config.label}</option>))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demandas List */}
|
||||
<div className="grid gap-4">
|
||||
{filteredDemandas.map((demanda) => (
|
||||
<div key={demanda.id} className="card hover:shadow-md transition-all">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-text">{demanda.titulo}</h3>
|
||||
<span className={`${statusConfig[demanda.status]?.class || 'badge-neutral'} flex items-center gap-1`}>
|
||||
{statusConfig[demanda.status]?.icon}
|
||||
{statusConfig[demanda.status]?.label || demanda.status}
|
||||
</span>
|
||||
<span className={`badge ${prioridadeConfig[demanda.prioridade]?.class || ''}`}>
|
||||
{prioridadeConfig[demanda.prioridade]?.label || demanda.prioridade}
|
||||
</span>
|
||||
{filteredDemandas.map((demanda) => {
|
||||
const crit = getCriticidade(demanda)
|
||||
const docsCount = docCounts[demanda.id] || 0
|
||||
return (
|
||||
<div key={demanda.id} className="card hover:shadow-md transition-all">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<p className="text-gray text-sm mt-1 line-clamp-1">{demanda.descricao}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-light">
|
||||
<span>Solicitante: {demanda.solicitante_nome}</span>
|
||||
<span>•</span>
|
||||
<span>{formatDate(demanda.data_criacao)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-text">{demanda.numero ? `#${demanda.numero} - ` : ''}{demanda.titulo}</h3>
|
||||
<span className={`${statusConfig[demanda.status]?.class || 'badge-neutral'} flex items-center gap-1`}>
|
||||
{statusConfig[demanda.status]?.icon}{statusConfig[demanda.status]?.label || demanda.status}
|
||||
</span>
|
||||
{crit && <span className={`badge ${criticidadeConfig[crit]?.class || ''}`}>{criticidadeConfig[crit]?.label || crit}</span>}
|
||||
</div>
|
||||
<p className="text-gray text-sm mt-1 line-clamp-1">{demanda.descricao}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-light flex-wrap">
|
||||
{demanda.categoria_id && <span>Cat: {categoriaMap[demanda.categoria_id] || '-'}</span>}
|
||||
{demanda.subcategoria_id && <span>Sub: {subcategoriaMap[demanda.subcategoria_id] || '-'}</span>}
|
||||
{demanda.local_id && <span>Local: {locaisMap[demanda.local_id] || '-'}</span>}
|
||||
{demanda.centro_custo_id && <span>CC: {centrosCustoMap[demanda.centro_custo_id] || '-'}</span>}
|
||||
{demanda.valor_estimado && <span className="flex items-center gap-1"><DollarSign className="w-3 h-3" />{formatCurrency(demanda.valor_estimado)}</span>}
|
||||
{demanda.data_desejada && <span>🗓️ {formatDate(demanda.data_desejada)}</span>}
|
||||
<span>{formatDate(demanda.created_at || demanda.data_criacao)}</span>
|
||||
{docsCount > 0 && <span className="flex items-center gap-1"><Paperclip className="w-3 h-3" />{docsCount}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:flex-shrink-0">
|
||||
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors">
|
||||
<Eye className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenModal(demanda)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors"
|
||||
>
|
||||
<Edit2 className="w-5 h-5" />
|
||||
</button>
|
||||
<button className="p-2 rounded-lg hover:bg-red-50 text-gray hover:text-red-500 transition-colors">
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 sm:flex-shrink-0">
|
||||
<button onClick={() => openDetail(demanda)} className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors"><Eye className="w-5 h-5" /></button>
|
||||
<button onClick={() => openEdit(demanda)} className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors"><Edit2 className="w-5 h-5" /></button>
|
||||
<button onClick={() => handleDelete(demanda.id)} className="p-2 rounded-lg hover:bg-red-50 text-gray hover:text-red-500 transition-colors"><Trash2 className="w-5 h-5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
)
|
||||
})}
|
||||
{filteredDemandas.length === 0 && (
|
||||
<div className="card text-center py-12">
|
||||
<FileText className="w-12 h-12 text-gray-light mx-auto mb-4" />
|
||||
<p className="text-gray">Nenhuma demanda encontrada</p>
|
||||
</div>
|
||||
<div className="card text-center py-12"><FileText className="w-12 h-12 text-gray-light mx-auto mb-4" /><p className="text-gray">Nenhuma demanda encontrada</p></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl animate-fade-in">
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<h2 className="text-xl font-semibold text-text">
|
||||
{selectedDemanda ? 'Editar Demanda' : 'Nova Demanda'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleCloseModal}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 text-gray"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Título</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.titulo}
|
||||
onChange={(e) => setFormData({ ...formData, titulo: e.target.value })}
|
||||
className="input-field"
|
||||
placeholder="Título da demanda"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Descrição</label>
|
||||
<textarea
|
||||
value={formData.descricao}
|
||||
onChange={(e) => setFormData({ ...formData, descricao: e.target.value })}
|
||||
className="input-field resize-none"
|
||||
rows={4}
|
||||
placeholder="Descreva a demanda em detalhes..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Prioridade</label>
|
||||
<select
|
||||
value={formData.prioridade}
|
||||
onChange={(e) => setFormData({ ...formData, prioridade: e.target.value })}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="baixa">Baixa</option>
|
||||
<option value="media">Média</option>
|
||||
<option value="alta">Alta</option>
|
||||
<option value="urgente">Urgente</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button type="button" onClick={handleCloseModal} className="btn-ghost flex-1">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn-primary flex-1">
|
||||
{selectedDemanda ? 'Salvar' : 'Criar Demanda'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal open={showModal} onClose={() => setShowModal(false)} title={editId ? 'Editar Demanda' : 'Nova Demanda'} onSubmit={handleSubmit} submitLabel={editId ? 'Salvar' : 'Criar Demanda'} loading={saving}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-text mb-2">Título</label>
|
||||
<input type="text" value={form.titulo} onChange={(e) => setForm({ ...form, titulo: e.target.value })} className="input-field" placeholder="Título da demanda" required />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-text mb-2">Descrição</label>
|
||||
<textarea value={form.descricao} onChange={(e) => setForm({ ...form, descricao: e.target.value })} className="input-field resize-none" rows={3} placeholder="Descreva a demanda..." required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Criticidade</label>
|
||||
<select value={form.criticidade} onChange={(e) => setForm({ ...form, criticidade: e.target.value })} className="input-field">
|
||||
<option value="baixa">Baixa</option><option value="media">Média</option><option value="alta">Alta</option><option value="urgente">Urgente</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Categoria</label>
|
||||
<select value={form.categoria_id} onChange={(e) => handleCategoriaChange(e.target.value)} className="input-field">
|
||||
<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">Subcategoria</label>
|
||||
<select value={form.subcategoria_id} onChange={(e) => setForm({ ...form, subcategoria_id: e.target.value })} className="input-field" disabled={!form.categoria_id}>
|
||||
<option value="">Selecione...</option>
|
||||
{subcategorias.map(s => <option key={s.id} value={s.id}>{s.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Data Desejada</label>
|
||||
<input type="date" value={form.data_desejada} onChange={(e) => setForm({ ...form, data_desejada: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Local</label>
|
||||
<select value={form.local_id} onChange={(e) => setForm({ ...form, local_id: e.target.value })} className="input-field">
|
||||
<option value="">Selecione...</option>
|
||||
{locais.map(l => <option key={l.id} value={l.id}>{l.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">
|
||||
<option value="">Selecione...</option>
|
||||
{centrosCusto.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">Valor Estimado (R$)</label>
|
||||
<input type="number" step="0.01" value={form.valor_estimado} onChange={(e) => setForm({ ...form, valor_estimado: e.target.value })} className="input-field" placeholder="0,00" />
|
||||
</div>
|
||||
<div className="sm:col-span-2 border-t border-border pt-3 mt-2">
|
||||
<p className="text-sm font-semibold mb-3" style={{ color: '#1A7A4C' }}>🌿 Campos ESG</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Impacto Ambiental</label>
|
||||
<select value={form.impacto_ambiental_demanda} onChange={(e) => setForm({ ...form, impacto_ambiental_demanda: e.target.value })} className="input-field">
|
||||
<option value="">Herdar da Categoria</option>
|
||||
<option value="Baixo">Baixo</option>
|
||||
<option value="Médio">Médio</option>
|
||||
<option value="Alto">Alto</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Justificativa Emergencial</label>
|
||||
<textarea value={form.justificativa_manutencao_emergencial} onChange={(e) => setForm({ ...form, justificativa_manutencao_emergencial: e.target.value })} className="input-field resize-none" rows={2} placeholder="Obrigatório para manutenção emergencial..." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<Modal open={!!showDetail} onClose={() => { setShowDetail(null); setDetailDocs([]); setDetailOS([]) }} title={`Demanda #${showDetail?.numero || ''}`}>
|
||||
{showDetail && (
|
||||
<>
|
||||
<div><strong>Título:</strong> {showDetail.titulo}</div>
|
||||
<div><strong>Descrição:</strong> {showDetail.descricao}</div>
|
||||
<div><strong>Status:</strong> {statusConfig[showDetail.status]?.label || showDetail.status}</div>
|
||||
<div><strong>Criticidade:</strong> {criticidadeConfig[getCriticidade(showDetail)]?.label || getCriticidade(showDetail)}</div>
|
||||
<div><strong>Categoria:</strong> {categoriaMap[showDetail.categoria_id] || '-'}</div>
|
||||
<div><strong>Subcategoria:</strong> {subcategoriaMap[showDetail.subcategoria_id] || '-'}</div>
|
||||
<div><strong>Local:</strong> {locaisMap[showDetail.local_id] || '-'}</div>
|
||||
<div><strong>Centro de Custo:</strong> {centrosCustoMap[showDetail.centro_custo_id] || '-'}</div>
|
||||
<div><strong>Data Desejada:</strong> {formatDate(showDetail.data_desejada)}</div>
|
||||
<div><strong>Valor Estimado:</strong> {formatCurrency(showDetail.valor_estimado)}</div>
|
||||
<div><strong>Data de Criação:</strong> {formatDate(showDetail.created_at)}</div>
|
||||
{/* ESG Info */}
|
||||
{((showDetail as any).impacto_ambiental_demanda || (showDetail as any).justificativa_manutencao_emergencial) && (
|
||||
<div className="border-t border-border pt-3 mt-3">
|
||||
<p className="text-sm font-semibold mb-2" style={{ color: '#1A7A4C' }}>🌿 ESG</p>
|
||||
{(showDetail as any).impacto_ambiental_demanda && (
|
||||
<div><strong>Impacto Ambiental:</strong>{' '}
|
||||
<span className={`badge ${(showDetail as any).impacto_ambiental_demanda === 'Alto' ? 'badge-error' : (showDetail as any).impacto_ambiental_demanda === 'Médio' ? 'badge-warning' : 'badge-success'}`}>
|
||||
{(showDetail as any).impacto_ambiental_demanda}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(showDetail as any).justificativa_manutencao_emergencial && (
|
||||
<div className="mt-2"><strong>Justificativa Emergencial:</strong> {(showDetail as any).justificativa_manutencao_emergencial}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ordens de Serviço Section */}
|
||||
<div className="border-t border-border pt-4 mt-4">
|
||||
<button onClick={() => setShowOS(!showOS)} className="font-semibold text-text mb-3 flex items-center gap-2 hover:text-primary transition-colors">
|
||||
<ClipboardList className="w-4 h-4" /> Ordens de Serviço ({detailOS.length})
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${showOS ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{showOS && (
|
||||
osLoading ? (
|
||||
<div className="flex justify-center py-4"><Loader2 className="w-5 h-5 animate-spin text-gray" /></div>
|
||||
) : detailOS.length === 0 ? (
|
||||
<p className="text-sm text-gray text-center py-2">Nenhuma OS vinculada</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{detailOS.map(os => (
|
||||
<div key={os.id} className="flex items-center gap-3 p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<ClipboardList className="w-5 h-5 text-secondary" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text">OS-{String(os.numero).padStart(4, '0')}</p>
|
||||
<p className="text-xs text-gray">{fornecedoresMap[os.fornecedor_id] || '-'} · {formatCurrency(os.valor)}</p>
|
||||
</div>
|
||||
<span className={`${osStatusConfig[os.status]?.class || 'badge-neutral'} text-xs`}>
|
||||
{osStatusConfig[os.status]?.label || os.status}
|
||||
</span>
|
||||
{os.status === 'emitida' && (
|
||||
<button onClick={() => handleChangeOSStatus(os.id, 'em_cotacao')} className="text-xs btn-primary !py-1 !px-2">
|
||||
→ Em Cotação
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Documents Section */}
|
||||
<div className="border-t border-border pt-4 mt-4">
|
||||
<h3 className="font-semibold text-text mb-3 flex items-center gap-2">
|
||||
<Paperclip className="w-4 h-4" /> Documentos
|
||||
</h3>
|
||||
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-xl p-4 text-center transition-colors mb-3 ${dragOver ? 'border-primary bg-primary/5' : 'border-gray-300'}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-3 mb-2">
|
||||
<select value={uploadTipo} onChange={(e) => setUploadTipo(e.target.value)} className="input-field w-auto text-sm py-1">
|
||||
<option value="planta">📋 Planta</option>
|
||||
<option value="foto">📸 Foto</option>
|
||||
<option value="laudo">📄 Laudo</option>
|
||||
<option value="outro">📎 Outro</option>
|
||||
</select>
|
||||
<button onClick={() => fileInputRef.current?.click()} disabled={uploading}
|
||||
className="btn-primary text-sm py-1 px-3 flex items-center gap-1">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
{uploading ? 'Enviando...' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray">Arraste arquivos aqui ou clique no botão</p>
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden"
|
||||
onChange={(e) => handleFileUpload(e.target.files)} />
|
||||
</div>
|
||||
|
||||
{docsLoading ? (
|
||||
<div className="flex justify-center py-4"><Loader2 className="w-5 h-5 animate-spin text-gray" /></div>
|
||||
) : detailDocs.length === 0 ? (
|
||||
<p className="text-sm text-gray text-center py-2">Nenhum documento anexado</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{detailDocs.map(doc => (
|
||||
<div key={doc.id} className="flex items-center gap-3 p-2 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<span className="text-lg">{tipoDocIcon[doc.tipo] || '📎'}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text truncate">{doc.nome_arquivo}</p>
|
||||
<p className="text-xs text-gray">{formatFileSize(doc.tamanho)} · {formatDate(doc.created_at)}</p>
|
||||
</div>
|
||||
<button onClick={() => handleDocDownload(doc)} className="p-1 rounded hover:bg-white text-gray hover:text-primary">
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDocDelete(doc)} className="p-1 rounded hover:bg-white text-gray hover:text-red-500">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
222
frontend/src/pages/ESG.tsx
Normal file
222
frontend/src/pages/ESG.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Leaf, Droplets, Trash2, Wind, Loader2, TrendingDown, TrendingUp, Target } from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
|
||||
interface ESGMetric {
|
||||
tipo: string
|
||||
valor: number
|
||||
unidade: string
|
||||
mes: number
|
||||
ano: number
|
||||
}
|
||||
|
||||
interface ESGGoal {
|
||||
id: number
|
||||
tipo: string
|
||||
meta: number
|
||||
atual: number
|
||||
unidade: string
|
||||
ano: number
|
||||
}
|
||||
|
||||
interface ResumoItem {
|
||||
tipo: string
|
||||
unidade: string
|
||||
total: number
|
||||
media: number
|
||||
registros: string
|
||||
}
|
||||
|
||||
interface MetricaPorMes {
|
||||
tipo: string
|
||||
mes: number
|
||||
total: number
|
||||
}
|
||||
|
||||
interface ESGMeta {
|
||||
id: string
|
||||
tipo: string
|
||||
meta_valor: number
|
||||
periodo_ano: number
|
||||
local_id: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface ESGDashboard {
|
||||
ano: number
|
||||
resumo: ResumoItem[]
|
||||
metricas_por_mes: MetricaPorMes[]
|
||||
metas: ESGMeta[]
|
||||
}
|
||||
|
||||
export default function ESG() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<ESGDashboard | null>(null)
|
||||
const [ano, setAno] = useState(new Date().getFullYear())
|
||||
const [tipo, setTipo] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [ano, tipo])
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: any = { ano }
|
||||
if (tipo) params.tipo = tipo
|
||||
const { data } = await api.get('/esg/dashboard', { params })
|
||||
setData(data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching ESG data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getResumoValue = (tipo: string) => {
|
||||
if (!data?.resumo || !Array.isArray(data.resumo)) return 0
|
||||
const item = data.resumo.find(r => r.tipo === tipo)
|
||||
return item?.total ?? 0
|
||||
}
|
||||
|
||||
const summaryCards = [
|
||||
{ title: 'Energia Total', value: getResumoValue('energia'), unit: 'kWh', icon: <Wind className="w-6 h-6" />, color: 'from-yellow-500 to-orange-500', bg: 'bg-yellow-100', text: 'text-yellow-600' },
|
||||
{ title: 'Água Total', value: getResumoValue('agua'), unit: 'm³', icon: <Droplets className="w-6 h-6" />, color: 'from-blue-500 to-cyan-500', bg: 'bg-blue-100', text: 'text-blue-600' },
|
||||
{ title: 'Resíduos Total', value: getResumoValue('residuos'), unit: 'kg', icon: <Trash2 className="w-6 h-6" />, color: 'from-green-500 to-emerald-500', bg: 'bg-green-100', text: 'text-green-600' },
|
||||
{ title: 'Emissões CO₂', value: getResumoValue('emissoes'), unit: 'tCO₂', icon: <Wind className="w-6 h-6" />, color: 'from-gray-500 to-gray-700', bg: 'bg-gray-100', text: 'text-gray-600' },
|
||||
]
|
||||
|
||||
const monthNames = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez']
|
||||
|
||||
const getMaxValue = (metrics: MetricaPorMes[], tipoFilter: string) => {
|
||||
const filtered = metrics.filter(m => m.tipo === tipoFilter)
|
||||
return Math.max(...filtered.map(m => m.total), 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">
|
||||
<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 flex items-center gap-2">
|
||||
<Leaf className="w-8 h-8 text-green-600" /> ESG & Sustentabilidade
|
||||
</h1>
|
||||
<p className="text-gray mt-1">Monitoramento de indicadores ambientais e metas de sustentabilidade.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select value={tipo} onChange={e => setTipo(e.target.value)} className="input-field text-sm">
|
||||
<option value="">Todos os tipos</option>
|
||||
<option value="energia">Energia</option>
|
||||
<option value="agua">Água</option>
|
||||
<option value="residuos">Resíduos</option>
|
||||
<option value="emissoes">Emissões</option>
|
||||
</select>
|
||||
<select value={ano} onChange={e => setAno(Number(e.target.value))} className="input-field text-sm">
|
||||
{[2024, 2025, 2026].map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6">
|
||||
{summaryCards.map((card, i) => (
|
||||
<div key={i} className="card group hover:shadow-lg transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${card.color} flex items-center justify-center text-white shadow-lg`}>
|
||||
{card.icon}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray text-sm mb-1">{card.title}</p>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-text">
|
||||
{typeof card.value === 'number' ? card.value.toLocaleString('pt-BR') : card.value}
|
||||
</p>
|
||||
<p className="text-xs text-gray mt-1">{card.unit}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Monthly Metrics */}
|
||||
{data?.metricas_por_mes && data.metricas_por_mes.length > 0 && (
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-text mb-4">Métricas Mensais</h2>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{['energia', 'agua', 'residuos', 'emissoes'].map(t => {
|
||||
const metrics = (data?.metricas_por_mes || []).filter(m => m.tipo === t)
|
||||
if (metrics.length === 0) return null
|
||||
const max = getMaxValue(data?.metricas_por_mes || [], t)
|
||||
return (
|
||||
<div key={t} className="space-y-2">
|
||||
<h3 className="font-medium text-text capitalize">{t === 'agua' ? 'Água' : t === 'emissoes' ? 'Emissões' : t === 'residuos' ? 'Resíduos' : t}</h3>
|
||||
{metrics.sort((a, b) => a.mes - b.mes).map(m => (
|
||||
<div key={m.mes} className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray w-8">{monthNames[m.mes - 1]}</span>
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-4 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-primary to-accent transition-all duration-500"
|
||||
style={{ width: `${(m.total / max) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray w-16 text-right">{m.total.toLocaleString('pt-BR')} {summaryCards.find(c => c.title.toLowerCase().includes(t))?.unit || ""}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Goals */}
|
||||
{data?.metas && data.metas.length > 0 && (
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-text mb-4 flex items-center gap-2">
|
||||
<Target className="w-5 h-5 text-primary" /> Metas de Sustentabilidade
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.metas.map((goal: any) => {
|
||||
const metaVal = goal.meta_valor ?? goal.meta ?? 0
|
||||
const atualVal = getResumoValue(goal.tipo)
|
||||
const pct = metaVal > 0 ? Math.min((atualVal / metaVal) * 100, 100) : 0
|
||||
const isGood = atualVal <= metaVal
|
||||
const unit = summaryCards.find(c => c.title.toLowerCase().includes(goal.tipo))?.unit || ''
|
||||
return (
|
||||
<div key={goal.id} className="p-4 bg-gray-50 rounded-xl space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-text capitalize">{goal.tipo === 'agua' ? 'Água' : goal.tipo === 'emissoes' ? 'Emissões' : goal.tipo === 'residuos' ? 'Resíduos' : goal.tipo}</span>
|
||||
{isGood ? <TrendingDown className="w-4 h-4 text-green-500" /> : <TrendingUp className="w-4 h-4 text-red-500" />}
|
||||
</div>
|
||||
<div className="bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${isGood ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray">
|
||||
<span>Atual: {atualVal.toLocaleString('pt-BR')} {unit}</span>
|
||||
<span>Meta: {metaVal.toLocaleString('pt-BR')} {unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!data?.metricas_por_mes?.length && !data?.metas?.length && (
|
||||
<div className="card text-center py-12">
|
||||
<Leaf className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray">Nenhum dado ESG encontrado</h3>
|
||||
<p className="text-sm text-gray-light mt-1">Registre métricas ambientais para visualizar o dashboard.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Building2,
|
||||
Search,
|
||||
Plus,
|
||||
Eye,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
X
|
||||
Building2, Search, Plus, Eye, Edit2, Trash2, Loader2,
|
||||
Mail, Phone, MapPin, Star, CheckCircle2, XCircle, X
|
||||
} from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
import { Fornecedor } from '../types'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const mockFornecedores: Fornecedor[] = [
|
||||
{ id: 1, razao_social: 'Tech Solutions Ltda', cnpj: '12.345.678/0001-90', email: 'contato@techsolutions.com', telefone: '(11) 99999-1234', endereco: 'Av. Paulista, 1000 - São Paulo/SP', ativo: true, especialidades: ['Ar Condicionado', 'Elétrica'], avaliacao: 4.5 },
|
||||
{ id: 2, razao_social: 'EletroFix Serviços', cnpj: '23.456.789/0001-01', email: 'eletrofix@email.com', telefone: '(11) 98888-5678', endereco: 'Rua Augusta, 500 - São Paulo/SP', ativo: true, especialidades: ['Elétrica', 'Iluminação'], avaliacao: 4.8 },
|
||||
{ id: 3, razao_social: 'HidroServ Manutenção', cnpj: '34.567.890/0001-12', email: 'hidroserv@email.com', telefone: '(11) 97777-9012', endereco: 'Rua Oscar Freire, 200 - São Paulo/SP', ativo: true, especialidades: ['Hidráulica', 'Encanamento'], avaliacao: 4.2 },
|
||||
{ id: 4, razao_social: 'ElevaTech Elevadores', cnpj: '45.678.901/0001-23', email: 'elevatech@email.com', telefone: '(11) 96666-3456', endereco: 'Av. Brasil, 1500 - São Paulo/SP', ativo: false, especialidades: ['Elevadores'], avaliacao: 3.9 },
|
||||
{ id: 5, razao_social: 'CleanPro Limpeza', cnpj: '56.789.012/0001-34', email: 'cleanpro@email.com', telefone: '(11) 95555-7890', endereco: 'Rua Consolação, 800 - São Paulo/SP', ativo: true, especialidades: ['Limpeza', 'Conservação'], avaliacao: 4.6 },
|
||||
]
|
||||
const emptyForm = { razao_social: '', nome_fantasia: '', cpf_cnpj: '', email: '', telefone: '', tipo_pessoa: 'PJ', nome_contato: '', possui_politica_ambiental: false, possui_politica_sst: false, declara_uso_epi: false, equipe_treinada: false, classificacao_esg: '' }
|
||||
|
||||
export default function Fornecedores() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -32,108 +15,114 @@ export default function Fornecedores() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [filterAtivo, setFilterAtivo] = useState('todos')
|
||||
const [selectedFornecedor, setSelectedFornecedor] = useState<Fornecedor | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [editId, setEditId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState(emptyForm)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchFornecedores()
|
||||
}, [])
|
||||
useEffect(() => { fetchFornecedores() }, [])
|
||||
|
||||
const fetchFornecedores = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/fornecedores')
|
||||
setFornecedores(data.length > 0 ? data : mockFornecedores)
|
||||
setFornecedores(data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching fornecedores:', err)
|
||||
setFornecedores(mockFornecedores)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredFornecedores = fornecedores.filter(forn => {
|
||||
const matchesSearch = forn.razao_social.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
forn.cnpj.includes(searchTerm)
|
||||
const matchesAtivo = filterAtivo === 'todos' ||
|
||||
(filterAtivo === 'ativos' && forn.ativo) ||
|
||||
(filterAtivo === 'inativos' && !forn.ativo)
|
||||
const nome = forn.razao_social || forn.nome_fantasia || ''
|
||||
const doc = forn.cpf_cnpj || forn.cnpj || ''
|
||||
const matchesSearch = nome.toLowerCase().includes(searchTerm.toLowerCase()) || doc.includes(searchTerm)
|
||||
const matchesAtivo = filterAtivo === 'todos' || (filterAtivo === 'ativos' && forn.ativo) || (filterAtivo === 'inativos' && !forn.ativo)
|
||||
return matchesSearch && matchesAtivo
|
||||
})
|
||||
|
||||
const renderStars = (rating: number = 0) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-300'}`}
|
||||
/>
|
||||
))}
|
||||
<span className="text-sm text-gray ml-1">{rating?.toFixed(1)}</span>
|
||||
</div>
|
||||
)
|
||||
const openNew = () => { setEditId(null); setForm(emptyForm); setShowCreateModal(true) }
|
||||
const openEdit = (f: Fornecedor) => {
|
||||
setEditId(f.id)
|
||||
setForm({ razao_social: f.razao_social || '', nome_fantasia: f.nome_fantasia || '', cpf_cnpj: f.cpf_cnpj || f.cnpj || '', email: f.email || '', telefone: f.telefone || '', tipo_pessoa: f.tipo_pessoa || 'PJ', nome_contato: (f as any).nome_contato || '', possui_politica_ambiental: f.possui_politica_ambiental || false, possui_politica_sst: f.possui_politica_sst || false, declara_uso_epi: f.declara_uso_epi || false, equipe_treinada: f.equipe_treinada || false, classificacao_esg: f.classificacao_esg || '' })
|
||||
setShowCreateModal(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editId) {
|
||||
await api.patch(`/fornecedores/${editId}`, form)
|
||||
} else {
|
||||
await api.post('/fornecedores', form)
|
||||
}
|
||||
setShowCreateModal(false)
|
||||
fetchFornecedores()
|
||||
} catch (err) {
|
||||
console.error('Error saving:', err)
|
||||
alert('Erro ao salvar fornecedor')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Tem certeza que deseja excluir este fornecedor?')) return
|
||||
try {
|
||||
await api.delete(`/fornecedores/${id}`);
|
||||
fetchFornecedores()
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || 'Erro ao excluir'
|
||||
alert(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const getEspecialidades = (f: Fornecedor) => f.categorias_atendidas || f.especialidades || []
|
||||
const getRating = (f: Fornecedor) => f.rating ?? f.avaliacao ?? 0
|
||||
const getDoc = (f: Fornecedor) => f.cpf_cnpj || f.cnpj || ''
|
||||
|
||||
const renderStars = (rating: number = 0) => (
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star key={star} className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-300'}`} />
|
||||
))}
|
||||
<span className="text-sm text-gray ml-1">{rating?.toFixed(1)}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
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="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">Fornecedores</h1>
|
||||
<p className="text-gray mt-1">Gerencie os fornecedores parceiros</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 Fornecedor
|
||||
<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 Fornecedor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="card">
|
||||
<p className="text-gray text-sm">Total</p>
|
||||
<p className="text-2xl font-bold text-text">{fornecedores.length}</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="text-gray text-sm">Ativos</p>
|
||||
<p className="text-2xl font-bold text-green-600">{fornecedores.filter(f => f.ativo).length}</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="text-gray text-sm">Inativos</p>
|
||||
<p className="text-2xl font-bold text-gray">{fornecedores.filter(f => !f.ativo).length}</p>
|
||||
</div>
|
||||
<div className="card"><p className="text-gray text-sm">Total</p><p className="text-2xl font-bold text-text">{fornecedores.length}</p></div>
|
||||
<div className="card"><p className="text-gray text-sm">Ativos</p><p className="text-2xl font-bold text-green-600">{fornecedores.filter(f => f.ativo).length}</p></div>
|
||||
<div className="card"><p className="text-gray text-sm">Inativos</p><p className="text-2xl font-bold text-gray">{fornecedores.filter(f => !f.ativo).length}</p></div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card">
|
||||
<div className="flex flex-col sm: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 nome ou CNPJ..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="input-field pl-12"
|
||||
/>
|
||||
<input type="text" placeholder="Buscar por nome ou CNPJ..." value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)} className="input-field pl-12" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{['todos', 'ativos', 'inativos'].map((filter) => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setFilterAtivo(filter)}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||
filterAtivo === filter
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-100 text-gray hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<button key={filter} onClick={() => setFilterAtivo(filter)}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${filterAtivo === filter ? 'bg-primary text-white' : 'bg-gray-100 text-gray hover:bg-gray-200'}`}>
|
||||
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
@@ -141,67 +130,42 @@ export default function Fornecedores() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fornecedores Grid */}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredFornecedores.map((fornecedor) => (
|
||||
<div
|
||||
key={fornecedor.id}
|
||||
className="card hover:shadow-lg transition-all cursor-pointer"
|
||||
onClick={() => setSelectedFornecedor(fornecedor)}
|
||||
>
|
||||
<div key={fornecedor.id} className="card hover:shadow-lg transition-all cursor-pointer" onClick={() => setSelectedFornecedor(fornecedor)}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-secondary/10 to-secondary/20 flex items-center justify-center">
|
||||
<Building2 className="w-6 h-6 text-secondary" />
|
||||
</div>
|
||||
<span className={`badge ${fornecedor.ativo ? 'badge-success' : 'badge-neutral'}`}>
|
||||
{fornecedor.ativo ? 'Ativo' : 'Inativo'}
|
||||
</span>
|
||||
<span className={`badge ${fornecedor.ativo ? 'badge-success' : 'badge-neutral'}`}>{fornecedor.ativo ? 'Ativo' : 'Inativo'}</span>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-text mb-1">{fornecedor.razao_social}</h3>
|
||||
<p className="text-sm text-gray mb-3">{fornecedor.cnpj}</p>
|
||||
|
||||
{renderStars(fornecedor.avaliacao)}
|
||||
|
||||
{fornecedor.especialidades && fornecedor.especialidades.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{fornecedor.especialidades.slice(0, 2).map((esp, i) => (
|
||||
<span key={i} className="text-xs px-2 py-1 bg-primary/10 text-primary rounded-lg">
|
||||
{esp}
|
||||
</span>
|
||||
))}
|
||||
{fornecedor.especialidades.length > 2 && (
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 text-gray rounded-lg">
|
||||
+{fornecedor.especialidades.length - 2}
|
||||
</span>
|
||||
)}
|
||||
<h3 className="font-semibold text-text mb-1">{fornecedor.razao_social || fornecedor.nome_fantasia}</h3>
|
||||
<p className="text-sm text-gray mb-3">{getDoc(fornecedor)}</p>
|
||||
{renderStars(getRating(fornecedor))}
|
||||
{fornecedor.classificacao_esg && (
|
||||
<div className="mt-2">
|
||||
<span className={`text-xs px-2 py-1 rounded-lg font-medium ${fornecedor.classificacao_esg === 'Avançado' ? 'bg-green-100 text-green-700' : fornecedor.classificacao_esg === 'Intermediário' ? 'bg-amber-100 text-amber-700' : 'bg-red-100 text-red-700'}`}>
|
||||
ESG: {fornecedor.classificacao_esg}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{getEspecialidades(fornecedor).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{getEspecialidades(fornecedor).slice(0, 2).map((esp: string, i: number) => (
|
||||
<span key={i} className="text-xs px-2 py-1 bg-primary/10 text-primary rounded-lg">{esp}</span>
|
||||
))}
|
||||
{getEspecialidades(fornecedor).length > 2 && <span className="text-xs px-2 py-1 bg-gray-100 text-gray rounded-lg">+{getEspecialidades(fornecedor).length - 2}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedFornecedor(fornecedor); }}
|
||||
className="flex-1 btn-ghost !py-2 text-sm"
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1 inline" />
|
||||
Ver
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-1 btn-ghost !py-2 text-sm"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 mr-1 inline" />
|
||||
Editar
|
||||
</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); setSelectedFornecedor(fornecedor) }} className="flex-1 btn-ghost !py-2 text-sm"><Eye className="w-4 h-4 mr-1 inline" />Ver</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); openEdit(fornecedor) }} className="flex-1 btn-ghost !py-2 text-sm"><Edit2 className="w-4 h-4 mr-1 inline" />Editar</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDelete(fornecedor.id) }} className="flex-1 btn-ghost !py-2 text-sm text-red-500"><Trash2 className="w-4 h-4 mr-1 inline" />Excluir</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filteredFornecedores.length === 0 && (
|
||||
<div className="col-span-full card text-center py-12">
|
||||
<Building2 className="w-12 h-12 text-gray-light mx-auto mb-4" />
|
||||
<p className="text-gray">Nenhum fornecedor encontrado</p>
|
||||
</div>
|
||||
<div className="col-span-full card text-center py-12"><Building2 className="w-12 h-12 text-gray-light mx-auto mb-4" /><p className="text-gray">Nenhum fornecedor encontrado</p></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -209,14 +173,9 @@ export default function Fornecedores() {
|
||||
{selectedFornecedor && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl animate-fade-in max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-6 border-b border-border sticky top-0 bg-white">
|
||||
<h2 className="text-xl font-semibold text-text">Detalhes do Fornecedor</h2>
|
||||
<button
|
||||
onClick={() => setSelectedFornecedor(null)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 text-gray"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center justify-between p-6 border-b border-border bg-gradient-to-r from-primary to-accent rounded-t-2xl">
|
||||
<h2 className="text-xl font-semibold text-white">Detalhes do Fornecedor</h2>
|
||||
<button onClick={() => setSelectedFornecedor(null)} className="p-2 rounded-lg hover:bg-white/20 text-white"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -224,83 +183,108 @@ export default function Fornecedores() {
|
||||
<Building2 className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-text">{selectedFornecedor.razao_social}</h3>
|
||||
<p className="text-gray">{selectedFornecedor.cnpj}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{selectedFornecedor.ativo ? (
|
||||
<span className="badge-success flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
Ativo
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-neutral flex items-center gap-1">
|
||||
<XCircle className="w-3 h-3" />
|
||||
Inativo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-text">{selectedFornecedor.razao_social || selectedFornecedor.nome_fantasia}</h3>
|
||||
<p className="text-gray">{getDoc(selectedFornecedor)}</p>
|
||||
<span className={`badge ${selectedFornecedor.ativo ? 'badge-success' : 'badge-neutral'} mt-1 inline-flex items-center gap-1`}>
|
||||
{selectedFornecedor.ativo ? <><CheckCircle2 className="w-3 h-3" />Ativo</> : <><XCircle className="w-3 h-3" />Inativo</>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-3 bg-card rounded-xl">
|
||||
<Mail className="w-5 h-5 text-gray" />
|
||||
<div>
|
||||
<p className="text-xs text-gray">E-mail</p>
|
||||
<p className="text-text">{selectedFornecedor.email}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-card rounded-xl"><Mail className="w-5 h-5 text-gray" /><div><p className="text-xs text-gray">E-mail</p><p className="text-text">{selectedFornecedor.email || '-'}</p></div></div>
|
||||
<div className="flex items-center gap-3 p-3 bg-card rounded-xl"><Phone className="w-5 h-5 text-gray" /><div><p className="text-xs text-gray">Telefone</p><p className="text-text">{selectedFornecedor.telefone || '-'}</p></div></div>
|
||||
{selectedFornecedor.endereco && <div className="flex items-center gap-3 p-3 bg-card rounded-xl"><MapPin className="w-5 h-5 text-gray" /><div><p className="text-xs text-gray">Endereço</p><p className="text-text">{selectedFornecedor.endereco}</p></div></div>}
|
||||
</div>
|
||||
<div><p className="text-sm text-gray mb-2">Avaliação</p>{renderStars(getRating(selectedFornecedor))}</div>
|
||||
{/* ESG Info */}
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="text-sm font-semibold mb-3" style={{ color: '#1A7A4C' }}>🌿 Informações ESG</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex items-center gap-2"><span>{selectedFornecedor.possui_politica_ambiental ? '✅' : '❌'}</span>Política Ambiental</div>
|
||||
<div className="flex items-center gap-2"><span>{selectedFornecedor.possui_politica_sst ? '✅' : '❌'}</span>Política SST</div>
|
||||
<div className="flex items-center gap-2"><span>{selectedFornecedor.declara_uso_epi ? '✅' : '❌'}</span>Uso de EPI</div>
|
||||
<div className="flex items-center gap-2"><span>{selectedFornecedor.equipe_treinada ? '✅' : '❌'}</span>Equipe Treinada</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-card rounded-xl">
|
||||
<Phone className="w-5 h-5 text-gray" />
|
||||
<div>
|
||||
<p className="text-xs text-gray">Telefone</p>
|
||||
<p className="text-text">{selectedFornecedor.telefone}</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedFornecedor.endereco && (
|
||||
<div className="flex items-center gap-3 p-3 bg-card rounded-xl">
|
||||
<MapPin className="w-5 h-5 text-gray" />
|
||||
<div>
|
||||
<p className="text-xs text-gray">Endereço</p>
|
||||
<p className="text-text">{selectedFornecedor.endereco}</p>
|
||||
</div>
|
||||
{selectedFornecedor.classificacao_esg && (
|
||||
<div className="mt-3">
|
||||
<span className={`badge ${selectedFornecedor.classificacao_esg === 'Avançado' ? 'badge-success' : selectedFornecedor.classificacao_esg === 'Intermediário' ? 'badge-warning' : 'badge-error'}`}>
|
||||
ESG: {selectedFornecedor.classificacao_esg}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray mb-2">Avaliação</p>
|
||||
{renderStars(selectedFornecedor.avaliacao)}
|
||||
</div>
|
||||
|
||||
{selectedFornecedor.especialidades && (
|
||||
<div>
|
||||
<p className="text-sm text-gray mb-2">Especialidades</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedFornecedor.especialidades.map((esp, i) => (
|
||||
<span key={i} className="px-3 py-1.5 bg-primary/10 text-primary rounded-lg text-sm font-medium">
|
||||
{esp}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
onClick={() => setSelectedFornecedor(null)}
|
||||
className="btn-ghost flex-1"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
<button className="btn-primary flex-1">
|
||||
Editar Fornecedor
|
||||
</button>
|
||||
<button onClick={() => setSelectedFornecedor(null)} className="btn-ghost flex-1">Fechar</button>
|
||||
<button onClick={() => { setSelectedFornecedor(null); openEdit(selectedFornecedor) }} className="btn-primary flex-1">Editar Fornecedor</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal open={showCreateModal} onClose={() => setShowCreateModal(false)} title={editId ? 'Editar Fornecedor' : 'Novo Fornecedor'} onSubmit={handleSubmit} submitLabel={editId ? 'Salvar' : 'Criar Fornecedor'} loading={saving}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Tipo Pessoa</label>
|
||||
<select value={form.tipo_pessoa} onChange={(e) => setForm({ ...form, tipo_pessoa: e.target.value })} className="input-field">
|
||||
<option value="PJ">Pessoa Jurídica</option><option value="PF">Pessoa Física</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Razão Social</label>
|
||||
<input type="text" value={form.razao_social} onChange={(e) => setForm({ ...form, razao_social: e.target.value })} className="input-field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Nome Fantasia</label>
|
||||
<input type="text" value={form.nome_fantasia} onChange={(e) => setForm({ ...form, nome_fantasia: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">{form.tipo_pessoa === 'PJ' ? 'CNPJ' : 'CPF'}</label>
|
||||
<input type="text" value={form.cpf_cnpj} onChange={(e) => setForm({ ...form, cpf_cnpj: e.target.value })} className="input-field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">E-mail</label>
|
||||
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Telefone</label>
|
||||
<input type="text" value={form.telefone} onChange={(e) => setForm({ ...form, telefone: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Nome do Contato</label>
|
||||
<input type="text" value={form.nome_contato} onChange={(e) => setForm({ ...form, nome_contato: e.target.value })} className="input-field" placeholder="Nome da pessoa de contato" />
|
||||
</div>
|
||||
<div className="col-span-full border-t border-border pt-3 mt-2">
|
||||
<p className="text-sm font-semibold mb-3" style={{ color: '#1A7A4C' }}>🌿 Campos ESG</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.possui_politica_ambiental} onChange={e => setForm({ ...form, possui_politica_ambiental: e.target.checked })} className="rounded border-gray-300 text-green-600" />
|
||||
Possui Política Ambiental
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.possui_politica_sst} onChange={e => setForm({ ...form, possui_politica_sst: e.target.checked })} className="rounded border-gray-300 text-green-600" />
|
||||
Possui Política SST
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.declara_uso_epi} onChange={e => setForm({ ...form, declara_uso_epi: e.target.checked })} className="rounded border-gray-300 text-green-600" />
|
||||
Declara Uso de EPI
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.equipe_treinada} onChange={e => setForm({ ...form, equipe_treinada: e.target.checked })} className="rounded border-gray-300 text-green-600" />
|
||||
Equipe Treinada
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Classificação ESG</label>
|
||||
<select value={form.classificacao_esg} onChange={e => setForm({ ...form, classificacao_esg: e.target.value })} className="input-field">
|
||||
<option value="">Selecione...</option>
|
||||
<option value="Básico">Básico</option>
|
||||
<option value="Intermediário">Intermediário</option>
|
||||
<option value="Avançado">Avançado</option>
|
||||
</select>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
129
frontend/src/pages/Importacao.tsx
Normal file
129
frontend/src/pages/Importacao.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Upload, FileSpreadsheet, CheckCircle, AlertCircle, Loader2, X } from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
|
||||
interface ImportResult {
|
||||
sucesso: boolean
|
||||
registros_importados?: number
|
||||
erros?: string[]
|
||||
mensagem?: string
|
||||
}
|
||||
|
||||
export default function Importacao() {
|
||||
const [tipo, setTipo] = useState('orcamento')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [result, setResult] = useState<ImportResult | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const f = e.dataTransfer.files[0]
|
||||
if (f) setFile(f)
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('tipo', tipo)
|
||||
const { data } = await api.post('/import/excel', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
setResult(data)
|
||||
} catch (err: any) {
|
||||
setResult({ sucesso: false, mensagem: err.response?.data?.error || 'Erro ao importar arquivo.' })
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-text flex items-center gap-2">
|
||||
<Upload className="w-8 h-8 text-primary" /> Importação de Dados
|
||||
</h1>
|
||||
<p className="text-gray mt-1">Importe planilhas Excel para atualizar orçamentos ou demandas.</p>
|
||||
</div>
|
||||
|
||||
<div className="card max-w-2xl mx-auto space-y-6">
|
||||
{/* Tipo selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Tipo de importação</label>
|
||||
<select value={tipo} onChange={e => setTipo(e.target.value)} className="input-field">
|
||||
<option value="orcamento">Orçamento</option>
|
||||
<option value="demandas">Demandas</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-2xl p-12 text-center cursor-pointer transition-all ${
|
||||
dragging ? 'border-primary bg-primary/5' : 'border-gray-300 hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
className="hidden"
|
||||
onChange={e => { if (e.target.files?.[0]) setFile(e.target.files[0]) }}
|
||||
/>
|
||||
<FileSpreadsheet className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
{file ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="font-medium text-text">{file.name}</span>
|
||||
<button onClick={e => { e.stopPropagation(); setFile(null) }} className="p-1 rounded-full hover:bg-gray-100">
|
||||
<X className="w-4 h-4 text-gray" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium text-text">Arraste o arquivo aqui ou clique para selecionar</p>
|
||||
<p className="text-sm text-gray mt-1">Formatos aceitos: .xlsx, .xls, .csv</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload button */}
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploading}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? <Loader2 className="w-5 h-5 animate-spin" /> : <Upload className="w-5 h-5" />}
|
||||
{uploading ? 'Importando...' : 'Importar'}
|
||||
</button>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className={`p-4 rounded-xl ${result.sucesso ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{result.sucesso ? <CheckCircle className="w-5 h-5 text-green-500" /> : <AlertCircle className="w-5 h-5 text-red-500" />}
|
||||
<span className="font-medium">{result.sucesso ? 'Importação concluída!' : 'Erro na importação'}</span>
|
||||
</div>
|
||||
{result.registros_importados !== undefined && (
|
||||
<p className="text-sm text-gray">{result.registros_importados} registros importados com sucesso.</p>
|
||||
)}
|
||||
{result.mensagem && <p className="text-sm text-gray">{result.mensagem}</p>}
|
||||
{result.erros && result.erros.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{result.erros.map((e, i) => <li key={i} className="text-sm text-red-600">• {e}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
frontend/src/pages/KPIs.tsx
Normal file
115
frontend/src/pages/KPIs.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { BarChart3, Loader2, TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
|
||||
interface KPI {
|
||||
id?: number
|
||||
nome: string
|
||||
valor: number
|
||||
unidade: string
|
||||
categoria: string
|
||||
status: string // verde, amarelo, vermelho
|
||||
descricao?: string
|
||||
}
|
||||
|
||||
interface KPIDashboard {
|
||||
kpis: KPI[]
|
||||
calculados: KPI[]
|
||||
}
|
||||
|
||||
export default function KPIs() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<KPIDashboard | null>(null)
|
||||
const [categoria, setCategoria] = useState('')
|
||||
const [ano, setAno] = useState(new Date().getFullYear())
|
||||
|
||||
useEffect(() => { fetchData() }, [categoria, ano])
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: any = { ano }
|
||||
if (categoria) params.categoria = categoria
|
||||
const { data } = await api.get('/kpis/dashboard', { params })
|
||||
setData(data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching KPIs:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { bg: string, border: string, badge: string, icon: React.ReactNode }> = {
|
||||
verde: { bg: 'bg-green-50', border: 'border-green-200', badge: 'badge-success', icon: <TrendingUp className="w-4 h-4 text-green-500" /> },
|
||||
amarelo: { bg: 'bg-yellow-50', border: 'border-yellow-200', badge: 'badge-warning', icon: <Minus className="w-4 h-4 text-yellow-500" /> },
|
||||
vermelho: { bg: 'bg-red-50', border: 'border-red-200', badge: 'badge-error', icon: <TrendingDown className="w-4 h-4 text-red-500" /> },
|
||||
}
|
||||
|
||||
const allKpis = [...(data?.calculados || []), ...(data?.kpis || [])]
|
||||
|
||||
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">
|
||||
<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 flex items-center gap-2">
|
||||
<BarChart3 className="w-8 h-8 text-primary" /> Indicadores KPI
|
||||
</h1>
|
||||
<p className="text-gray mt-1">Acompanhe os principais indicadores de performance.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select value={categoria} onChange={e => setCategoria(e.target.value)} className="input-field text-sm">
|
||||
<option value="">Todas categorias</option>
|
||||
<option value="financeiro">Financeiro</option>
|
||||
<option value="operacional">Operacional</option>
|
||||
<option value="qualidade">Qualidade</option>
|
||||
<option value="fornecedores">Fornecedores</option>
|
||||
</select>
|
||||
<select value={ano} onChange={e => setAno(Number(e.target.value))} className="input-field text-sm">
|
||||
{[2024, 2025, 2026].map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{allKpis.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
{allKpis.map((kpi, i) => {
|
||||
const cfg = statusConfig[kpi.status] || statusConfig.verde
|
||||
return (
|
||||
<div key={i} className={`card border ${cfg.border} ${cfg.bg} hover:shadow-lg transition-all duration-300`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<span className={`${cfg.badge} text-xs px-2 py-1 rounded-full`}>
|
||||
{kpi.status === 'verde' ? 'Bom' : kpi.status === 'amarelo' ? 'Atenção' : 'Crítico'}
|
||||
</span>
|
||||
{cfg.icon}
|
||||
</div>
|
||||
<h3 className="font-semibold text-text mb-1">{kpi.nome}</h3>
|
||||
{kpi.descricao && <p className="text-xs text-gray mb-3">{kpi.descricao}</p>}
|
||||
<div className="flex items-end gap-1">
|
||||
<span className="text-3xl font-bold text-text">
|
||||
{typeof kpi.valor === 'number' ? kpi.valor.toLocaleString('pt-BR', { maximumFractionDigits: 1 }) : kpi.valor}
|
||||
</span>
|
||||
<span className="text-sm text-gray mb-1">{kpi.unidade}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray mt-2 capitalize">{kpi.categoria}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card text-center py-12">
|
||||
<BarChart3 className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray">Nenhum KPI encontrado</h3>
|
||||
<p className="text-sm text-gray-light mt-1">Os indicadores serão calculados automaticamente conforme dados são registrados.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export default function Landing() {
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center shadow-lg shadow-primary/20">
|
||||
<Flame className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-xl text-text">HEFESTO</span>
|
||||
<span className="font-bold text-xl text-text">Nexus Facilities</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
@@ -98,11 +98,11 @@ export default function Landing() {
|
||||
|
||||
{/* Headline */}
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-text mb-6 leading-tight">
|
||||
Forje o{' '}
|
||||
Orçamentos sob{' '}
|
||||
<span className="bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
||||
controle
|
||||
Controle
|
||||
</span>
|
||||
{' '}dos seus custos
|
||||
, Resultados sob Medida
|
||||
</h1>
|
||||
|
||||
{/* Subheadline */}
|
||||
@@ -174,7 +174,7 @@ export default function Landing() {
|
||||
<div>
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-text mb-6">
|
||||
Por que escolher o{' '}
|
||||
<span className="text-primary">HEFESTO</span>?
|
||||
<span className="text-primary">Nexus Facilities</span>?
|
||||
</h2>
|
||||
<p className="text-gray text-lg mb-8">
|
||||
Nossa plataforma foi desenvolvida especificamente para atender às necessidades
|
||||
@@ -222,7 +222,7 @@ export default function Landing() {
|
||||
Pronto para transformar sua gestão de facilities?
|
||||
</h2>
|
||||
<p className="text-white/70 text-lg mb-8 max-w-2xl mx-auto">
|
||||
Comece agora mesmo e descubra como o HEFESTO pode ajudar sua empresa
|
||||
Comece agora mesmo e descubra como o Nexus Facilities pode ajudar sua empresa
|
||||
a ter mais controle e eficiência.
|
||||
</p>
|
||||
<Link to="/login" className="btn-primary text-lg !py-4 !px-8 inline-flex items-center gap-2">
|
||||
@@ -240,10 +240,10 @@ export default function Landing() {
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-accent flex items-center justify-center">
|
||||
<Flame className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-text">HEFESTO</span>
|
||||
<span className="font-bold text-text">Nexus Facilities</span>
|
||||
</div>
|
||||
<p className="text-gray text-sm">
|
||||
© 2026 HEFESTO. Todos os direitos reservados.
|
||||
© 2026 Nexus Facilities. Todos os direitos reservados.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,8 +68,8 @@ export default function Login() {
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-gradient-to-br from-primary to-accent mb-6 shadow-xl shadow-primary/30 animate-pulse-glow">
|
||||
<Flame className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-text mb-2">HEFESTO</h1>
|
||||
<p className="text-gray">Sistema de Controle Orçamentário</p>
|
||||
<h1 className="text-3xl font-bold text-text mb-2">Nexus Facilities</h1>
|
||||
<p className="text-gray">Gestão de Facilities</p>
|
||||
</div>
|
||||
|
||||
{/* Login card */}
|
||||
@@ -164,7 +164,7 @@ export default function Login() {
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-gray-light text-sm mt-8">
|
||||
© 2026 HEFESTO. Todos os direitos reservados.
|
||||
© 2026 Nexus Facilities. Todos os direitos reservados.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
170
frontend/src/pages/Metas.tsx
Normal file
170
frontend/src/pages/Metas.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Target, Loader2, Plus, X } from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
|
||||
interface Meta {
|
||||
id: number
|
||||
nome: string
|
||||
descricao: string
|
||||
tipo: string
|
||||
valor_meta: number
|
||||
valor_atual: number
|
||||
unidade: string
|
||||
status: string
|
||||
data_inicio: string
|
||||
data_fim: string
|
||||
}
|
||||
|
||||
export default function Metas() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [metas, setMetas] = useState<Meta[]>([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [filterTipo, setFilterTipo] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [form, setForm] = useState({ nome: '', descricao: '', tipo: 'financeiro', valor_meta: 0, unidade: '', data_inicio: '', data_fim: '' })
|
||||
|
||||
useEffect(() => { fetchData() }, [filterTipo, filterStatus])
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: any = {}
|
||||
if (filterTipo) params.tipo = filterTipo
|
||||
if (filterStatus) params.status = filterStatus
|
||||
const { data } = await api.get('/metas/progresso', { params })
|
||||
setMetas(Array.isArray(data) ? data : data?.metas || [])
|
||||
} catch (err) {
|
||||
console.error('Error fetching metas:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.post('/metas', form)
|
||||
setShowForm(false)
|
||||
setForm({ nome: '', descricao: '', tipo: 'financeiro', valor_meta: 0, unidade: '', data_inicio: '', data_fim: '' })
|
||||
fetchData()
|
||||
} catch (err) {
|
||||
console.error('Error creating meta:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const statusBadge: Record<string, string> = {
|
||||
em_andamento: 'badge-info',
|
||||
atingida: 'badge-success',
|
||||
atrasada: 'badge-error',
|
||||
}
|
||||
const statusLabel: Record<string, string> = {
|
||||
em_andamento: 'Em Andamento',
|
||||
atingida: 'Atingida',
|
||||
atrasada: 'Atrasada',
|
||||
}
|
||||
|
||||
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">
|
||||
<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 flex items-center gap-2">
|
||||
<Target className="w-8 h-8 text-primary" /> Metas & Progresso
|
||||
</h1>
|
||||
<p className="text-gray mt-1">Acompanhe metas e objetivos da organização.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select value={filterTipo} onChange={e => setFilterTipo(e.target.value)} className="input-field text-sm">
|
||||
<option value="">Todos tipos</option>
|
||||
<option value="financeiro">Financeiro</option>
|
||||
<option value="operacional">Operacional</option>
|
||||
<option value="qualidade">Qualidade</option>
|
||||
<option value="esg">ESG</option>
|
||||
</select>
|
||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="input-field text-sm">
|
||||
<option value="">Todos status</option>
|
||||
<option value="em_andamento">Em Andamento</option>
|
||||
<option value="atingida">Atingida</option>
|
||||
<option value="atrasada">Atrasada</option>
|
||||
</select>
|
||||
<button onClick={() => setShowForm(true)} className="btn-primary text-sm flex items-center gap-1">
|
||||
<Plus className="w-4 h-4" /> Nova Meta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Form Modal */}
|
||||
{showForm && (
|
||||
<div className="card border-2 border-primary/20">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-text">Nova Meta</h3>
|
||||
<button onClick={() => setShowForm(false)} className="p-1 rounded hover:bg-gray-100"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<input placeholder="Nome" value={form.nome} onChange={e => setForm({...form, nome: e.target.value})} className="input-field" />
|
||||
<select value={form.tipo} onChange={e => setForm({...form, tipo: e.target.value})} className="input-field">
|
||||
<option value="financeiro">Financeiro</option>
|
||||
<option value="operacional">Operacional</option>
|
||||
<option value="qualidade">Qualidade</option>
|
||||
<option value="esg">ESG</option>
|
||||
</select>
|
||||
<input placeholder="Valor Meta" type="number" value={form.valor_meta || ''} onChange={e => setForm({...form, valor_meta: Number(e.target.value)})} className="input-field" />
|
||||
<input placeholder="Unidade (ex: R$, %, un)" value={form.unidade} onChange={e => setForm({...form, unidade: e.target.value})} className="input-field" />
|
||||
<input type="date" value={form.data_inicio} onChange={e => setForm({...form, data_inicio: e.target.value})} className="input-field" />
|
||||
<input type="date" value={form.data_fim} onChange={e => setForm({...form, data_fim: e.target.value})} className="input-field" />
|
||||
<textarea placeholder="Descrição" value={form.descricao} onChange={e => setForm({...form, descricao: e.target.value})} className="input-field sm:col-span-2" rows={2} />
|
||||
</div>
|
||||
<button onClick={handleCreate} disabled={saving || !form.nome} className="btn-primary mt-4 flex items-center gap-2 disabled:opacity-50">
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />} Criar Meta
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Goals Grid */}
|
||||
{metas.length > 0 ? (
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{metas.map(meta => {
|
||||
const pct = meta.valor_meta > 0 ? Math.min((meta.valor_atual / meta.valor_meta) * 100, 100) : 0
|
||||
return (
|
||||
<div key={meta.id} className="card hover:shadow-lg transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="font-semibold text-text">{meta.nome}</h3>
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${statusBadge[meta.status] || 'badge-info'}`}>
|
||||
{statusLabel[meta.status] || meta.status}
|
||||
</span>
|
||||
</div>
|
||||
{meta.descricao && <p className="text-sm text-gray mb-3">{meta.descricao}</p>}
|
||||
<div className="bg-gray-100 rounded-full h-4 overflow-hidden mb-2">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
meta.status === 'atingida' ? 'bg-green-500' :
|
||||
meta.status === 'atrasada' ? 'bg-red-500' : 'bg-gradient-to-r from-primary to-accent'
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray">
|
||||
<span>{meta.valor_atual?.toLocaleString('pt-BR')} / {meta.valor_meta?.toLocaleString('pt-BR')} {meta.unidade}</span>
|
||||
<span className="font-medium">{pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray mt-2 capitalize">{meta.tipo}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card text-center py-12">
|
||||
<Target className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray">Nenhuma meta encontrada</h3>
|
||||
<p className="text-sm text-gray-light mt-1">Crie metas para acompanhar o progresso da organização.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Wallet,
|
||||
Search,
|
||||
Filter,
|
||||
Plus,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Calendar
|
||||
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' },
|
||||
@@ -21,47 +16,72 @@ const statusConfig: Record<string, { label: string; class: string }> = {
|
||||
'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' },
|
||||
]
|
||||
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<Orcamento[]>([])
|
||||
const [orcamentos, setOrcamentos] = useState<any[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedYear, setSelectedYear] = useState(2024)
|
||||
const [selectedMonth, setSelectedMonth] = useState(0) // 0 = todos
|
||||
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()
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrcamentos()
|
||||
}, [])
|
||||
// Capex/Opex chart data
|
||||
const [investData, setInvestData] = useState<any[]>([])
|
||||
|
||||
useEffect(() => { fetchOrcamentos(); fetchInvestData() }, [])
|
||||
|
||||
const fetchOrcamentos = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/orcamento')
|
||||
setOrcamentos(data.length > 0 ? data : mockOrcamentos)
|
||||
setOrcamentos(data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching orcamentos:', err)
|
||||
setOrcamentos(mockOrcamentos)
|
||||
} 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 matchesSearch = orc.categoria.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
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 + orc.valor_previsto, 0)
|
||||
const totalRealizado = filteredOrcamentos.reduce((acc, orc) => acc + orc.valor_realizado, 0)
|
||||
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 = [
|
||||
@@ -69,15 +89,58 @@ export default function Orcamentos() {
|
||||
'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'
|
||||
]
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
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" />
|
||||
@@ -87,24 +150,22 @@ export default function Orcamentos() {
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
{/* 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-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" />
|
||||
@@ -130,59 +191,71 @@ export default function Orcamentos() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{/* 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"
|
||||
/>
|
||||
<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 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 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">Tipo Investimento</th>
|
||||
<th className="table-cell">Centro de Custo</th>
|
||||
<th className="table-cell">Período</th>
|
||||
<th className="table-cell text-right">Previsto</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.map((orcamento) => {
|
||||
const percentage = Number(getPercentage(orcamento.valor_realizado, orcamento.valor_previsto))
|
||||
{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">
|
||||
@@ -190,40 +263,62 @@ export default function Orcamentos() {
|
||||
<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>
|
||||
<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(orcamento.valor_previsto)}
|
||||
<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_realizado)}
|
||||
{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 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>
|
||||
<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>
|
||||
)
|
||||
@@ -231,24 +326,69 @@ export default function Orcamentos() {
|
||||
</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" />
|
||||
<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 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 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,153 +1,231 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
ClipboardList,
|
||||
Search,
|
||||
Plus,
|
||||
Eye,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
PlayCircle,
|
||||
ChevronDown,
|
||||
Building2,
|
||||
Calendar
|
||||
ClipboardList, Search, Plus, Eye, Edit2, Trash2, Loader2,
|
||||
Clock, CheckCircle2, AlertCircle, PlayCircle, ChevronDown, Building2, Calendar, X,
|
||||
Upload, Download, FileText
|
||||
} from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
import { OrdemServico } from '../types'
|
||||
import { OrdemServico, Demanda, DocumentoVersao } from '../types'
|
||||
import { useLookups } from '../hooks/useLookups'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const statusConfig: Record<string, { label: string; class: string; icon: React.ReactNode }> = {
|
||||
'pendente': { label: 'Pendente', class: 'badge-warning', icon: <Clock className="w-3 h-3" /> },
|
||||
'emitida': { label: 'Emitida', class: 'badge-warning', icon: <Clock className="w-3 h-3" /> },
|
||||
'em_cotacao': { label: 'Em Cotação', class: 'badge-info', icon: <AlertCircle className="w-3 h-3" /> },
|
||||
'em_andamento': { label: 'Em Andamento', class: 'badge-info', icon: <PlayCircle className="w-3 h-3" /> },
|
||||
'em_execucao': { label: 'Em Execução', class: 'badge-info', icon: <PlayCircle className="w-3 h-3" /> },
|
||||
'aguardando_aprovacao': { label: 'Aguard. Aprovação', class: 'badge-warning', icon: <AlertCircle className="w-3 h-3" /> },
|
||||
'concluida': { label: 'Concluída', class: 'badge-success', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||
'cancelada': { label: 'Cancelada', class: 'badge-error', icon: <AlertCircle className="w-3 h-3" /> },
|
||||
}
|
||||
|
||||
const mockOrdens: OrdemServico[] = [
|
||||
{ id: 1, numero: 'OS-2024-0001', demanda_id: 1, fornecedor_id: 1, fornecedor_nome: 'Tech Solutions', status: 'em_andamento', data_criacao: '2024-01-16', descricao: 'Manutenção preventiva ar condicionado' },
|
||||
{ id: 2, numero: 'OS-2024-0002', demanda_id: 2, fornecedor_id: 2, fornecedor_nome: 'EletroFix', status: 'pendente', data_criacao: '2024-01-15', descricao: 'Troca de lâmpadas LED' },
|
||||
{ id: 3, numero: 'OS-2024-0003', demanda_id: 3, fornecedor_id: 3, fornecedor_nome: 'HidroServ', status: 'concluida', data_criacao: '2024-01-14', descricao: 'Reparo vazamento' },
|
||||
{ id: 4, numero: 'OS-2024-0004', demanda_id: 4, fornecedor_id: 1, fornecedor_nome: 'Tech Solutions', status: 'aguardando_aprovacao', data_criacao: '2024-01-13', descricao: 'Pintura geral sala de reunião' },
|
||||
{ id: 5, numero: 'OS-2024-0005', demanda_id: 5, fornecedor_id: 4, fornecedor_nome: 'ElevaTech', status: 'em_andamento', data_criacao: '2024-01-12', descricao: 'Manutenção elevador social' },
|
||||
]
|
||||
const emptyForm = { fornecedor_id: '', valor: 0, observacoes: '', demanda_id: '', data: '', uso_material_sustentavel: '', gera_residuos: '', descarte_certificado: '' }
|
||||
|
||||
export default function OrdensServico() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [ordens, setOrdens] = useState<OrdemServico[]>([])
|
||||
const [demandas, setDemandas] = useState<Demanda[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('todos')
|
||||
const [filterDemanda, setFilterDemanda] = useState('')
|
||||
const [filterCategoria, setFilterCategoria] = useState('')
|
||||
const [filterFornecedor, setFilterFornecedor] = useState('')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [showDetail, setShowDetail] = useState<OrdemServico | null>(null)
|
||||
const [editId, setEditId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState(emptyForm)
|
||||
const [saving, setSaving] = useState(false)
|
||||
// Documents
|
||||
const [detailDocs, setDetailDocs] = useState<DocumentoVersao[]>([])
|
||||
const [docsLoading, setDocsLoading] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrdens()
|
||||
}, [])
|
||||
const { fornecedoresMap, fornecedores, categoriaMap, categorias, loading: lookupsLoading } = useLookups()
|
||||
|
||||
// Build demanda map
|
||||
const demandaMap: Record<string, string> = {}
|
||||
demandas.forEach(d => { demandaMap[d.id] = d.numero ? `#${d.numero} - ${d.titulo}` : d.titulo })
|
||||
|
||||
// Build demanda->categoria map
|
||||
const demandaCategoriaMap: Record<string, string> = {}
|
||||
demandas.forEach(d => { demandaCategoriaMap[d.id] = d.categoria_id })
|
||||
|
||||
useEffect(() => { fetchOrdens(); fetchDemandas() }, [])
|
||||
|
||||
const fetchOrdens = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/ordens-servico')
|
||||
setOrdens(data.length > 0 ? data : mockOrdens)
|
||||
setOrdens(data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching ordens:', err)
|
||||
setOrdens(mockOrdens)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredOrdens = ordens.filter(ordem => {
|
||||
const matchesSearch = ordem.numero.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
ordem.descricao?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
ordem.fornecedor_nome?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesStatus = filterStatus === 'todos' || ordem.status === filterStatus
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('pt-BR')
|
||||
const fetchDemandas = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/demandas')
|
||||
setDemandas(data)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
const fetchDetailDocs = async (osId: string) => {
|
||||
setDocsLoading(true)
|
||||
try {
|
||||
const { data } = await api.get(`/ordens-servico/${osId}/documentos`)
|
||||
setDetailDocs(data)
|
||||
} catch { setDetailDocs([]) }
|
||||
finally { setDocsLoading(false) }
|
||||
}
|
||||
|
||||
const getFornecedorNome = (o: OrdemServico) => o.fornecedor_nome || fornecedoresMap[o.fornecedor_id] || '-'
|
||||
|
||||
const filteredOrdens = ordens.filter(ordem => {
|
||||
const num = typeof ordem.numero === 'number' ? `OS-${String(ordem.numero).padStart(4, '0')}` : (ordem.numero || '')
|
||||
const matchesSearch = num.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(ordem.descricao || ordem.observacoes || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
getFornecedorNome(ordem).toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesStatus = filterStatus === 'todos' || ordem.status === filterStatus
|
||||
const matchesDemanda = !filterDemanda || ordem.demanda_id === filterDemanda
|
||||
const matchesCategoria = !filterCategoria || demandaCategoriaMap[ordem.demanda_id] === filterCategoria
|
||||
const matchesFornecedor = !filterFornecedor || ordem.fornecedor_id === filterFornecedor
|
||||
return matchesSearch && matchesStatus && matchesDemanda && matchesCategoria && matchesFornecedor
|
||||
})
|
||||
|
||||
const openNew = () => { setEditId(null); setForm(emptyForm); setShowModal(true) }
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editId) {
|
||||
await api.patch(`/ordens-servico/${editId}`, form)
|
||||
} else {
|
||||
await api.post('/ordens-servico', { ...form, status: 'emitida' })
|
||||
}
|
||||
setShowModal(false)
|
||||
fetchOrdens()
|
||||
} catch (err) {
|
||||
console.error('Error saving:', err)
|
||||
alert('Erro ao salvar OS')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Tem certeza que deseja excluir esta OS?')) return
|
||||
try { await api.delete(`/ordens-servico/${id}`); fetchOrdens() } catch { alert('Erro ao excluir') }
|
||||
}
|
||||
|
||||
const handleFileUpload = async (files: FileList | null) => {
|
||||
if (!files || !showDetail) return
|
||||
setUploading(true)
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (showDetail.fornecedor_id) formData.append('fornecedor_id', showDetail.fornecedor_id)
|
||||
await api.post(`/ordens-servico/${showDetail.id}/documentos`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
fetchDetailDocs(showDetail.id)
|
||||
} catch (err) {
|
||||
alert('Erro ao fazer upload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr?: string | null) => {
|
||||
if (!dateStr) return '-'
|
||||
try { return new Date(dateStr).toLocaleDateString('pt-BR') } catch { return '-' }
|
||||
}
|
||||
|
||||
const formatCurrency = (v: number | null | undefined) => {
|
||||
if (v == null) return '-'
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v)
|
||||
}
|
||||
|
||||
const openDetail = (o: OrdemServico) => {
|
||||
setShowDetail(o)
|
||||
fetchDetailDocs(o.id)
|
||||
}
|
||||
|
||||
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">
|
||||
{/* 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">Ordens de Serviço</h1>
|
||||
<p className="text-gray mt-1">Acompanhe todas as ordens de serviço</p>
|
||||
</div>
|
||||
<button className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
|
||||
<Plus className="w-5 h-5" />
|
||||
Nova OS
|
||||
<button onClick={openNew} className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
|
||||
<Plus className="w-5 h-5" />Nova OS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-4">
|
||||
{Object.entries(statusConfig).map(([key, config]) => {
|
||||
const count = ordens.filter(o => o.status === key).length
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilterStatus(filterStatus === key ? 'todos' : key)}
|
||||
className={`card text-left transition-all ${filterStatus === key ? 'ring-2 ring-primary' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{config.icon}
|
||||
<span className="text-xs text-gray truncate">{config.label}</span>
|
||||
</div>
|
||||
<button key={key} onClick={() => setFilterStatus(filterStatus === key ? 'todos' : key)}
|
||||
className={`card text-left transition-all ${filterStatus === key ? 'ring-2 ring-primary' : ''}`}>
|
||||
<div className="flex items-center gap-2 mb-2">{config.icon}<span className="text-xs text-gray truncate">{config.label}</span></div>
|
||||
<p className="text-xl font-bold text-text">{count}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card">
|
||||
<div className="flex flex-col sm: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 número, descrição ou fornecedor..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="input-field pl-12"
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col sm: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 número, descrição ou fornecedor..." value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)} className="input-field pl-12" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="input-field appearance-none pr-10 w-full sm:w-48">
|
||||
<option value="todos">Todos os status</option>
|
||||
{Object.entries(statusConfig).map(([key, config]) => (<option key={key} value={key}>{config.label}</option>))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="input-field appearance-none pr-10 w-full sm:w-48"
|
||||
>
|
||||
<option value="todos">Todos os status</option>
|
||||
{Object.entries(statusConfig).map(([key, config]) => (
|
||||
<option key={key} value={key}>{config.label}</option>
|
||||
))}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<select value={filterDemanda} onChange={(e) => setFilterDemanda(e.target.value)} className="input-field sm:w-60">
|
||||
<option value="">Todas as Demandas</option>
|
||||
{demandas.map(d => <option key={d.id} value={d.id}>{d.numero ? `#${d.numero}` : ''} {d.titulo}</option>)}
|
||||
</select>
|
||||
<select value={filterCategoria} onChange={(e) => setFilterCategoria(e.target.value)} className="input-field sm:w-48">
|
||||
<option value="">Todas Categorias</option>
|
||||
{categorias.map(c => <option key={c.id} value={c.id}>{c.nome}</option>)}
|
||||
</select>
|
||||
<select value={filterFornecedor} onChange={(e) => setFilterFornecedor(e.target.value)} className="input-field sm:w-48">
|
||||
<option value="">Todos Fornecedores</option>
|
||||
{fornecedores.map(f => <option key={f.id} value={f.id}>{f.nome}</option>)}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray pointer-events-none" />
|
||||
</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">Número</th>
|
||||
<th className="table-cell">Descrição</th>
|
||||
<th className="table-cell">Demanda</th>
|
||||
<th className="table-cell">Fornecedor</th>
|
||||
<th className="table-cell text-right">Valor</th>
|
||||
<th className="table-cell">Data</th>
|
||||
<th className="table-cell text-center">Status</th>
|
||||
<th className="table-cell text-center">Ações</th>
|
||||
@@ -161,41 +239,26 @@ export default function OrdensServico() {
|
||||
<div className="w-10 h-10 rounded-lg bg-secondary/10 flex items-center justify-center">
|
||||
<ClipboardList className="w-5 h-5 text-secondary" />
|
||||
</div>
|
||||
<span className="font-mono font-semibold text-primary">{ordem.numero}</span>
|
||||
<span className="font-mono font-semibold text-primary">{typeof ordem.numero === 'number' ? `OS-${String(ordem.numero).padStart(4, '0')}` : ordem.numero}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell"><span className="line-clamp-1 text-sm">{demandaMap[ordem.demanda_id] || '-'}</span></td>
|
||||
<td className="table-cell">
|
||||
<span className="line-clamp-1">{ordem.descricao}</span>
|
||||
<div className="flex items-center gap-2"><Building2 className="w-4 h-4 text-gray" /><span>{getFornecedorNome(ordem)}</span></div>
|
||||
</td>
|
||||
<td className="table-cell text-right font-medium">{ordem.valor ? formatCurrency(ordem.valor) : '-'}</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-gray" />
|
||||
<span>{ordem.fornecedor_nome}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-gray" />
|
||||
<span>{formatDate(ordem.data_criacao)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2"><Calendar className="w-4 h-4 text-gray" /><span>{formatDate(ordem.data || ordem.data_criacao || ordem.created_at)}</span></div>
|
||||
</td>
|
||||
<td className="table-cell text-center">
|
||||
<span className={`${statusConfig[ordem.status]?.class || 'badge-neutral'} inline-flex items-center gap-1`}>
|
||||
{statusConfig[ordem.status]?.icon}
|
||||
{statusConfig[ordem.status]?.label || ordem.status}
|
||||
{statusConfig[ordem.status]?.icon}{statusConfig[ordem.status]?.label || ordem.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors">
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-2 rounded-lg hover:bg-red-50 text-gray hover:text-red-500 transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => openDetail(ordem)} className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors"><Eye className="w-4 h-4" /></button>
|
||||
<button onClick={() => handleDelete(ordem.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>
|
||||
@@ -203,14 +266,166 @@ export default function OrdensServico() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredOrdens.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<ClipboardList className="w-12 h-12 text-gray-light mx-auto mb-4" />
|
||||
<p className="text-gray">Nenhuma ordem de serviço encontrada</p>
|
||||
</div>
|
||||
<div className="text-center py-12"><ClipboardList className="w-12 h-12 text-gray-light mx-auto mb-4" /><p className="text-gray">Nenhuma ordem de serviço encontrada</p></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal open={showModal} onClose={() => setShowModal(false)} title="Nova Ordem de Serviço" onSubmit={handleSubmit} submitLabel="Criar OS" loading={saving}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Demanda</label>
|
||||
<select value={form.demanda_id} onChange={(e) => setForm({ ...form, demanda_id: e.target.value })} className="input-field">
|
||||
<option value="">Selecione...</option>
|
||||
{demandas.map(d => <option key={d.id} value={d.id}>{d.numero ? `#${d.numero}` : ''} {d.titulo}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Fornecedor</label>
|
||||
<select value={form.fornecedor_id} onChange={(e) => setForm({ ...form, fornecedor_id: e.target.value })} className="input-field" required>
|
||||
<option value="">Selecione...</option>
|
||||
{fornecedores.map(f => <option key={f.id} value={f.id}>{f.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Data</label>
|
||||
<input type="date" value={form.data} onChange={(e) => setForm({ ...form, data: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Valor</label>
|
||||
<input type="number" step="0.01" value={form.valor} onChange={(e) => setForm({ ...form, valor: Number(e.target.value) })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Observações</label>
|
||||
<textarea value={form.observacoes} onChange={(e) => setForm({ ...form, observacoes: e.target.value })} className="input-field resize-none" rows={3} />
|
||||
</div>
|
||||
<div className="col-span-full border-t border-border pt-3 mt-2">
|
||||
<p className="text-sm font-semibold mb-3" style={{ color: '#1A7A4C' }}>🌿 Campos ESG</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Material Sustentável</label>
|
||||
<select value={form.uso_material_sustentavel} onChange={e => setForm({ ...form, uso_material_sustentavel: e.target.value })} className="input-field">
|
||||
<option value="">Não Informado</option>
|
||||
<option value="Sim">Sim</option>
|
||||
<option value="Não">Não</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Geração de Resíduos</label>
|
||||
<select value={form.gera_residuos} onChange={e => setForm({ ...form, gera_residuos: e.target.value })} className="input-field">
|
||||
<option value="">Não Informado</option>
|
||||
<option value="Baixo">Baixo</option>
|
||||
<option value="Médio">Médio</option>
|
||||
<option value="Alto">Alto</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Descarte Certificado</label>
|
||||
<select value={form.descarte_certificado} onChange={e => setForm({ ...form, descarte_certificado: e.target.value })} className="input-field">
|
||||
<option value="">Não Informado</option>
|
||||
<option value="Sim">Sim</option>
|
||||
<option value="Não">Não</option>
|
||||
</select>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<Modal open={!!showDetail} onClose={() => { setShowDetail(null); setDetailDocs([]) }} title={`OS ${showDetail ? (typeof showDetail.numero === 'number' ? `OS-${String(showDetail.numero).padStart(4, '0')}` : showDetail.numero) : ''}`}>
|
||||
{showDetail && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><strong>Demanda:</strong> {demandaMap[showDetail.demanda_id] || '-'}</div>
|
||||
<div><strong>Fornecedor:</strong> {getFornecedorNome(showDetail)}</div>
|
||||
<div><strong>Valor:</strong> {formatCurrency(showDetail.valor)}</div>
|
||||
<div><strong>Status:</strong> {statusConfig[showDetail.status]?.label || showDetail.status}</div>
|
||||
<div><strong>Data:</strong> {formatDate(showDetail.data)}</div>
|
||||
<div><strong>Data Início:</strong> {formatDate(showDetail.data_inicio)}</div>
|
||||
<div><strong>Data Conclusão:</strong> {formatDate(showDetail.data_conclusao)}</div>
|
||||
</div>
|
||||
<div><strong>Observações:</strong> {showDetail.observacoes || '-'}</div>
|
||||
|
||||
{/* ESG */}
|
||||
{((showDetail as any).uso_material_sustentavel || (showDetail as any).gera_residuos || (showDetail as any).descarte_certificado) && (
|
||||
<div className="border-t border-border pt-4 mt-4">
|
||||
<h3 className="font-semibold mb-3" style={{ color: '#1A7A4C' }}>🌿 Dados ESG</h3>
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div><strong>Material Sustentável:</strong> {(showDetail as any).uso_material_sustentavel || 'Não Informado'}</div>
|
||||
<div><strong>Geração Resíduos:</strong> {(showDetail as any).gera_residuos || 'Não Informado'}</div>
|
||||
<div><strong>Descarte Certificado:</strong> {(showDetail as any).descarte_certificado || 'Não Informado'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proposal Data */}
|
||||
{(showDetail.valor_bruto || showDetail.valor_liquido) && (
|
||||
<div className="border-t border-border pt-4 mt-4">
|
||||
<h3 className="font-semibold text-text mb-3">Dados da Proposta</h3>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div><strong>Valor Bruto:</strong> {formatCurrency(showDetail.valor_bruto)}</div>
|
||||
<div><strong>Valor Líquido:</strong> {formatCurrency(showDetail.valor_liquido)}</div>
|
||||
<div><strong>ISS:</strong> {formatCurrency(showDetail.iss)}</div>
|
||||
<div><strong>INSS:</strong> {formatCurrency(showDetail.inss)}</div>
|
||||
<div><strong>PCC:</strong> {formatCurrency(showDetail.pcc)}</div>
|
||||
<div><strong>Cond. Pagamento:</strong> {showDetail.condicao_pagamento || '-'}</div>
|
||||
<div><strong>Prazo Execução:</strong> {showDetail.prazo_execucao || '-'}</div>
|
||||
<div><strong>Data Est. Entrega:</strong> {formatDate(showDetail.data_estimada_entrega)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document Versioning */}
|
||||
<div className="border-t border-border pt-4 mt-4">
|
||||
<h3 className="font-semibold text-text mb-3 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" /> Documentos (PDF)
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<button onClick={() => fileInputRef.current?.click()} disabled={uploading}
|
||||
className="btn-primary text-sm py-1 px-3 flex items-center gap-1">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
{uploading ? 'Enviando...' : 'Upload PDF'}
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept=".pdf" className="hidden"
|
||||
onChange={(e) => handleFileUpload(e.target.files)} />
|
||||
</div>
|
||||
|
||||
{docsLoading ? (
|
||||
<div className="flex justify-center py-4"><Loader2 className="w-5 h-5 animate-spin text-gray" /></div>
|
||||
) : detailDocs.length === 0 ? (
|
||||
<p className="text-sm text-gray text-center py-2">Nenhum documento anexado</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{detailDocs.map(doc => (
|
||||
<div key={doc.id} className="p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-red-500" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-text truncate">{doc.nome_arquivo}</p>
|
||||
<span className="badge badge-info text-xs">V{doc.versao}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray">
|
||||
{fornecedoresMap[doc.fornecedor_id] || ''} · {(doc.tamanho / 1024).toFixed(1)} KB · {formatDate(doc.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<a href={`/api/ordens-servico/documentos/${doc.id}/download`} target="_blank" className="p-1 rounded hover:bg-white text-gray hover:text-primary">
|
||||
<Download className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
{(doc.valor_bruto || doc.valor_liquido) && (
|
||||
<div className="mt-2 grid grid-cols-3 gap-2 text-xs text-gray">
|
||||
<span>Bruto: {formatCurrency(doc.valor_bruto)}</span>
|
||||
<span>Líquido: {formatCurrency(doc.valor_liquido)}</span>
|
||||
<span>ISS: {formatCurrency(doc.iss)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,278 +1,235 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
BarChart3,
|
||||
Download,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
PieChart,
|
||||
FileText,
|
||||
Filter
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
LineChart, Line, PieChart as RechartsPie, Pie, Cell, Legend,
|
||||
AreaChart, Area
|
||||
} from 'recharts'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { FileText, Loader2, Download, Calendar, TrendingUp, Building2, ClipboardList, Leaf } from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
|
||||
const monthlyData = [
|
||||
{ name: 'Jan', orcamento: 120, gasto: 95, economia: 25 },
|
||||
{ name: 'Fev', orcamento: 115, gasto: 110, economia: 5 },
|
||||
{ name: 'Mar', orcamento: 130, gasto: 105, economia: 25 },
|
||||
{ name: 'Abr', orcamento: 125, gasto: 120, economia: 5 },
|
||||
{ name: 'Mai', orcamento: 140, gasto: 115, economia: 25 },
|
||||
{ name: 'Jun', orcamento: 135, gasto: 125, economia: 10 },
|
||||
]
|
||||
type TabKey = 'orcamento' | 'demandas' | 'fornecedores' | 'os' | 'esg_impacto' | 'esg_fornecedores' | 'esg_preventiva' | 'esg_governanca'
|
||||
|
||||
const categoryData = [
|
||||
{ name: 'Manutenção', value: 35, color: '#E65100' },
|
||||
{ name: 'Limpeza', value: 25, color: '#1A237E' },
|
||||
{ name: 'Segurança', value: 20, color: '#FF8F00' },
|
||||
{ name: 'Utilities', value: 12, color: '#2E7D32' },
|
||||
{ name: 'Outros', value: 8, color: '#757575' },
|
||||
]
|
||||
|
||||
const trendData = [
|
||||
{ name: 'Sem 1', demandas: 15, os: 12 },
|
||||
{ name: 'Sem 2', demandas: 22, os: 18 },
|
||||
{ name: 'Sem 3', demandas: 18, os: 16 },
|
||||
{ name: 'Sem 4', demandas: 25, os: 22 },
|
||||
]
|
||||
|
||||
const fornecedorData = [
|
||||
{ name: 'Tech Solutions', atendimentos: 45, satisfacao: 4.5 },
|
||||
{ name: 'EletroFix', atendimentos: 38, satisfacao: 4.8 },
|
||||
{ name: 'HidroServ', atendimentos: 32, satisfacao: 4.2 },
|
||||
{ name: 'CleanPro', atendimentos: 28, satisfacao: 4.6 },
|
||||
{ name: 'ElevaTech', atendimentos: 15, satisfacao: 3.9 },
|
||||
const tabs: { key: TabKey; label: string; icon: React.ReactNode; esg?: boolean }[] = [
|
||||
{ key: 'orcamento', label: 'Orçamento', icon: <TrendingUp className="w-4 h-4" /> },
|
||||
{ key: 'demandas', label: 'Demandas', icon: <FileText className="w-4 h-4" /> },
|
||||
{ key: 'fornecedores', label: 'Fornecedores', icon: <Building2 className="w-4 h-4" /> },
|
||||
{ key: 'os', label: 'OS', icon: <ClipboardList className="w-4 h-4" /> },
|
||||
{ key: 'esg_impacto', label: 'Impacto Ambiental', icon: <Leaf className="w-4 h-4" />, esg: true },
|
||||
{ key: 'esg_fornecedores', label: 'ESG Fornecedores', icon: <Leaf className="w-4 h-4" />, esg: true },
|
||||
{ key: 'esg_preventiva', label: 'Evolução Preventiva', icon: <Leaf className="w-4 h-4" />, esg: true },
|
||||
{ key: 'esg_governanca', label: 'Exceções Governança', icon: <Leaf className="w-4 h-4" />, esg: true },
|
||||
]
|
||||
|
||||
export default function Relatorios() {
|
||||
const [period, setPeriod] = useState('mensal')
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('orcamento')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
|
||||
useEffect(() => { fetchData() }, [activeTab, dateFrom, dateTo])
|
||||
|
||||
const endpoints: Record<TabKey, string> = {
|
||||
orcamento: '/relatorios/orcamento-mensal',
|
||||
demandas: '/relatorios/demandas-periodo',
|
||||
fornecedores: '/relatorios/fornecedores-ranking',
|
||||
os: '/relatorios/os-performance',
|
||||
esg_impacto: '/relatorios/esg-impacto-ambiental',
|
||||
esg_fornecedores: '/relatorios/esg-fornecedores',
|
||||
esg_preventiva: '/relatorios/esg-evolucao-preventiva',
|
||||
esg_governanca: '/relatorios/esg-excecoes-governanca',
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: any = {}
|
||||
if (dateFrom) params.data_inicio = dateFrom
|
||||
if (dateTo) params.data_fim = dateTo
|
||||
const { data } = await api.get(endpoints[activeTab], { params })
|
||||
setData(data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching report:', err)
|
||||
setData(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderEsgImpacto = () => {
|
||||
if (!data) return null
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{data.resumo && (
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
{data.resumo.map((r: any) => (
|
||||
<div key={r.impacto} className={`p-4 rounded-xl ${r.impacto === 'Alto' ? 'bg-red-50' : r.impacto === 'Médio' ? 'bg-amber-50' : 'bg-green-50'}`}>
|
||||
<p className="text-sm text-gray">Impacto {r.impacto}</p>
|
||||
<p className="text-2xl font-bold">{r.total}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{renderGenericTable(data.detalhes || [])}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderEsgFornecedores = () => {
|
||||
if (!data) return null
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{data.resumo && (
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
{data.resumo.map((r: any) => (
|
||||
<div key={r.classificacao_esg} className={`p-4 rounded-xl ${r.classificacao_esg === 'Avançado' ? 'bg-green-50' : r.classificacao_esg === 'Intermediário' ? 'bg-amber-50' : 'bg-red-50'}`}>
|
||||
<p className="text-sm text-gray">{r.classificacao_esg}</p>
|
||||
<p className="text-2xl font-bold">{r.total}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{data.fornecedores && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead><tr className="table-header">
|
||||
<th className="table-cell">Fornecedor</th>
|
||||
<th className="table-cell">ESG</th>
|
||||
<th className="table-cell">Rating</th>
|
||||
<th className="table-cell">Pol. Ambiental</th>
|
||||
<th className="table-cell">SST</th>
|
||||
<th className="table-cell">EPI</th>
|
||||
<th className="table-cell">Treinada</th>
|
||||
<th className="table-cell">Total OS</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{data.fornecedores.map((f: any) => (
|
||||
<tr key={f.id} className="table-row">
|
||||
<td className="table-cell font-medium">{f.razao_social}</td>
|
||||
<td className="table-cell">
|
||||
<span className={`badge ${f.classificacao_esg === 'Avançado' ? 'badge-success' : f.classificacao_esg === 'Intermediário' ? 'badge-warning' : 'badge-error'}`}>
|
||||
{f.classificacao_esg || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">{Number(f.rating).toFixed(1)}</td>
|
||||
<td className="table-cell">{f.possui_politica_ambiental ? '✅' : '❌'}</td>
|
||||
<td className="table-cell">{f.possui_politica_sst ? '✅' : '❌'}</td>
|
||||
<td className="table-cell">{f.declara_uso_epi ? '✅' : '❌'}</td>
|
||||
<td className="table-cell">{f.equipe_treinada ? '✅' : '❌'}</td>
|
||||
<td className="table-cell">{f.total_os}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderGenericTable = (items: any[]) => {
|
||||
if (!items || items.length === 0) return <p className="text-gray text-center py-8">Nenhum dado.</p>
|
||||
const keys = Object.keys(items[0]).filter(k => k !== 'id')
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead><tr className="table-header">
|
||||
{keys.map(k => <th key={k} className="table-cell text-left capitalize">{k.replace(/_/g, ' ')}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{items.map((item: any, i: number) => (
|
||||
<tr key={i} className="table-row">
|
||||
{keys.map(k => (
|
||||
<td key={k} className="table-cell text-sm">
|
||||
{typeof item[k] === 'number' ? item[k].toLocaleString('pt-BR', { maximumFractionDigits: 2 }) : String(item[k] ?? '-')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) return <div className="flex justify-center py-12"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>
|
||||
if (!data) return <div className="text-center py-12"><FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" /><p className="text-gray">Nenhum dado disponível.</p></div>
|
||||
|
||||
// ESG specific renderers
|
||||
if (activeTab === 'esg_impacto') return renderEsgImpacto()
|
||||
if (activeTab === 'esg_fornecedores') return renderEsgFornecedores()
|
||||
if (activeTab === 'esg_governanca') {
|
||||
const itens = data.itens || []
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-gray mb-4">Total de exceções: <strong>{data.total}</strong></p>
|
||||
{renderGenericTable(itens.map((w: any) => ({
|
||||
demanda: w.demanda_numero ? `#${w.demanda_numero} - ${w.demanda_titulo}` : w.demanda_titulo,
|
||||
valor: w.valor_total,
|
||||
status: w.status,
|
||||
})))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Generic
|
||||
const items = Array.isArray(data) ? data : data?.items || data?.dados || data?.detalhes || []
|
||||
if (Array.isArray(items) && items.length > 0) return renderGenericTable(items)
|
||||
|
||||
if (typeof data === 'object' && !Array.isArray(data)) {
|
||||
const entries = Object.entries(data).filter(([k]) => !['items', 'dados', 'detalhes', 'itens', 'resumo', 'fornecedores'].includes(k))
|
||||
if (entries.length > 0) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k} className="p-4 bg-gray-50 rounded-xl">
|
||||
<p className="text-sm text-gray capitalize">{k.replace(/_/g, ' ')}</p>
|
||||
<p className="text-xl font-bold text-text">
|
||||
{typeof v === 'number' ? v.toLocaleString('pt-BR', { maximumFractionDigits: 2 }) : String(v)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
return <p className="text-center text-gray py-8">Formato de dados não reconhecido.</p>
|
||||
}
|
||||
|
||||
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">Relatórios</h1>
|
||||
<p className="text-gray mt-1">Análises e métricas de facilities</p>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-text flex items-center gap-2">
|
||||
<FileText className="w-8 h-8 text-primary" /> Relatórios
|
||||
</h1>
|
||||
<p className="text-gray mt-1">Relatórios detalhados por área.</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value)}
|
||||
className="input-field w-40"
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-gray" />
|
||||
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} className="input-field text-sm" />
|
||||
<span className="text-gray">—</span>
|
||||
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} className="input-field text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-gray-100 p-1 rounded-xl overflow-x-auto">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
|
||||
activeTab === tab.key
|
||||
? `bg-white shadow-sm ${tab.esg ? '' : 'text-primary'}`
|
||||
: 'text-gray hover:text-text'
|
||||
}`}
|
||||
style={activeTab === tab.key && tab.esg ? { color: '#1A7A4C' } : {}}
|
||||
>
|
||||
<option value="semanal">Semanal</option>
|
||||
<option value="mensal">Mensal</option>
|
||||
<option value="trimestral">Trimestral</option>
|
||||
<option value="anual">Anual</option>
|
||||
</select>
|
||||
<button className="btn-primary flex items-center gap-2">
|
||||
<Download className="w-5 h-5" />
|
||||
Exportar
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<TrendingUp className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<span className="text-sm text-gray">Economia Total</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-text">95K</p>
|
||||
<p className="text-xs text-green-600 mt-1">+12% vs período anterior</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-secondary/10 flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-secondary" />
|
||||
</div>
|
||||
<span className="text-sm text-gray">Demandas</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-text">156</p>
|
||||
<p className="text-xs text-green-600 mt-1">+8% vs período anterior</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center">
|
||||
<BarChart3 className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<span className="text-sm text-gray">OS Concluídas</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-text">142</p>
|
||||
<p className="text-xs text-green-600 mt-1">91% taxa de conclusão</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center">
|
||||
<TrendingDown className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<span className="text-sm text-gray">Tempo Médio</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-text">3.2 dias</p>
|
||||
<p className="text-xs text-green-600 mt-1">-15% vs período anterior</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts Row 1 */}
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
{/* Bar Chart - Orçamento vs Gasto */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text">Orçamento vs Gasto</h2>
|
||||
<p className="text-sm text-gray">Comparativo mensal (em milhares)</p>
|
||||
</div>
|
||||
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray">
|
||||
<Filter className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={monthlyData} barGap={8}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #E0E0E0',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="orcamento" fill="#1A237E" radius={[4, 4, 0, 0]} name="Orçamento" />
|
||||
<Bar dataKey="gasto" fill="#E65100" radius={[4, 4, 0, 0]} name="Gasto" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pie Chart - Por Categoria */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text">Gastos por Categoria</h2>
|
||||
<p className="text-sm text-gray">Distribuição percentual</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsPie>
|
||||
<Pie
|
||||
data={categoryData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={4}
|
||||
dataKey="value"
|
||||
>
|
||||
{categoryData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
formatter={(value) => <span className="text-sm text-gray">{value}</span>}
|
||||
/>
|
||||
<Tooltip />
|
||||
</RechartsPie>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts Row 2 */}
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
{/* Area Chart - Tendência */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text">Tendência Semanal</h2>
|
||||
<p className="text-sm text-gray">Demandas vs Ordens de Serviço</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={trendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #E0E0E0',
|
||||
borderRadius: '12px'
|
||||
}}
|
||||
/>
|
||||
<Area type="monotone" dataKey="demandas" stroke="#E65100" fill="#E65100" fillOpacity={0.2} name="Demandas" />
|
||||
<Area type="monotone" dataKey="os" stroke="#1A237E" fill="#1A237E" fillOpacity={0.2} name="Ordens de Serviço" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bar Chart - Fornecedores */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text">Top Fornecedores</h2>
|
||||
<p className="text-sm text-gray">Por número de atendimentos</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={fornecedorData} layout="vertical" barSize={20}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" horizontal={false} />
|
||||
<XAxis type="number" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||
<YAxis type="category" dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} width={100} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #E0E0E0',
|
||||
borderRadius: '12px'
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="atendimentos" fill="#FF8F00" radius={[0, 4, 4, 0]} name="Atendimentos" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Reports */}
|
||||
{/* Content */}
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-text mb-4">Relatórios Disponíveis</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ name: 'Relatório Mensal', desc: 'Resumo completo do mês', icon: <Calendar className="w-5 h-5" /> },
|
||||
{ name: 'Análise de Custos', desc: 'Detalhamento por categoria', icon: <PieChart className="w-5 h-5" /> },
|
||||
{ name: 'Performance Fornecedores', desc: 'Avaliação e métricas', icon: <TrendingUp className="w-5 h-5" /> },
|
||||
{ name: 'Histórico de Demandas', desc: 'Todas as solicitações', icon: <FileText className="w-5 h-5" /> },
|
||||
].map((report, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="flex items-center gap-4 p-4 bg-card rounded-xl hover:bg-gray-100 transition-all text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
|
||||
{report.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-text">{report.name}</p>
|
||||
<p className="text-xs text-gray">{report.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
id: number | string;
|
||||
nome: string;
|
||||
email: string;
|
||||
perfil: string;
|
||||
@@ -11,62 +11,187 @@ export interface AuthResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
total_orcamento?: number;
|
||||
total_gasto?: number;
|
||||
economia?: number;
|
||||
pendencias?: number;
|
||||
demandas_pendentes?: number;
|
||||
ordens_abertas?: number;
|
||||
fornecedores_ativos?: number;
|
||||
contratos_vigentes?: number;
|
||||
export interface DashboardIndicadores {
|
||||
demandas_abertas: number;
|
||||
em_cotacao: number;
|
||||
pendentes: number;
|
||||
em_aprovacao: number;
|
||||
os_ativas: number;
|
||||
alertas: number;
|
||||
}
|
||||
|
||||
export interface Demanda {
|
||||
id: number;
|
||||
id: string;
|
||||
numero: number;
|
||||
titulo: string;
|
||||
descricao: string;
|
||||
local_id: string;
|
||||
centro_custo_id: string;
|
||||
categoria_id: string;
|
||||
subcategoria_id: string;
|
||||
criticidade: string;
|
||||
data_desejada: string;
|
||||
status: string;
|
||||
prioridade: string;
|
||||
solicitante_id: number;
|
||||
solicitante_id: string | number;
|
||||
gestor_id: string;
|
||||
valor_estimado: number | null;
|
||||
documentos: any[];
|
||||
itens_linha: any[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// legacy compat
|
||||
solicitante_nome?: string;
|
||||
data_criacao: string;
|
||||
data_atualizacao?: string;
|
||||
prioridade?: string;
|
||||
data_criacao?: string;
|
||||
impacto_ambiental_demanda?: string;
|
||||
justificativa_manutencao_emergencial?: string;
|
||||
// computed
|
||||
_documentos_count?: number;
|
||||
}
|
||||
|
||||
export interface Subcategoria {
|
||||
id: string;
|
||||
nome: string;
|
||||
categoria_id: string;
|
||||
ativo?: boolean;
|
||||
}
|
||||
|
||||
export interface DocumentoFile {
|
||||
id: string;
|
||||
demanda_id: string;
|
||||
nome_arquivo: string;
|
||||
tipo: string;
|
||||
caminho: string;
|
||||
tamanho: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface OrdemServico {
|
||||
id: number;
|
||||
numero: string;
|
||||
demanda_id: number;
|
||||
fornecedor_id: number;
|
||||
fornecedor_nome?: string;
|
||||
id: string;
|
||||
numero: number | string;
|
||||
demanda_id: string;
|
||||
proposta_id: string;
|
||||
fornecedor_id: string;
|
||||
valor: number;
|
||||
status: string;
|
||||
valor?: number;
|
||||
data_criacao: string;
|
||||
data_conclusao?: string;
|
||||
data: string;
|
||||
data_inicio: string;
|
||||
data_conclusao: string;
|
||||
valor_bruto: number;
|
||||
valor_liquido: number;
|
||||
iss: number;
|
||||
inss: number;
|
||||
pcc: number;
|
||||
condicao_pagamento: string;
|
||||
prazo_execucao: string;
|
||||
data_estimada_entrega: string;
|
||||
observacoes: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// legacy compat
|
||||
fornecedor_nome?: string;
|
||||
descricao?: string;
|
||||
data_criacao?: string;
|
||||
}
|
||||
|
||||
export interface DocumentoVersao {
|
||||
id: string;
|
||||
ordem_servico_id: string;
|
||||
fornecedor_id: string;
|
||||
nome_arquivo: string;
|
||||
caminho: string;
|
||||
tamanho: number;
|
||||
versao: number;
|
||||
valor_bruto: number;
|
||||
valor_liquido: number;
|
||||
iss: number;
|
||||
inss: number;
|
||||
pcc: number;
|
||||
condicao_pagamento: string;
|
||||
prazo_execucao: string;
|
||||
data_estimada_entrega: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Fornecedor {
|
||||
id: number;
|
||||
id: string;
|
||||
tipo_pessoa: string;
|
||||
cpf_cnpj: string;
|
||||
razao_social: string;
|
||||
cnpj: string;
|
||||
nome_fantasia: string;
|
||||
email: string;
|
||||
telefone: string;
|
||||
endereco?: string;
|
||||
endereco: string;
|
||||
categorias_atendidas: string[];
|
||||
rating: number;
|
||||
usuario_id: string;
|
||||
nome_contato: string;
|
||||
possui_politica_ambiental: boolean;
|
||||
possui_politica_sst: boolean;
|
||||
declara_uso_epi: boolean;
|
||||
equipe_treinada: boolean;
|
||||
classificacao_esg: string;
|
||||
ativo: boolean;
|
||||
certidoes: any[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// legacy compat
|
||||
cnpj?: string;
|
||||
especialidades?: string[];
|
||||
avaliacao?: number;
|
||||
}
|
||||
|
||||
export interface Orcamento {
|
||||
id: number;
|
||||
id: string;
|
||||
ano: number;
|
||||
mes: number;
|
||||
categoria: string;
|
||||
valor_previsto: number;
|
||||
centro_custo_id: string;
|
||||
categoria_id: string;
|
||||
valor_planejado: number;
|
||||
valor_comprometido: number;
|
||||
valor_realizado: number;
|
||||
status: string;
|
||||
tipo_periodo: string;
|
||||
valor_anual: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// legacy compat
|
||||
categoria?: string;
|
||||
valor_previsto?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface Categoria {
|
||||
id: string;
|
||||
nome: string;
|
||||
subcategoria?: string;
|
||||
criticidade_padrao?: string;
|
||||
sla_dias?: number;
|
||||
categoria_pai_id?: string;
|
||||
tipo_investimento?: string;
|
||||
tipo_manutencao?: string;
|
||||
impacto_ambiental_esperado?: string;
|
||||
potencial_geracao_residuos?: string;
|
||||
ativo?: boolean;
|
||||
}
|
||||
|
||||
export interface CentroCusto {
|
||||
id: string;
|
||||
codigo: string;
|
||||
nome: string;
|
||||
responsavel_id?: string;
|
||||
ativo?: boolean;
|
||||
}
|
||||
|
||||
export interface Local {
|
||||
id: string;
|
||||
nome: string;
|
||||
endereco?: string;
|
||||
centro_custo_id?: string;
|
||||
responsavel_id?: string;
|
||||
tipo_operacao_local?: string;
|
||||
classificacao_impacto_ambiental?: string;
|
||||
praticas_sustentaveis?: string[];
|
||||
ativo?: boolean;
|
||||
}
|
||||
|
||||
export interface Contrato {
|
||||
|
||||
Reference in New Issue
Block a user