HEFESTO v1.0 - Sistema de Controle Orçamentário para Facilities
- Backend NestJS com 12 módulos - Frontend React com dashboard e gestão - Manuais técnico e de negócios (MD + PDF) - Workflow de aprovação com alçadas - RBAC com 6 perfis de acesso
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
16
frontend/README.md
Normal file
16
frontend/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
29
frontend/eslint.config.js
Normal file
29
frontend/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<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>
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4210
frontend/package-lock.json
generated
Normal file
4210
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
frontend/package.json
Normal file
35
frontend/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"axios": "^1.13.5",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"recharts": "^3.7.0",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^25.2.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
10
frontend/public/favicon.svg
Normal file
10
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="flame" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#E65100"/>
|
||||
<stop offset="100%" style="stop-color:#FF8F00"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100" height="100" rx="20" fill="url(#flame)"/>
|
||||
<path d="M50 20c-5 15 5 25 0 40-3-10-15-15-10-30 5 10 15 5 10-10z M50 25c8 12-2 22 5 35 2-8 12-12 8-25-4 8-12 4-13-10z" fill="white" opacity="0.9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 500 B |
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
39
frontend/src/App.tsx
Normal file
39
frontend/src/App.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import Landing from './pages/Landing'
|
||||
import Login from './pages/Login'
|
||||
import Layout from './components/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Demandas from './pages/Demandas'
|
||||
import Orcamentos from './pages/Orcamentos'
|
||||
import OrdensServico from './pages/OrdensServico'
|
||||
import Fornecedores from './pages/Fornecedores'
|
||||
import Relatorios from './pages/Relatorios'
|
||||
import Usuarios from './pages/Usuarios'
|
||||
|
||||
interface PrivateRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function PrivateRoute({ children }: PrivateRouteProps) {
|
||||
const token = localStorage.getItem('token')
|
||||
return token ? <>{children}</> : <Navigate to="/login" />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Landing />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/app" element={<PrivateRoute><Layout /></PrivateRoute>}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="demandas" element={<Demandas />} />
|
||||
<Route path="orcamentos" element={<Orcamentos />} />
|
||||
<Route path="ordens-servico" element={<OrdensServico />} />
|
||||
<Route path="fornecedores" element={<Fornecedores />} />
|
||||
<Route path="relatorios" element={<Relatorios />} />
|
||||
<Route path="usuarios" element={<Usuarios />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
255
frontend/src/components/Layout.tsx
Normal file
255
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
Wallet,
|
||||
ClipboardList,
|
||||
Building2,
|
||||
BarChart3,
|
||||
Users,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Flame,
|
||||
ChevronLeft,
|
||||
Bell,
|
||||
Search
|
||||
} from 'lucide-react'
|
||||
import { User } from '../types'
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ path: '/app', label: 'Dashboard', icon: <LayoutDashboard className="w-5 h-5" /> },
|
||||
{ path: '/app/demandas', label: 'Demandas', icon: <FileText className="w-5 h-5" /> },
|
||||
{ path: '/app/orcamentos', label: 'Orçamentos', icon: <Wallet className="w-5 h-5" /> },
|
||||
{ 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/usuarios', label: 'Usuários', icon: <Users className="w-5 h-5" />, adminOnly: true },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
const userData = localStorage.getItem('user')
|
||||
if (userData) {
|
||||
setUser(JSON.parse(userData))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const isAdmin = user?.perfil?.toLowerCase() === 'admin' || user?.perfil?.toLowerCase() === 'administrador'
|
||||
|
||||
const filteredNavItems = navItems.filter(item => !item.adminOnly || isAdmin)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
{/* Desktop Sidebar */}
|
||||
<aside
|
||||
className={`hidden lg:flex flex-col bg-secondary fixed h-screen transition-all duration-300 z-40 ${
|
||||
sidebarOpen ? 'w-64' : 'w-20'
|
||||
}`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="p-4 flex items-center justify-between border-b border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center shadow-lg">
|
||||
<Flame className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
{sidebarOpen && (
|
||||
<span className="font-bold text-white text-xl tracking-tight">HEFESTO</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="p-2 rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-all"
|
||||
>
|
||||
<ChevronLeft className={`w-5 h-5 transition-transform ${!sidebarOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4 space-y-2 overflow-y-auto">
|
||||
{filteredNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === '/app'}
|
||||
className={({ isActive }) => `
|
||||
flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200
|
||||
${isActive
|
||||
? 'bg-gradient-to-r from-primary to-accent text-white shadow-lg shadow-primary/30'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/10'
|
||||
}
|
||||
${!sidebarOpen ? 'justify-center px-3' : ''}
|
||||
`}
|
||||
>
|
||||
{item.icon}
|
||||
{sidebarOpen && <span className="font-medium">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User section */}
|
||||
<div className="p-4 border-t border-white/10">
|
||||
{sidebarOpen ? (
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 flex items-center justify-center">
|
||||
<span className="text-white font-semibold">
|
||||
{user?.nome?.charAt(0).toUpperCase() || 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium truncate">{user?.nome || 'Usuário'}</p>
|
||||
<p className="text-white/50 text-sm truncate">{user?.perfil || 'Perfil'}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={`flex items-center gap-3 w-full px-4 py-3 rounded-xl text-white/70 hover:text-white hover:bg-red-500/20 transition-all ${
|
||||
!sidebarOpen ? 'justify-center px-3' : ''
|
||||
}`}
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
{sidebarOpen && <span className="font-medium">Sair</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Sidebar */}
|
||||
<aside
|
||||
className={`lg:hidden fixed inset-y-0 left-0 w-72 bg-secondary z-50 transform transition-transform duration-300 ${
|
||||
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="p-4 flex items-center justify-between border-b border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="p-2 rounded-lg text-white/60 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="p-4 space-y-2">
|
||||
{filteredNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === '/app'}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={({ isActive }) => `
|
||||
flex items-center gap-3 px-4 py-3 rounded-xl transition-all
|
||||
${isActive
|
||||
? 'bg-gradient-to-r from-primary to-accent text-white'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/10'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-white/10">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 flex items-center justify-center">
|
||||
<span className="text-white font-semibold">
|
||||
{user?.nome?.charAt(0).toUpperCase() || 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium truncate">{user?.nome || 'Usuário'}</p>
|
||||
<p className="text-white/50 text-sm truncate">{user?.perfil || 'Perfil'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 rounded-xl text-white/70 hover:text-white hover:bg-red-500/20 transition-all"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="font-medium">Sair</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className={`flex-1 transition-all duration-300 ${sidebarOpen ? 'lg:ml-64' : 'lg:ml-20'}`}>
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-border sticky top-0 z-30">
|
||||
<div className="flex items-center justify-between px-4 lg:px-6 h-16">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="lg:hidden p-2 rounded-lg text-gray hover:text-text hover:bg-gray-100 transition-all"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
<div className="hidden sm:flex items-center gap-2 px-4 py-2 bg-gray-50 rounded-xl">
|
||||
<Search className="w-5 h-5 text-gray-light" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar..."
|
||||
className="bg-transparent border-none outline-none text-sm w-48 placeholder-gray-light"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="p-2 rounded-xl text-gray hover:text-primary hover:bg-primary/5 transition-all relative">
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-primary rounded-full"></span>
|
||||
</button>
|
||||
<div className="hidden sm:flex items-center gap-2 pl-3 border-l border-border">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-accent flex items-center justify-center">
|
||||
<span className="text-white text-sm font-semibold">
|
||||
{user?.nome?.charAt(0).toUpperCase() || 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-text">{user?.nome?.split(' ')[0] || 'Usuário'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="p-4 lg:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
282
frontend/src/index.css
Normal file
282
frontend/src/index.css
Normal file
@@ -0,0 +1,282 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary: #E65100;
|
||||
--color-primary-dark: #BF360C;
|
||||
--color-secondary: #1A237E;
|
||||
--color-secondary-light: #3949AB;
|
||||
--color-accent: #FF8F00;
|
||||
--color-accent-light: #FFB300;
|
||||
--color-text: #212121;
|
||||
--color-gray: #757575;
|
||||
--color-gray-light: #9E9E9E;
|
||||
--color-card: #FAFAFA;
|
||||
--color-border: #E0E0E0;
|
||||
--font-family-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family-sans);
|
||||
background-color: #FFFFFF;
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #F5F5F5;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #BDBDBD;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #9E9E9E;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
from { opacity: 0; transform: translateX(-20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 20px rgba(230, 81, 0, 0.3); }
|
||||
50% { box-shadow: 0 0 40px rgba(230, 81, 0, 0.5); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in-left {
|
||||
animation: slide-in-left 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn-primary {
|
||||
background: linear-gradient(to right, var(--color-primary), var(--color-accent));
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
box-shadow: 0 10px 15px -3px rgba(230, 81, 0, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--color-secondary);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--color-secondary-light);
|
||||
box-shadow: 0 10px 15px -3px rgba(26, 35, 126, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
border: 2px solid var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
transition: all 0.2s;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
color: var(--color-gray);
|
||||
font-weight: 500;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: all 0.2s;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background-color: #F5F5F5;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input-field {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: white;
|
||||
color: var(--color-text);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.input-field::placeholder {
|
||||
color: var(--color-gray-light);
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
ring: 2px;
|
||||
ring-color: rgba(230, 81, 0, 0.2);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
background-color: white;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
background-color: white;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
border-color: rgba(230, 81, 0, 0.3);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Badge styles */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #DCFCE7;
|
||||
color: #15803D;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: #FEF3C7;
|
||||
color: #B45309;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background-color: #FEE2E2;
|
||||
color: #DC2626;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: #DBEAFE;
|
||||
color: #1D4ED8;
|
||||
}
|
||||
|
||||
.badge-neutral {
|
||||
background-color: #F3F4F6;
|
||||
color: #4B5563;
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.table-header {
|
||||
background-color: var(--color-card);
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-gray);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background-color: rgba(250, 250, 250, 0.5);
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Skeleton loading */
|
||||
.skeleton {
|
||||
background: linear-gradient(to right, #E5E7EB, #F3F4F6, #E5E7EB);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
297
frontend/src/pages/Dashboard.tsx
Normal file
297
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Wallet,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Clock,
|
||||
FileText,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
ArrowUpRight,
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts'
|
||||
import api from '../services/api'
|
||||
import { User } from '../types'
|
||||
|
||||
interface StatsCard {
|
||||
title: string;
|
||||
value: string | number;
|
||||
change?: string;
|
||||
changeType?: 'positive' | 'negative' | 'neutral';
|
||||
icon: React.ReactNode;
|
||||
color: 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' },
|
||||
]
|
||||
|
||||
export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [stats, setStats] = useState<any>(null)
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const userData = localStorage.getItem('user')
|
||||
if (userData) {
|
||||
setUser(JSON.parse(userData))
|
||||
}
|
||||
fetchDashboard()
|
||||
}, [])
|
||||
|
||||
const fetchDashboard = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/dashboard')
|
||||
setStats(data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching dashboard:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
},
|
||||
]
|
||||
|
||||
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">
|
||||
{/* Welcome 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">
|
||||
Olá, {user?.nome?.split(' ')[0] || 'Usuário'}! 👋
|
||||
</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>
|
||||
<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>
|
||||
|
||||
{/* 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" />}
|
||||
</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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
310
frontend/src/pages/Demandas.tsx
Normal file
310
frontend/src/pages/Demandas.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
FileText,
|
||||
Search,
|
||||
Plus,
|
||||
Filter,
|
||||
Eye,
|
||||
Edit2,
|
||||
Trash2,
|
||||
X,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
ChevronDown
|
||||
} from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
import { Demanda } from '../types'
|
||||
|
||||
const statusConfig: Record<string, { label: string; class: string; icon: React.ReactNode }> = {
|
||||
'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 }> = {
|
||||
'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' },
|
||||
}
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
export default function Demandas() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [demandas, setDemandas] = useState<Demanda[]>([])
|
||||
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' })
|
||||
|
||||
useEffect(() => {
|
||||
fetchDemandas()
|
||||
}, [])
|
||||
|
||||
const fetchDemandas = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/demandas')
|
||||
setDemandas(data.length > 0 ? data : mockDemandas)
|
||||
} catch (err) {
|
||||
console.error('Error fetching demandas:', err)
|
||||
setDemandas(mockDemandas)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredDemandas = demandas.filter(demanda => {
|
||||
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' })
|
||||
}
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowModal(false)
|
||||
setSelectedDemanda(null)
|
||||
setFormData({ titulo: '', descricao: '', prioridade: 'media' })
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
setDemandas([newDemanda, ...demandas])
|
||||
}
|
||||
handleCloseModal()
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('pt-BR')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-text">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>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{Object.entries(statusConfig).slice(0, 4).map(([key, config]) => {
|
||||
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>
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
306
frontend/src/pages/Fornecedores.tsx
Normal file
306
frontend/src/pages/Fornecedores.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
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'
|
||||
|
||||
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 },
|
||||
]
|
||||
|
||||
export default function Fornecedores() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fornecedores, setFornecedores] = useState<Fornecedor[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [filterAtivo, setFilterAtivo] = useState('todos')
|
||||
const [selectedFornecedor, setSelectedFornecedor] = useState<Fornecedor | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchFornecedores()
|
||||
}, [])
|
||||
|
||||
const fetchFornecedores = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/fornecedores')
|
||||
setFornecedores(data.length > 0 ? data : mockFornecedores)
|
||||
} 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)
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-text">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>
|
||||
</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>
|
||||
|
||||
{/* 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"
|
||||
/>
|
||||
</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'
|
||||
}`}
|
||||
>
|
||||
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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 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>
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
</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>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{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>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-secondary to-secondary-light flex items-center justify-center">
|
||||
<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>
|
||||
</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>
|
||||
<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(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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
253
frontend/src/pages/Landing.tsx
Normal file
253
frontend/src/pages/Landing.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Flame,
|
||||
Shield,
|
||||
BarChart3,
|
||||
Zap,
|
||||
CheckCircle2,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
FileText,
|
||||
Wallet,
|
||||
Users
|
||||
} from 'lucide-react'
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <Wallet className="w-6 h-6" />,
|
||||
title: 'Controle Orçamentário',
|
||||
description: 'Gerencie seus orçamentos de facilities com precisão e visibilidade total.'
|
||||
},
|
||||
{
|
||||
icon: <FileText className="w-6 h-6" />,
|
||||
title: 'Gestão de Demandas',
|
||||
description: 'Acompanhe todas as solicitações desde a criação até a conclusão.'
|
||||
},
|
||||
{
|
||||
icon: <Building2 className="w-6 h-6" />,
|
||||
title: 'Fornecedores',
|
||||
description: 'Cadastro completo e avaliação de fornecedores parceiros.'
|
||||
},
|
||||
{
|
||||
icon: <BarChart3 className="w-6 h-6" />,
|
||||
title: 'Relatórios Inteligentes',
|
||||
description: 'Dashboards e relatórios para decisões estratégicas.'
|
||||
}
|
||||
]
|
||||
|
||||
const stats = [
|
||||
{ value: '98%', label: 'Satisfação' },
|
||||
{ value: '50+', label: 'Empresas' },
|
||||
{ value: '10k+', label: 'Demandas' },
|
||||
{ value: '24/7', label: 'Suporte' }
|
||||
]
|
||||
|
||||
const benefits = [
|
||||
'Redução de custos operacionais',
|
||||
'Maior controle e transparência',
|
||||
'Processos automatizados',
|
||||
'Relatórios em tempo real',
|
||||
'Integração com fornecedores',
|
||||
'Aprovações simplificadas'
|
||||
]
|
||||
|
||||
export default function Landing() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Header */}
|
||||
<header className="fixed top-0 left-0 right-0 bg-white/80 backdrop-blur-lg border-b border-border z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/login"
|
||||
className="hidden sm:inline-flex text-gray hover:text-primary font-medium transition-colors"
|
||||
>
|
||||
Entrar
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
className="btn-primary !py-2 !px-5 text-sm"
|
||||
>
|
||||
Começar Agora
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="pt-32 pb-20 px-4 sm:px-6 lg:px-8 relative overflow-hidden">
|
||||
{/* Background decorations */}
|
||||
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-gradient-to-br from-primary/10 to-accent/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/3"></div>
|
||||
<div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-gradient-to-tr from-secondary/10 to-secondary/5 rounded-full blur-3xl translate-y-1/2 -translate-x-1/3"></div>
|
||||
|
||||
<div className="max-w-7xl mx-auto relative">
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
{/* Badge */}
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium mb-8">
|
||||
<Zap className="w-4 h-4" />
|
||||
Sistema de Controle Orçamentário para Facilities
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-text mb-6 leading-tight">
|
||||
Forje o{' '}
|
||||
<span className="bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
||||
controle
|
||||
</span>
|
||||
{' '}dos seus custos
|
||||
</h1>
|
||||
|
||||
{/* Subheadline */}
|
||||
<p className="text-lg sm:text-xl text-gray max-w-2xl mx-auto mb-10">
|
||||
Gerencie orçamentos, demandas e fornecedores em uma única plataforma poderosa.
|
||||
Transforme a gestão de facilities da sua empresa.
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-16">
|
||||
<Link to="/login" className="btn-primary text-lg !py-4 !px-8 flex items-center gap-2 w-full sm:w-auto justify-center">
|
||||
Acessar Sistema
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
<a href="#features" className="btn-outline text-lg !py-4 !px-8 w-full sm:w-auto">
|
||||
Conhecer Recursos
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-6 sm:gap-8">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="text-3xl sm:text-4xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
||||
{stat.value}
|
||||
</div>
|
||||
<div className="text-gray text-sm mt-1">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section id="features" className="py-20 px-4 sm:px-6 lg:px-8 bg-card">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-text mb-4">
|
||||
Tudo que você precisa para{' '}
|
||||
<span className="text-primary">gestão de facilities</span>
|
||||
</h2>
|
||||
<p className="text-gray text-lg max-w-2xl mx-auto">
|
||||
Recursos completos para otimizar seus processos e reduzir custos operacionais.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{features.map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="card-hover group"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary/10 to-accent/10 flex items-center justify-center text-primary mb-4 group-hover:from-primary group-hover:to-accent group-hover:text-white transition-all duration-300">
|
||||
{feature.icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-text mb-2">{feature.title}</h3>
|
||||
<p className="text-gray text-sm">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefits Section */}
|
||||
<section className="py-20 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-text mb-6">
|
||||
Por que escolher o{' '}
|
||||
<span className="text-primary">HEFESTO</span>?
|
||||
</h2>
|
||||
<p className="text-gray text-lg mb-8">
|
||||
Nossa plataforma foi desenvolvida especificamente para atender às necessidades
|
||||
complexas da gestão de facilities, oferecendo controle total sobre orçamentos e processos.
|
||||
</p>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{benefits.map((benefit, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-primary flex-shrink-0" />
|
||||
<span className="text-text">{benefit}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="bg-gradient-to-br from-primary to-accent rounded-3xl p-8 text-white">
|
||||
<Shield className="w-12 h-12 mb-6 opacity-80" />
|
||||
<h3 className="text-2xl font-bold mb-4">Segurança Garantida</h3>
|
||||
<p className="text-white/80 mb-6">
|
||||
Seus dados estão protegidos com as mais avançadas tecnologias de segurança.
|
||||
Conformidade total com LGPD.
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex -space-x-2">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="w-8 h-8 rounded-full bg-white/20 border-2 border-white flex items-center justify-center">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-white/80 text-sm">+50 empresas confiam</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -bottom-6 -right-6 w-32 h-32 bg-accent/20 rounded-full blur-2xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 px-4 sm:px-6 lg:px-8 bg-secondary">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-6">
|
||||
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
|
||||
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">
|
||||
Começar Gratuitamente
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 px-4 sm:px-6 lg:px-8 bg-card border-t border-border">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-gray text-sm">
|
||||
© 2026 HEFESTO. Todos os direitos reservados.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
172
frontend/src/pages/Login.tsx
Normal file
172
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Lock, Mail, Flame, ArrowLeft, Eye, EyeOff, Loader2 } from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
|
||||
interface DemoUser {
|
||||
email: string;
|
||||
role: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const demoUsers: DemoUser[] = [
|
||||
{ email: 'admin@hefesto.com', role: 'Admin', color: '#E65100' },
|
||||
{ email: 'joao.santos@hefesto.com', role: 'Gestor', color: '#1A237E' },
|
||||
{ email: 'ana.oliveira@hefesto.com', role: 'Financeiro', color: '#2E7D32' },
|
||||
{ email: 'maria.silva@hefesto.com', role: 'Solicitante', color: '#7B1FA2' },
|
||||
]
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [senha, setSenha] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const { data } = await api.post('/auth/login', { email, senha })
|
||||
localStorage.setItem('token', data.access_token)
|
||||
localStorage.setItem('user', JSON.stringify(data.user))
|
||||
navigate('/app')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Credenciais inválidas. Verifique e tente novamente.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fillDemoUser = (userEmail: string) => {
|
||||
setEmail(userEmail)
|
||||
setSenha('123456')
|
||||
setError('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-white flex items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Background decorations */}
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-gradient-to-br from-primary/10 to-accent/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/3 pointer-events-none"></div>
|
||||
<div className="absolute bottom-0 left-0 w-[400px] h-[400px] bg-gradient-to-tr from-secondary/10 to-secondary/5 rounded-full blur-3xl translate-y-1/2 -translate-x-1/3 pointer-events-none"></div>
|
||||
|
||||
<div className="w-full max-w-md relative animate-fade-in">
|
||||
{/* Back to home */}
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 text-gray hover:text-primary transition-colors mb-8 group"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
||||
Voltar ao início
|
||||
</Link>
|
||||
|
||||
{/* Logo and header */}
|
||||
<div className="text-center mb-8">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Login card */}
|
||||
<div className="bg-white rounded-2xl p-8 shadow-xl border border-gray-100">
|
||||
<h2 className="text-xl font-semibold text-text mb-6 text-center">Acesse sua conta</h2>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-xl mb-6 text-sm flex items-start gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-red-500 text-xs font-bold">!</span>
|
||||
</div>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">E-mail</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="input-field pl-12"
|
||||
placeholder="seu@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Senha</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={senha}
|
||||
onChange={e => setSenha(e.target.value)}
|
||||
className="input-field pl-12 pr-12"
|
||||
placeholder="••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-light hover:text-primary transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !email || !senha}
|
||||
className="w-full btn-primary py-4 text-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
) : (
|
||||
'Entrar'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Demo users */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-light text-center mb-4">
|
||||
Usuários para demonstração — senha:{' '}
|
||||
<span className="text-primary font-semibold">123456</span>
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{demoUsers.map((user) => (
|
||||
<button
|
||||
key={user.email}
|
||||
type="button"
|
||||
onClick={() => fillDemoUser(user.email)}
|
||||
className={`text-left px-3 py-2.5 rounded-xl bg-card hover:bg-gray-100 transition-all border-2 ${
|
||||
email === user.email ? 'border-primary' : 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs text-text truncate font-medium">{user.email}</p>
|
||||
<p className="text-[10px] font-semibold mt-0.5" style={{ color: user.color }}>
|
||||
{user.role}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-gray-light text-sm mt-8">
|
||||
© 2026 HEFESTO. Todos os direitos reservados.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
254
frontend/src/pages/Orcamentos.tsx
Normal file
254
frontend/src/pages/Orcamentos.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Wallet,
|
||||
Search,
|
||||
Filter,
|
||||
Plus,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Calendar
|
||||
} from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
import { Orcamento } from '../types'
|
||||
|
||||
const statusConfig: Record<string, { label: string; class: string }> = {
|
||||
'dentro_limite': { label: 'Dentro do Limite', class: 'badge-success' },
|
||||
'alerta': { label: 'Alerta', class: 'badge-warning' },
|
||||
'excedido': { label: 'Excedido', class: 'badge-error' },
|
||||
'disponivel': { label: 'Disponível', class: 'badge-info' },
|
||||
}
|
||||
|
||||
const mockOrcamentos: Orcamento[] = [
|
||||
{ id: 1, ano: 2024, mes: 1, categoria: 'Manutenção Predial', valor_previsto: 50000, valor_realizado: 45000, status: 'dentro_limite' },
|
||||
{ id: 2, ano: 2024, mes: 1, categoria: 'Limpeza', valor_previsto: 30000, valor_realizado: 28500, status: 'dentro_limite' },
|
||||
{ id: 3, ano: 2024, mes: 1, categoria: 'Segurança', valor_previsto: 25000, valor_realizado: 26500, status: 'alerta' },
|
||||
{ id: 4, ano: 2024, mes: 1, categoria: 'Jardinagem', valor_previsto: 10000, valor_realizado: 12500, status: 'excedido' },
|
||||
{ id: 5, ano: 2024, mes: 2, categoria: 'Manutenção Predial', valor_previsto: 55000, valor_realizado: 42000, status: 'disponivel' },
|
||||
{ id: 6, ano: 2024, mes: 2, categoria: 'Utilities', valor_previsto: 35000, valor_realizado: 33000, status: 'dentro_limite' },
|
||||
]
|
||||
|
||||
export default function Orcamentos() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [orcamentos, setOrcamentos] = useState<Orcamento[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedYear, setSelectedYear] = useState(2024)
|
||||
const [selectedMonth, setSelectedMonth] = useState(0) // 0 = todos
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrcamentos()
|
||||
}, [])
|
||||
|
||||
const fetchOrcamentos = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/orcamento')
|
||||
setOrcamentos(data.length > 0 ? data : mockOrcamentos)
|
||||
} catch (err) {
|
||||
console.error('Error fetching orcamentos:', err)
|
||||
setOrcamentos(mockOrcamentos)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredOrcamentos = orcamentos.filter(orc => {
|
||||
const matchesSearch = orc.categoria.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesYear = orc.ano === selectedYear
|
||||
const matchesMonth = selectedMonth === 0 || orc.mes === selectedMonth
|
||||
return matchesSearch && matchesYear && matchesMonth
|
||||
})
|
||||
|
||||
const totalPrevisto = filteredOrcamentos.reduce((acc, orc) => acc + orc.valor_previsto, 0)
|
||||
const totalRealizado = filteredOrcamentos.reduce((acc, orc) => acc + orc.valor_realizado, 0)
|
||||
const economia = totalPrevisto - totalRealizado
|
||||
|
||||
const months = [
|
||||
'Todos', 'Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho',
|
||||
'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'
|
||||
]
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value)
|
||||
}
|
||||
|
||||
const getPercentage = (realizado: number, previsto: number) => {
|
||||
return ((realizado / previsto) * 100).toFixed(1)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-text">Orçamentos</h1>
|
||||
<p className="text-gray mt-1">Gerencie e acompanhe os orçamentos de facilities</p>
|
||||
</div>
|
||||
<button className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
|
||||
<Plus className="w-5 h-5" />
|
||||
Novo Orçamento
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="card bg-gradient-to-br from-primary to-accent text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white/80 text-sm">Total Previsto</p>
|
||||
<p className="text-2xl font-bold mt-1">{formatCurrency(totalPrevisto)}</p>
|
||||
</div>
|
||||
<Wallet className="w-10 h-10 opacity-80" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card bg-gradient-to-br from-secondary to-secondary-light text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white/80 text-sm">Total Realizado</p>
|
||||
<p className="text-2xl font-bold mt-1">{formatCurrency(totalRealizado)}</p>
|
||||
</div>
|
||||
<TrendingDown className="w-10 h-10 opacity-80" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`card ${economia >= 0 ? 'bg-gradient-to-br from-green-500 to-emerald-500' : 'bg-gradient-to-br from-red-500 to-rose-500'} text-white`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white/80 text-sm">{economia >= 0 ? 'Economia' : 'Excedente'}</p>
|
||||
<p className="text-2xl font-bold mt-1">{formatCurrency(Math.abs(economia))}</p>
|
||||
</div>
|
||||
<TrendingUp className="w-10 h-10 opacity-80" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por categoria..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="input-field pl-12"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
value={selectedYear}
|
||||
onChange={(e) => setSelectedYear(Number(e.target.value))}
|
||||
className="input-field w-32"
|
||||
>
|
||||
<option value={2024}>2024</option>
|
||||
<option value={2023}>2023</option>
|
||||
<option value={2022}>2022</option>
|
||||
</select>
|
||||
<select
|
||||
value={selectedMonth}
|
||||
onChange={(e) => setSelectedMonth(Number(e.target.value))}
|
||||
className="input-field w-40"
|
||||
>
|
||||
{months.map((month, index) => (
|
||||
<option key={index} value={index}>{month}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card !p-0 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="table-header">
|
||||
<tr>
|
||||
<th className="table-cell">Categoria</th>
|
||||
<th className="table-cell">Período</th>
|
||||
<th className="table-cell text-right">Previsto</th>
|
||||
<th className="table-cell text-right">Realizado</th>
|
||||
<th className="table-cell text-center">% Utilizado</th>
|
||||
<th className="table-cell text-center">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredOrcamentos.map((orcamento) => {
|
||||
const percentage = Number(getPercentage(orcamento.valor_realizado, orcamento.valor_previsto))
|
||||
return (
|
||||
<tr key={orcamento.id} className="table-row">
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Wallet className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<span className="font-medium">{orcamento.categoria}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-gray" />
|
||||
<span>{months[orcamento.mes]}/{orcamento.ano}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell text-right font-medium">
|
||||
{formatCurrency(orcamento.valor_previsto)}
|
||||
</td>
|
||||
<td className="table-cell text-right font-medium">
|
||||
{formatCurrency(orcamento.valor_realizado)}
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="w-24 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
percentage > 100 ? 'bg-red-500' :
|
||||
percentage > 85 ? 'bg-amber-500' :
|
||||
'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium w-14 text-right">{percentage}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell text-center">
|
||||
<span className={statusConfig[orcamento.status]?.class || 'badge-neutral'}>
|
||||
{statusConfig[orcamento.status]?.label || orcamento.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
||||
<span className="text-sm text-gray">
|
||||
Mostrando {filteredOrcamentos.length} de {orcamentos.length} registros
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray disabled:opacity-50" disabled>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button className="px-3 py-1 rounded-lg bg-primary text-white text-sm">1</button>
|
||||
<button className="px-3 py-1 rounded-lg hover:bg-gray-100 text-gray text-sm">2</button>
|
||||
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
216
frontend/src/pages/OrdensServico.tsx
Normal file
216
frontend/src/pages/OrdensServico.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
ClipboardList,
|
||||
Search,
|
||||
Plus,
|
||||
Eye,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
PlayCircle,
|
||||
ChevronDown,
|
||||
Building2,
|
||||
Calendar
|
||||
} from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
import { OrdemServico } from '../types'
|
||||
|
||||
const statusConfig: Record<string, { label: string; class: string; icon: React.ReactNode }> = {
|
||||
'pendente': { label: 'Pendente', class: 'badge-warning', icon: <Clock className="w-3 h-3" /> },
|
||||
'em_andamento': { label: 'Em Andamento', 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' },
|
||||
]
|
||||
|
||||
export default function OrdensServico() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [ordens, setOrdens] = useState<OrdemServico[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('todos')
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrdens()
|
||||
}, [])
|
||||
|
||||
const fetchOrdens = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/ordens-servico')
|
||||
setOrdens(data.length > 0 ? data : mockOrdens)
|
||||
} 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')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-text">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>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 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">Fornecedor</th>
|
||||
<th className="table-cell">Data</th>
|
||||
<th className="table-cell text-center">Status</th>
|
||||
<th className="table-cell text-center">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredOrdens.map((ordem) => (
|
||||
<tr key={ordem.id} className="table-row">
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className="line-clamp-1">{ordem.descricao}</span>
|
||||
</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>
|
||||
</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}
|
||||
</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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
279
frontend/src/pages/Relatorios.tsx
Normal file
279
frontend/src/pages/Relatorios.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
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'
|
||||
|
||||
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 },
|
||||
]
|
||||
|
||||
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 },
|
||||
]
|
||||
|
||||
export default function Relatorios() {
|
||||
const [period, setPeriod] = useState('mensal')
|
||||
|
||||
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>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value)}
|
||||
className="input-field w-40"
|
||||
>
|
||||
<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
|
||||
</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 */}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
357
frontend/src/pages/Usuarios.tsx
Normal file
357
frontend/src/pages/Usuarios.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Users,
|
||||
Search,
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Shield,
|
||||
X,
|
||||
Loader2,
|
||||
Mail,
|
||||
User,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Key
|
||||
} from 'lucide-react'
|
||||
import api from '../services/api'
|
||||
|
||||
interface Usuario {
|
||||
id: number;
|
||||
nome: string;
|
||||
email: string;
|
||||
perfil: string;
|
||||
perfil_id: number;
|
||||
ativo?: boolean;
|
||||
ultimo_acesso?: string;
|
||||
}
|
||||
|
||||
const perfilConfig: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
'admin': { label: 'Administrador', color: '#E65100', icon: <Shield className="w-4 h-4" /> },
|
||||
'administrador': { label: 'Administrador', color: '#E65100', icon: <Shield className="w-4 h-4" /> },
|
||||
'gestor': { label: 'Gestor', color: '#1A237E', icon: <Users className="w-4 h-4" /> },
|
||||
'financeiro': { label: 'Financeiro', color: '#2E7D32', icon: <Key className="w-4 h-4" /> },
|
||||
'solicitante': { label: 'Solicitante', color: '#7B1FA2', icon: <User className="w-4 h-4" /> },
|
||||
}
|
||||
|
||||
const mockUsuarios: Usuario[] = [
|
||||
{ id: 1, nome: 'Admin Sistema', email: 'admin@hefesto.com', perfil: 'Administrador', perfil_id: 1, ativo: true, ultimo_acesso: '2024-01-16 14:30' },
|
||||
{ id: 2, nome: 'João Santos', email: 'joao.santos@hefesto.com', perfil: 'Gestor', perfil_id: 2, ativo: true, ultimo_acesso: '2024-01-16 10:15' },
|
||||
{ id: 3, nome: 'Ana Oliveira', email: 'ana.oliveira@hefesto.com', perfil: 'Financeiro', perfil_id: 3, ativo: true, ultimo_acesso: '2024-01-15 16:45' },
|
||||
{ id: 4, nome: 'Maria Silva', email: 'maria.silva@hefesto.com', perfil: 'Solicitante', perfil_id: 4, ativo: true, ultimo_acesso: '2024-01-16 09:00' },
|
||||
{ id: 5, nome: 'Carlos Lima', email: 'carlos.lima@hefesto.com', perfil: 'Solicitante', perfil_id: 4, ativo: false, ultimo_acesso: '2024-01-10 11:30' },
|
||||
]
|
||||
|
||||
export default function Usuarios() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [usuarios, setUsuarios] = useState<Usuario[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [filterPerfil, setFilterPerfil] = useState('todos')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<Usuario | null>(null)
|
||||
const [formData, setFormData] = useState({ nome: '', email: '', perfil_id: 4, senha: '' })
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate loading
|
||||
setTimeout(() => {
|
||||
setUsuarios(mockUsuarios)
|
||||
setLoading(false)
|
||||
}, 500)
|
||||
}, [])
|
||||
|
||||
const filteredUsuarios = usuarios.filter(user => {
|
||||
const matchesSearch = user.nome.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesPerfil = filterPerfil === 'todos' || user.perfil.toLowerCase() === filterPerfil
|
||||
return matchesSearch && matchesPerfil
|
||||
})
|
||||
|
||||
const handleOpenModal = (user?: Usuario) => {
|
||||
if (user) {
|
||||
setSelectedUser(user)
|
||||
setFormData({ nome: user.nome, email: user.email, perfil_id: user.perfil_id, senha: '' })
|
||||
} else {
|
||||
setSelectedUser(null)
|
||||
setFormData({ nome: '', email: '', perfil_id: 4, senha: '' })
|
||||
}
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowModal(false)
|
||||
setSelectedUser(null)
|
||||
setFormData({ nome: '', email: '', perfil_id: 4, senha: '' })
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// Mock save
|
||||
if (selectedUser) {
|
||||
setUsuarios(usuarios.map(u => u.id === selectedUser.id ? { ...u, ...formData, perfil: getPerfilLabel(formData.perfil_id) } : u))
|
||||
} else {
|
||||
const newUser: Usuario = {
|
||||
id: Date.now(),
|
||||
...formData,
|
||||
perfil: getPerfilLabel(formData.perfil_id),
|
||||
ativo: true,
|
||||
ultimo_acesso: 'Nunca'
|
||||
}
|
||||
setUsuarios([...usuarios, newUser])
|
||||
}
|
||||
handleCloseModal()
|
||||
}
|
||||
|
||||
const getPerfilLabel = (id: number) => {
|
||||
const perfis: Record<number, string> = { 1: 'Administrador', 2: 'Gestor', 3: 'Financeiro', 4: 'Solicitante' }
|
||||
return perfis[id] || 'Solicitante'
|
||||
}
|
||||
|
||||
const getPerfilConfig = (perfil: string) => {
|
||||
const key = perfil.toLowerCase()
|
||||
return perfilConfig[key] || { label: perfil, color: '#757575', icon: <User className="w-4 h-4" /> }
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-text">Usuários</h1>
|
||||
<p className="text-gray mt-1">Gerencie os usuários do sistema</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" />
|
||||
Novo Usuário
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<p className="text-gray text-sm">Total</p>
|
||||
<p className="text-2xl font-bold text-text">{usuarios.length}</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="text-gray text-sm">Ativos</p>
|
||||
<p className="text-2xl font-bold text-green-600">{usuarios.filter(u => u.ativo !== false).length}</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="text-gray text-sm">Admins</p>
|
||||
<p className="text-2xl font-bold text-primary">{usuarios.filter(u => u.perfil.toLowerCase().includes('admin')).length}</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="text-gray text-sm">Inativos</p>
|
||||
<p className="text-2xl font-bold text-gray">{usuarios.filter(u => u.ativo === false).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 e-mail..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="input-field pl-12"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{['todos', 'administrador', 'gestor', 'financeiro', 'solicitante'].map((filter) => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setFilterPerfil(filter)}
|
||||
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||
filterPerfil === filter
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-100 text-gray hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</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">Usuário</th>
|
||||
<th className="table-cell">E-mail</th>
|
||||
<th className="table-cell">Perfil</th>
|
||||
<th className="table-cell">Último Acesso</th>
|
||||
<th className="table-cell text-center">Status</th>
|
||||
<th className="table-cell text-center">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsuarios.map((user) => {
|
||||
const config = getPerfilConfig(user.perfil)
|
||||
return (
|
||||
<tr key={user.id} className="table-row">
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-white font-semibold"
|
||||
style={{ backgroundColor: config.color }}
|
||||
>
|
||||
{user.nome.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-medium">{user.nome}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-gray" />
|
||||
<span>{user.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold"
|
||||
style={{ backgroundColor: `${config.color}15`, color: config.color }}
|
||||
>
|
||||
{config.icon}
|
||||
{config.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell text-gray text-sm">
|
||||
{user.ultimo_acesso}
|
||||
</td>
|
||||
<td className="table-cell text-center">
|
||||
{user.ativo !== false ? (
|
||||
<span className="badge-success inline-flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
Ativo
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-neutral inline-flex items-center gap-1">
|
||||
<XCircle className="w-3 h-3" />
|
||||
Inativo
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={() => handleOpenModal(user)}
|
||||
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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredUsuarios.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Users className="w-12 h-12 text-gray-light mx-auto mb-4" />
|
||||
<p className="text-gray">Nenhum usuário encontrado</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-md 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">
|
||||
{selectedUser ? 'Editar Usuário' : 'Novo Usuário'}
|
||||
</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">Nome</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.nome}
|
||||
onChange={(e) => setFormData({ ...formData, nome: e.target.value })}
|
||||
className="input-field"
|
||||
placeholder="Nome completo"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">E-mail</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="input-field"
|
||||
placeholder="email@exemplo.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Perfil</label>
|
||||
<select
|
||||
value={formData.perfil_id}
|
||||
onChange={(e) => setFormData({ ...formData, perfil_id: Number(e.target.value) })}
|
||||
className="input-field"
|
||||
>
|
||||
<option value={1}>Administrador</option>
|
||||
<option value={2}>Gestor</option>
|
||||
<option value={3}>Financeiro</option>
|
||||
<option value={4}>Solicitante</option>
|
||||
</select>
|
||||
</div>
|
||||
{!selectedUser && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-2">Senha</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.senha}
|
||||
onChange={(e) => setFormData({ ...formData, senha: e.target.value })}
|
||||
className="input-field"
|
||||
placeholder="••••••"
|
||||
required={!selectedUser}
|
||||
/>
|
||||
</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">
|
||||
{selectedUser ? 'Salvar' : 'Criar Usuário'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
frontend/src/services/api.ts
Normal file
27
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
|
||||
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
82
frontend/src/types/index.ts
Normal file
82
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
nome: string;
|
||||
email: string;
|
||||
perfil: string;
|
||||
perfil_id: number;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
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 Demanda {
|
||||
id: number;
|
||||
titulo: string;
|
||||
descricao: string;
|
||||
status: string;
|
||||
prioridade: string;
|
||||
solicitante_id: number;
|
||||
solicitante_nome?: string;
|
||||
data_criacao: string;
|
||||
data_atualizacao?: string;
|
||||
}
|
||||
|
||||
export interface OrdemServico {
|
||||
id: number;
|
||||
numero: string;
|
||||
demanda_id: number;
|
||||
fornecedor_id: number;
|
||||
fornecedor_nome?: string;
|
||||
status: string;
|
||||
valor?: number;
|
||||
data_criacao: string;
|
||||
data_conclusao?: string;
|
||||
descricao?: string;
|
||||
}
|
||||
|
||||
export interface Fornecedor {
|
||||
id: number;
|
||||
razao_social: string;
|
||||
cnpj: string;
|
||||
email: string;
|
||||
telefone: string;
|
||||
endereco?: string;
|
||||
ativo: boolean;
|
||||
especialidades?: string[];
|
||||
avaliacao?: number;
|
||||
}
|
||||
|
||||
export interface Orcamento {
|
||||
id: number;
|
||||
ano: number;
|
||||
mes: number;
|
||||
categoria: string;
|
||||
valor_previsto: number;
|
||||
valor_realizado: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Contrato {
|
||||
id: number;
|
||||
numero: string;
|
||||
fornecedor_id: number;
|
||||
fornecedor_nome?: string;
|
||||
objeto: string;
|
||||
valor_mensal: number;
|
||||
data_inicio: string;
|
||||
data_fim: string;
|
||||
status: string;
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
27
frontend/tsconfig.json
Normal file
27
frontend/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
frontend/vite.config.ts
Normal file
16
frontend/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user