Major update: ESG, KPIs, metas, alertas, auditoria, documentos, importação, relatórios, subcategorias, dashboard orçamentos

This commit is contained in:
bigtux
2026-02-10 18:52:52 -03:00
parent d8ca580acb
commit 90c7a2cacb
92 changed files with 10265 additions and 1238 deletions

View File

@@ -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">

View File

@@ -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="/" />} />

View File

@@ -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)}

View 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>
)
}

View 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 }
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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
View 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>
)
}

View File

@@ -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>
)
}

View 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
View 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>
)
}

View File

@@ -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>

View File

@@ -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>

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)

View File

@@ -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 {