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:
2026-02-09 14:53:01 -03:00
commit d8ca580acb
107 changed files with 22657 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
.next/
database.sqlite
*.sqlite
.env
*.db

4
backend/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

98
backend/README.md Normal file
View File

@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

35
backend/eslint.config.mjs Normal file
View File

@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

8
backend/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

11057
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

86
backend/package.json Normal file
View File

@@ -0,0 +1,86 @@
{
"name": "backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.6.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.28",
"uuid": "^13.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

61
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,61 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { LocaisModule } from './modules/locais/locais.module';
import { CentrosCustoModule } from './modules/centros-custo/centros-custo.module';
import { CategoriasModule } from './modules/categorias/categorias.module';
import { FornecedoresModule } from './modules/fornecedores/fornecedores.module';
import { DemandasModule } from './modules/demandas/demandas.module';
import { PropostasModule } from './modules/propostas/propostas.module';
import { OrcamentoModule } from './modules/orcamento/orcamento.module';
import { WorkflowModule } from './modules/workflow/workflow.module';
import { DashboardModule } from './modules/dashboard/dashboard.module';
import { OrdensServicoModule } from './modules/ordens-servico/ordens-servico.module';
import { SeedService } from './database/seeds/seed.service';
import { Perfil } from './modules/users/entities/perfil.entity';
import { Usuario } from './modules/users/entities/usuario.entity';
import { Local } from './modules/locais/entities/local.entity';
import { CentroCusto } from './modules/centros-custo/entities/centro-custo.entity';
import { Categoria } from './modules/categorias/entities/categoria.entity';
import { Fornecedor } from './modules/fornecedores/entities/fornecedor.entity';
import { Certidao } from './modules/fornecedores/entities/certidao.entity';
import { Demanda } from './modules/demandas/entities/demanda.entity';
import { ItemLinha } from './modules/demandas/entities/item-linha.entity';
import { Proposta } from './modules/propostas/entities/proposta.entity';
import { OrcamentoPlanejado } from './modules/orcamento/entities/orcamento-planejado.entity';
import { WorkflowAprovacao } from './modules/workflow/entities/workflow-aprovacao.entity';
import { OrdemServico } from './modules/ordens-servico/entities/ordem-servico.entity';
import { Avaliacao } from './modules/ordens-servico/entities/avaliacao.entity';
import { Alerta } from './modules/dashboard/entities/alerta.entity';
import { AuditLog } from './modules/dashboard/entities/audit-log.entity';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'better-sqlite3',
database: 'hefesto.db',
autoLoadEntities: true,
synchronize: true,
}),
TypeOrmModule.forFeature([
Perfil, Usuario, Local, CentroCusto, Categoria, Fornecedor, Certidao,
Demanda, ItemLinha, Proposta, OrcamentoPlanejado, WorkflowAprovacao,
OrdemServico, Avaliacao, Alerta, AuditLog,
]),
AuthModule,
UsersModule,
LocaisModule,
CentrosCustoModule,
CategoriasModule,
FornecedoresModule,
DemandasModule,
PropostasModule,
OrcamentoModule,
WorkflowModule,
DashboardModule,
OrdensServicoModule,
],
providers: [SeedService],
})
export class AppModule {}

View File

@@ -0,0 +1,3 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,19 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
if (!user) return false;
return requiredRoles.includes(user.perfil_nome);
}
}

View File

@@ -0,0 +1,249 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { v4 as uuid } from 'uuid';
import { Perfil } from '../../modules/users/entities/perfil.entity';
import { Usuario } from '../../modules/users/entities/usuario.entity';
import { Local } from '../../modules/locais/entities/local.entity';
import { CentroCusto } from '../../modules/centros-custo/entities/centro-custo.entity';
import { Categoria } from '../../modules/categorias/entities/categoria.entity';
import { Fornecedor } from '../../modules/fornecedores/entities/fornecedor.entity';
import { Certidao } from '../../modules/fornecedores/entities/certidao.entity';
import { Demanda } from '../../modules/demandas/entities/demanda.entity';
import { ItemLinha } from '../../modules/demandas/entities/item-linha.entity';
import { Proposta } from '../../modules/propostas/entities/proposta.entity';
import { OrcamentoPlanejado } from '../../modules/orcamento/entities/orcamento-planejado.entity';
import { WorkflowAprovacao } from '../../modules/workflow/entities/workflow-aprovacao.entity';
import { OrdemServico } from '../../modules/ordens-servico/entities/ordem-servico.entity';
import { Alerta } from '../../modules/dashboard/entities/alerta.entity';
@Injectable()
export class SeedService {
constructor(
@InjectRepository(Perfil) private perfilRepo: Repository<Perfil>,
@InjectRepository(Usuario) private userRepo: Repository<Usuario>,
@InjectRepository(Local) private localRepo: Repository<Local>,
@InjectRepository(CentroCusto) private ccRepo: Repository<CentroCusto>,
@InjectRepository(Categoria) private catRepo: Repository<Categoria>,
@InjectRepository(Fornecedor) private fornRepo: Repository<Fornecedor>,
@InjectRepository(Certidao) private certRepo: Repository<Certidao>,
@InjectRepository(Demanda) private demandaRepo: Repository<Demanda>,
@InjectRepository(ItemLinha) private itemRepo: Repository<ItemLinha>,
@InjectRepository(Proposta) private propRepo: Repository<Proposta>,
@InjectRepository(OrcamentoPlanejado) private orcRepo: Repository<OrcamentoPlanejado>,
@InjectRepository(WorkflowAprovacao) private wfRepo: Repository<WorkflowAprovacao>,
@InjectRepository(OrdemServico) private osRepo: Repository<OrdemServico>,
@InjectRepository(Alerta) private alertaRepo: Repository<Alerta>,
) {}
async seed() {
const existingPerfis = await this.perfilRepo.count();
if (existingPerfis > 0) return;
console.log('🌱 Seeding database...');
const hash = await bcrypt.hash('123456', 10);
// Perfis
const perfis = [
{ id: uuid(), nome: 'solicitante', descricao: 'Solicitante de demandas', permissoes: {} },
{ id: uuid(), nome: 'gestor_facilities', descricao: 'Gestor de Facilities', permissoes: {} },
{ id: uuid(), nome: 'aprovador_financeiro', descricao: 'Aprovador Financeiro', permissoes: {} },
{ id: uuid(), nome: 'diretoria', descricao: 'Diretoria', permissoes: {} },
{ id: uuid(), nome: 'fornecedor', descricao: 'Fornecedor', permissoes: {} },
{ id: uuid(), nome: 'administrador', descricao: 'Administrador do Sistema', permissoes: {} },
];
await this.perfilRepo.save(perfis);
// Centros de Custo
const ccs = [
{ id: uuid(), codigo: 'CC-001', nome: 'Sede Corporativa SP' },
{ id: uuid(), codigo: 'CC-002', nome: 'Filial Rio de Janeiro' },
{ id: uuid(), codigo: 'CC-003', nome: 'Centro de Distribuição MG' },
{ id: uuid(), codigo: 'CC-004', nome: 'Escritório Curitiba' },
{ id: uuid(), codigo: 'CC-005', nome: 'Fábrica Campinas' },
];
await this.ccRepo.save(ccs);
// Usuários
const users = [
{ id: uuid(), nome: 'Admin Sistema', email: 'admin@hefesto.com', senha_hash: hash, perfil_id: perfis[5].id },
{ id: uuid(), nome: 'Maria Silva', email: 'maria.silva@hefesto.com', senha_hash: hash, perfil_id: perfis[0].id },
{ id: uuid(), nome: 'João Santos', email: 'joao.santos@hefesto.com', senha_hash: hash, perfil_id: perfis[1].id },
{ id: uuid(), nome: 'Ana Oliveira', email: 'ana.oliveira@hefesto.com', senha_hash: hash, perfil_id: perfis[2].id },
{ id: uuid(), nome: 'Carlos Mendes', email: 'carlos.mendes@hefesto.com', senha_hash: hash, perfil_id: perfis[3].id },
{ id: uuid(), nome: 'Roberto Lima', email: 'roberto.lima@hefesto.com', senha_hash: hash, perfil_id: perfis[0].id },
{ id: uuid(), nome: 'Fernanda Costa', email: 'fernanda.costa@hefesto.com', senha_hash: hash, perfil_id: perfis[1].id },
{ id: uuid(), nome: 'Paulo Rodrigues', email: 'paulo.rodrigues@hefesto.com', senha_hash: hash, perfil_id: perfis[4].id },
{ id: uuid(), nome: 'Luciana Ferreira', email: 'luciana.ferreira@hefesto.com', senha_hash: hash, perfil_id: perfis[4].id },
{ id: uuid(), nome: 'Ricardo Almeida', email: 'ricardo.almeida@hefesto.com', senha_hash: hash, perfil_id: perfis[4].id },
];
await this.userRepo.save(users);
// Locais
const locais = [
{ id: uuid(), nome: 'Torre Norte - SP', endereco: 'Av. Paulista, 1000, São Paulo - SP', centro_custo_id: ccs[0].id, responsavel_id: users[2].id },
{ id: uuid(), nome: 'Torre Sul - SP', endereco: 'Av. Paulista, 1002, São Paulo - SP', centro_custo_id: ccs[0].id, responsavel_id: users[2].id },
{ id: uuid(), nome: 'Escritório RJ', endereco: 'Rua do Ouvidor, 50, Rio de Janeiro - RJ', centro_custo_id: ccs[1].id, responsavel_id: users[6].id },
{ id: uuid(), nome: 'CD Betim', endereco: 'Rod. Fernão Dias, km 492, Betim - MG', centro_custo_id: ccs[2].id, responsavel_id: users[2].id },
{ id: uuid(), nome: 'Fábrica Campinas', endereco: 'Distrito Industrial, Campinas - SP', centro_custo_id: ccs[4].id, responsavel_id: users[6].id },
];
await this.localRepo.save(locais);
// Categorias
const cats = [
{ id: uuid(), nome: 'Manutenção Predial', criticidade_padrao: 'media', sla_dias: 15 },
{ id: uuid(), nome: 'Climatização e HVAC', criticidade_padrao: 'alta', sla_dias: 7 },
{ id: uuid(), nome: 'Limpeza e Conservação', criticidade_padrao: 'baixa', sla_dias: 30 },
{ id: uuid(), nome: 'Segurança Patrimonial', criticidade_padrao: 'critica', sla_dias: 3 },
{ id: uuid(), nome: 'Elétrica e Iluminação', criticidade_padrao: 'alta', sla_dias: 10 },
{ id: uuid(), nome: 'Paisagismo', criticidade_padrao: 'baixa', sla_dias: 45 },
{ id: uuid(), nome: 'Dedetização e Controle de Pragas', criticidade_padrao: 'media', sla_dias: 15 },
{ id: uuid(), nome: 'Reforma e Adequação', criticidade_padrao: 'media', sla_dias: 60 },
];
await this.catRepo.save(cats);
// Fornecedores
const forns = [
{ id: uuid(), tipo_pessoa: 'PJ', cpf_cnpj: '12.345.678/0001-90', razao_social: 'TechClima Engenharia Ltda', nome_fantasia: 'TechClima', email: 'contato@techclima.com.br', telefone: '(11) 3456-7890', rating: 4.5, usuario_id: users[7].id, categorias_atendidas: [cats[1].id] },
{ id: uuid(), tipo_pessoa: 'PJ', cpf_cnpj: '23.456.789/0001-01', razao_social: 'ServiLimp Serviços de Limpeza S/A', nome_fantasia: 'ServiLimp', email: 'comercial@servilimp.com.br', telefone: '(11) 2345-6789', rating: 3.8, usuario_id: users[8].id, categorias_atendidas: [cats[2].id] },
{ id: uuid(), tipo_pessoa: 'PJ', cpf_cnpj: '34.567.890/0001-12', razao_social: 'Forte Segurança Empresarial Ltda', nome_fantasia: 'Forte Seg', email: 'propostas@forteseg.com.br', telefone: '(21) 3456-7890', rating: 4.2, usuario_id: users[9].id, categorias_atendidas: [cats[3].id] },
{ id: uuid(), tipo_pessoa: 'PJ', cpf_cnpj: '45.678.901/0001-23', razao_social: 'EletroForce Instalações Elétricas', nome_fantasia: 'EletroForce', email: 'orcamento@eletroforce.com.br', telefone: '(11) 4567-8901', rating: 4.0, categorias_atendidas: [cats[4].id] },
{ id: uuid(), tipo_pessoa: 'PJ', cpf_cnpj: '56.789.012/0001-34', razao_social: 'Predial Master Engenharia', nome_fantasia: 'Predial Master', email: 'contato@predialmaster.com.br', telefone: '(11) 5678-9012', rating: 4.7, categorias_atendidas: [cats[0].id, cats[7].id] },
];
await this.fornRepo.save(forns);
// Certidões
const certs = [
{ id: uuid(), fornecedor_id: forns[0].id, tipo: 'CND Federal', numero: 'CND-2026-001', data_emissao: '2026-01-10', data_validade: '2026-07-10', status: 'vigente' },
{ id: uuid(), fornecedor_id: forns[0].id, tipo: 'CND Estadual', numero: 'CND-2026-002', data_emissao: '2026-01-15', data_validade: '2026-07-15', status: 'vigente' },
{ id: uuid(), fornecedor_id: forns[1].id, tipo: 'CND Federal', numero: 'CND-2025-100', data_emissao: '2025-06-01', data_validade: '2025-12-01', status: 'vencida' },
{ id: uuid(), fornecedor_id: forns[2].id, tipo: 'CND Federal', numero: 'CND-2026-050', data_emissao: '2026-01-20', data_validade: '2026-07-20', status: 'vigente' },
{ id: uuid(), fornecedor_id: forns[2].id, tipo: 'FGTS', numero: 'CRF-2026-003', data_emissao: '2026-01-05', data_validade: '2026-02-05', status: 'vigente' },
{ id: uuid(), fornecedor_id: forns[3].id, tipo: 'CND Municipal', numero: 'CND-2026-080', data_emissao: '2025-12-15', data_validade: '2026-06-15', status: 'vigente' },
{ id: uuid(), fornecedor_id: forns[4].id, tipo: 'CND Federal', numero: 'CND-2026-200', data_emissao: '2026-02-01', data_validade: '2026-08-01', status: 'vigente' },
{ id: uuid(), fornecedor_id: forns[4].id, tipo: 'CND Trabalhista', numero: 'CNDT-2026-010', data_emissao: '2026-01-25', data_validade: '2026-07-25', status: 'vigente' },
];
await this.certRepo.save(certs);
// Demandas
const demandas = [
{ id: uuid(), numero: 1, titulo: 'Manutenção preventiva ar-condicionado Torre Norte', descricao: 'Revisão semestral de todos os 48 splits e 6 centrais de ar', local_id: locais[0].id, centro_custo_id: ccs[0].id, categoria_id: cats[1].id, criticidade: 'alta', status: 'em_cotacao', solicitante_id: users[1].id, gestor_id: users[2].id, data_desejada: '2026-03-15' },
{ id: uuid(), numero: 2, titulo: 'Contratação de serviço de limpeza - Escritório RJ', descricao: 'Limpeza diária do escritório RJ - 2 pavimentos, 800m²', local_id: locais[2].id, centro_custo_id: ccs[1].id, categoria_id: cats[2].id, criticidade: 'media', status: 'propostas_recebidas', solicitante_id: users[5].id, gestor_id: users[6].id },
{ id: uuid(), numero: 3, titulo: 'Instalação de câmeras de segurança - CD Betim', descricao: 'Instalação de 32 câmeras IP + NVR + cabeamento', local_id: locais[3].id, centro_custo_id: ccs[2].id, categoria_id: cats[3].id, criticidade: 'critica', status: 'em_aprovacao', solicitante_id: users[1].id, gestor_id: users[2].id },
{ id: uuid(), numero: 4, titulo: 'Troca de iluminação para LED - Torre Sul', descricao: 'Substituição de 500 lâmpadas fluorescentes por LED', local_id: locais[1].id, centro_custo_id: ccs[0].id, categoria_id: cats[4].id, criticidade: 'media', status: 'aprovada', solicitante_id: users[5].id, gestor_id: users[2].id },
{ id: uuid(), numero: 5, titulo: 'Reforma do refeitório - Fábrica Campinas', descricao: 'Reforma completa: piso, pintura, bancadas, instalações', local_id: locais[4].id, centro_custo_id: ccs[4].id, categoria_id: cats[7].id, criticidade: 'media', status: 'em_execucao', solicitante_id: users[1].id, gestor_id: users[6].id },
{ id: uuid(), numero: 6, titulo: 'Dedetização trimestral - Todas as unidades', descricao: 'Controle de pragas urbanas em todas as 5 unidades', local_id: locais[0].id, centro_custo_id: ccs[0].id, categoria_id: cats[6].id, criticidade: 'baixa', status: 'aberta', solicitante_id: users[5].id },
{ id: uuid(), numero: 7, titulo: 'Reparo no telhado - CD Betim', descricao: 'Vazamento em 3 pontos do galpão principal', local_id: locais[3].id, centro_custo_id: ccs[2].id, categoria_id: cats[0].id, criticidade: 'alta', status: 'em_escopo', solicitante_id: users[1].id, gestor_id: users[2].id },
{ id: uuid(), numero: 8, titulo: 'Manutenção do paisagismo - Sede SP', descricao: 'Poda, jardinagem e manutenção das áreas verdes', local_id: locais[0].id, centro_custo_id: ccs[0].id, categoria_id: cats[5].id, criticidade: 'baixa', status: 'rascunho', solicitante_id: users[5].id },
{ id: uuid(), numero: 9, titulo: 'Instalação de gerador de emergência - Torre Norte', descricao: 'Gerador diesel 500kVA com QTA', local_id: locais[0].id, centro_custo_id: ccs[0].id, categoria_id: cats[4].id, criticidade: 'critica', status: 'concluida', solicitante_id: users[1].id, gestor_id: users[2].id },
{ id: uuid(), numero: 10, titulo: 'Adequação NR-10 - Fábrica Campinas', descricao: 'Adequação de quadros e instalações elétricas à NR-10', local_id: locais[4].id, centro_custo_id: ccs[4].id, categoria_id: cats[4].id, criticidade: 'alta', status: 'cancelada', solicitante_id: users[5].id, gestor_id: users[6].id },
];
await this.demandaRepo.save(demandas);
// Itens de Linha
const itens = [
{ id: uuid(), demanda_id: demandas[0].id, descricao: 'Revisão splits 9000 BTUs (24 un)', tipo: 'mao_de_obra', quantidade: 24, unidade: 'un', ordem: 1 },
{ id: uuid(), demanda_id: demandas[0].id, descricao: 'Revisão splits 12000 BTUs (18 un)', tipo: 'mao_de_obra', quantidade: 18, unidade: 'un', ordem: 2 },
{ id: uuid(), demanda_id: demandas[0].id, descricao: 'Revisão splits 18000 BTUs (6 un)', tipo: 'mao_de_obra', quantidade: 6, unidade: 'un', ordem: 3 },
{ id: uuid(), demanda_id: demandas[0].id, descricao: 'Revisão centrais de ar (6 un)', tipo: 'mao_de_obra', quantidade: 6, unidade: 'un', ordem: 4 },
{ id: uuid(), demanda_id: demandas[0].id, descricao: 'Filtros de reposição', tipo: 'material', quantidade: 54, unidade: 'un', ordem: 5 },
{ id: uuid(), demanda_id: demandas[2].id, descricao: 'Câmera IP 4MP Hikvision', tipo: 'material', quantidade: 32, unidade: 'un', ordem: 1 },
{ id: uuid(), demanda_id: demandas[2].id, descricao: 'NVR 32 canais', tipo: 'equipamento', quantidade: 1, unidade: 'un', ordem: 2 },
{ id: uuid(), demanda_id: demandas[2].id, descricao: 'Cabo UTP Cat6 (caixa 305m)', tipo: 'material', quantidade: 4, unidade: 'cx', ordem: 3 },
{ id: uuid(), demanda_id: demandas[2].id, descricao: 'Instalação e configuração', tipo: 'mao_de_obra', quantidade: 1, unidade: 'vb', ordem: 4 },
{ id: uuid(), demanda_id: demandas[4].id, descricao: 'Demolição e remoção', tipo: 'mao_de_obra', quantidade: 1, unidade: 'vb', ordem: 1 },
{ id: uuid(), demanda_id: demandas[4].id, descricao: 'Piso porcelanato 60x60', tipo: 'material', quantidade: 120, unidade: 'm²', ordem: 2 },
{ id: uuid(), demanda_id: demandas[4].id, descricao: 'Pintura epóxi paredes', tipo: 'mao_de_obra', quantidade: 280, unidade: 'm²', ordem: 3 },
];
await this.itemRepo.save(itens);
// Propostas
const propostas = [
// Demanda 1 - Ar condicionado
{ id: uuid(), demanda_id: demandas[0].id, fornecedor_id: forns[0].id, valor_bruto: 48500, valor_liquido: 42350, impostos: { iss: 2425, inss: 2425, pcc: 1300 }, condicao_pagamento: '30/60 dias', prazo_execucao_dias: 15, status: 'recebida', match_escopo_pct: 95 },
{ id: uuid(), demanda_id: demandas[0].id, fornecedor_id: forns[3].id, valor_bruto: 52000, valor_liquido: 45500, impostos: { iss: 2600, inss: 2600, pcc: 1300 }, condicao_pagamento: '30 dias', prazo_execucao_dias: 10, status: 'recebida', match_escopo_pct: 88 },
// Demanda 2 - Limpeza
{ id: uuid(), demanda_id: demandas[1].id, fornecedor_id: forns[1].id, valor_bruto: 18500, valor_liquido: 16200, impostos: { iss: 925, inss: 925, pcc: 450 }, condicao_pagamento: 'Mensal', prazo_execucao_dias: 365, status: 'analisada', match_escopo_pct: 100 },
{ id: uuid(), demanda_id: demandas[1].id, fornecedor_id: forns[4].id, valor_bruto: 21000, valor_liquido: 18400, impostos: { iss: 1050, inss: 1050, pcc: 500 }, condicao_pagamento: 'Mensal', prazo_execucao_dias: 365, status: 'recebida', match_escopo_pct: 92 },
// Demanda 3 - Câmeras
{ id: uuid(), demanda_id: demandas[2].id, fornecedor_id: forns[2].id, valor_bruto: 145000, valor_liquido: 126800, impostos: { iss: 7250, inss: 7250, pcc: 3700 }, condicao_pagamento: '30/60/90 dias', prazo_execucao_dias: 30, status: 'selecionada', selecionada: true, match_escopo_pct: 97 },
{ id: uuid(), demanda_id: demandas[2].id, fornecedor_id: forns[3].id, valor_bruto: 158000, valor_liquido: 138200, impostos: { iss: 7900, inss: 7900, pcc: 4000 }, condicao_pagamento: '50% + 50%', prazo_execucao_dias: 25, status: 'rejeitada', match_escopo_pct: 85 },
{ id: uuid(), demanda_id: demandas[2].id, fornecedor_id: forns[4].id, valor_bruto: 139000, valor_liquido: 121600, impostos: { iss: 6950, inss: 6950, pcc: 3500 }, condicao_pagamento: '30/60/90 dias', prazo_execucao_dias: 35, status: 'rejeitada', match_escopo_pct: 90 },
// Demanda 4 - LED
{ id: uuid(), demanda_id: demandas[3].id, fornecedor_id: forns[3].id, valor_bruto: 75000, valor_liquido: 65600, impostos: { iss: 3750, inss: 3750, pcc: 1900 }, condicao_pagamento: '30/60 dias', prazo_execucao_dias: 20, status: 'selecionada', selecionada: true, match_escopo_pct: 98 },
{ id: uuid(), demanda_id: demandas[3].id, fornecedor_id: forns[0].id, valor_bruto: 82000, valor_liquido: 71800, impostos: { iss: 4100, inss: 4100, pcc: 2000 }, condicao_pagamento: '30 dias', prazo_execucao_dias: 25, status: 'rejeitada', match_escopo_pct: 93 },
// Demanda 5 - Reforma refeitório
{ id: uuid(), demanda_id: demandas[4].id, fornecedor_id: forns[4].id, valor_bruto: 280000, valor_liquido: 245000, impostos: { iss: 14000, inss: 14000, pcc: 7000 }, condicao_pagamento: 'Medição mensal', prazo_execucao_dias: 90, status: 'selecionada', selecionada: true, match_escopo_pct: 100 },
{ id: uuid(), demanda_id: demandas[4].id, fornecedor_id: forns[3].id, valor_bruto: 310000, valor_liquido: 271200, impostos: { iss: 15500, inss: 15500, pcc: 7800 }, condicao_pagamento: '30/60/90 dias', prazo_execucao_dias: 75, status: 'rejeitada', match_escopo_pct: 88 },
// Demanda 9 - Gerador
{ id: uuid(), demanda_id: demandas[8].id, fornecedor_id: forns[3].id, valor_bruto: 320000, valor_liquido: 280000, impostos: { iss: 16000, inss: 16000, pcc: 8000 }, condicao_pagamento: '50% + 50%', prazo_execucao_dias: 45, status: 'selecionada', selecionada: true, match_escopo_pct: 96 },
{ id: uuid(), demanda_id: demandas[8].id, fornecedor_id: forns[4].id, valor_bruto: 295000, valor_liquido: 258100, impostos: { iss: 14750, inss: 14750, pcc: 7400 }, condicao_pagamento: '30/60/90 dias', prazo_execucao_dias: 60, status: 'rejeitada', match_escopo_pct: 91 },
{ id: uuid(), demanda_id: demandas[8].id, fornecedor_id: forns[0].id, valor_bruto: 345000, valor_liquido: 301800, impostos: { iss: 17250, inss: 17250, pcc: 8700 }, condicao_pagamento: '30 dias', prazo_execucao_dias: 40, status: 'rejeitada', match_escopo_pct: 82 },
];
await this.propRepo.save(propostas);
// Workflow
const workflows = [
{ id: uuid(), demanda_id: demandas[2].id, proposta_id: propostas[4].id, valor_total: 145000, status: 'em_andamento', etapa_atual: 2, etapas: [
{ ordem: 1, perfil: 'gestor_facilities', status: 'aprovado', data_acao: '2026-02-05T10:00:00Z', observacao: 'Projeto necessário para segurança', ressalva: false },
{ ordem: 2, perfil: 'aprovador_financeiro', status: 'pendente', data_acao: null, observacao: null, ressalva: false },
{ ordem: 3, perfil: 'diretoria', status: 'pendente', data_acao: null, observacao: null, ressalva: false },
]},
{ id: uuid(), demanda_id: demandas[3].id, proposta_id: propostas[7].id, valor_total: 75000, status: 'aprovado', etapa_atual: 3, etapas: [
{ ordem: 1, perfil: 'gestor_facilities', status: 'aprovado', data_acao: '2026-01-20T09:00:00Z', observacao: 'OK', ressalva: false },
{ ordem: 2, perfil: 'aprovador_financeiro', status: 'aprovado', data_acao: '2026-01-21T14:00:00Z', observacao: 'Dentro do orçamento', ressalva: false },
{ ordem: 3, perfil: 'diretoria', status: 'aprovado', data_acao: '2026-01-22T11:00:00Z', observacao: 'Aprovado', ressalva: false },
]},
{ id: uuid(), demanda_id: demandas[4].id, proposta_id: propostas[9].id, valor_total: 280000, status: 'aprovado', etapa_atual: 3, etapas: [
{ ordem: 1, perfil: 'gestor_facilities', status: 'aprovado', data_acao: '2026-01-10T10:00:00Z', observacao: 'Urgente', ressalva: false },
{ ordem: 2, perfil: 'aprovador_financeiro', status: 'aprovado_com_ressalva', data_acao: '2026-01-11T16:00:00Z', observacao: 'Verificar possibilidade de parcelamento maior', ressalva: true },
{ ordem: 3, perfil: 'diretoria', status: 'aprovado', data_acao: '2026-01-12T09:00:00Z', observacao: 'Aprovado', ressalva: false },
]},
];
await this.wfRepo.save(workflows);
// Ordens de Serviço
const oss = [
{ id: uuid(), numero: 1, demanda_id: demandas[3].id, proposta_id: propostas[7].id, fornecedor_id: forns[3].id, valor: 75000, status: 'emitida', data_inicio: null },
{ id: uuid(), numero: 2, demanda_id: demandas[4].id, proposta_id: propostas[9].id, fornecedor_id: forns[4].id, valor: 280000, status: 'em_execucao', data_inicio: '2026-01-20' },
{ id: uuid(), numero: 3, demanda_id: demandas[8].id, proposta_id: propostas[11].id, fornecedor_id: forns[3].id, valor: 320000, status: 'concluida', data_inicio: '2025-11-01', data_conclusao: '2025-12-15' },
];
await this.osRepo.save(oss);
// Orçamento Planejado 2026
const orcData = [];
for (let mes = 1; mes <= 12; mes++) {
for (const cc of ccs) {
for (const cat of cats.slice(0, 5)) {
orcData.push({
id: uuid(),
ano: 2026,
mes,
centro_custo_id: cc.id,
categoria_id: cat.id,
valor_planejado: Math.round((15000 + Math.random() * 85000) * 100) / 100,
valor_comprometido: mes <= 2 ? Math.round(Math.random() * 30000 * 100) / 100 : 0,
valor_realizado: mes <= 1 ? Math.round(Math.random() * 20000 * 100) / 100 : 0,
});
}
}
}
// Save in batches
for (let i = 0; i < orcData.length; i += 50) {
await this.orcRepo.save(orcData.slice(i, i + 50));
}
// Alertas
const alertas = [
{ id: uuid(), usuario_id: users[3].id, tipo: 'aprovacao_pendente', titulo: 'Aprovação pendente: Câmeras CD Betim', mensagem: 'A demanda #3 aguarda sua aprovação financeira. Valor: R$ 145.000,00', entidade: 'workflow', entidade_id: workflows[0].id },
{ id: uuid(), usuario_id: users[2].id, tipo: 'sla_vencendo', titulo: 'SLA próximo: Dedetização', mensagem: 'A demanda #6 está há 10 dias sem andamento', entidade: 'demanda', entidade_id: demandas[5].id },
{ id: uuid(), usuario_id: users[0].id, tipo: 'certidao_vencendo', titulo: 'Certidão vencida: ServiLimp', mensagem: 'A CND Federal da ServiLimp está vencida desde 01/12/2025', entidade: 'fornecedor', entidade_id: forns[1].id },
{ id: uuid(), usuario_id: users[2].id, tipo: 'estouro_orcamento', titulo: 'Alerta de orçamento: CC Sede SP', mensagem: 'O centro de custo CC-001 está com comprometimento acima de 80% para Climatização em Janeiro', entidade: 'orcamento' },
{ id: uuid(), usuario_id: users[6].id, tipo: 'os_atrasada', titulo: 'OS em atraso: Reforma Refeitório', mensagem: 'A OS #2 está em execução há mais de 30 dias', entidade: 'ordem_servico', entidade_id: oss[1].id },
];
await this.alertaRepo.save(alertas);
console.log('✅ Seed completed!');
}
}

18
backend/src/main.ts Normal file
View File

@@ -0,0 +1,18 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { SeedService } from './database/seeds/seed.service';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.enableCors({ origin: '*' });
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
const seedService = app.get(SeedService);
await seedService.seed();
await app.listen(8080);
console.log('HEFESTO API running on http://localhost:8080');
}
bootstrap();

View File

@@ -0,0 +1,20 @@
import { Controller, Post, Body, Get, UseGuards, Request, SetMetadata } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@SetMetadata('isPublic', true)
@Post('login')
async login(@Body() body: { email: string; senha: string }) {
return this.authService.login(body.email, body.senha);
}
@UseGuards(JwtAuthGuard)
@Get('me')
async me(@Request() req) {
return this.authService.me(req.user.sub);
}
}

View File

@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { Usuario } from '../users/entities/usuario.entity';
import { Perfil } from '../users/entities/perfil.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Usuario, Perfil]),
PassportModule,
JwtModule.register({
secret: 'hefesto-jwt-secret-2026',
signOptions: { expiresIn: '24h' },
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,63 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { Usuario } from '../users/entities/usuario.entity';
import { Perfil } from '../users/entities/perfil.entity';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(Usuario) private userRepo: Repository<Usuario>,
@InjectRepository(Perfil) private perfilRepo: Repository<Perfil>,
private jwtService: JwtService,
) {}
async login(email: string, senha: string) {
const user = await this.userRepo.findOne({
where: { email, ativo: true },
relations: ['perfil'],
});
if (!user) throw new UnauthorizedException('Credenciais inválidas');
const valid = await bcrypt.compare(senha, user.senha_hash);
if (!valid) throw new UnauthorizedException('Credenciais inválidas');
user.ultimo_acesso = new Date();
await this.userRepo.save(user);
const payload = {
sub: user.id,
email: user.email,
nome: user.nome,
perfil_id: user.perfil_id,
perfil_nome: user.perfil.nome,
};
return {
access_token: this.jwtService.sign(payload),
user: {
id: user.id,
nome: user.nome,
email: user.email,
perfil: user.perfil.nome,
perfil_id: user.perfil_id,
},
};
}
async me(userId: string) {
const user = await this.userRepo.findOne({
where: { id: userId },
relations: ['perfil'],
});
return {
id: user.id,
nome: user.nome,
email: user.email,
perfil: user.perfil.nome,
perfil_id: user.perfil_id,
};
}
}

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: 'hefesto-jwt-secret-2026',
});
}
async validate(payload: any) {
return {
sub: payload.sub,
email: payload.email,
nome: payload.nome,
perfil_id: payload.perfil_id,
perfil_nome: payload.perfil_nome,
};
}
}

View File

@@ -0,0 +1,13 @@
import { Controller, Get, Post, Patch, Delete, Body, Param } from '@nestjs/common';
import { CategoriasService } from './categorias.service';
@Controller('categorias')
export class CategoriasController {
constructor(private svc: CategoriasService) {}
@Get() findAll() { return this.svc.findAll(); }
@Get(':id') findOne(@Param('id') id: string) { return this.svc.findOne(id); }
@Post() create(@Body() body: any) { return this.svc.create(body); }
@Patch(':id') update(@Param('id') id: string, @Body() body: any) { return this.svc.update(id, body); }
@Delete(':id') remove(@Param('id') id: string) { return this.svc.remove(id); }
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Categoria } from './entities/categoria.entity';
import { CategoriasController } from './categorias.controller';
import { CategoriasService } from './categorias.service';
@Module({
imports: [TypeOrmModule.forFeature([Categoria])],
controllers: [CategoriasController],
providers: [CategoriasService],
exports: [CategoriasService],
})
export class CategoriasModule {}

View File

@@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Categoria } from './entities/categoria.entity';
@Injectable()
export class CategoriasService {
constructor(@InjectRepository(Categoria) private repo: Repository<Categoria>) {}
findAll() { return this.repo.find({ where: { ativo: true } }); }
findOne(id: string) { return this.repo.findOne({ where: { id } }); }
create(data: Partial<Categoria>) { return this.repo.save(data); }
async update(id: string, data: Partial<Categoria>) { await this.repo.update(id, data); return this.findOne(id); }
async remove(id: string) { await this.repo.update(id, { ativo: false }); }
}

View File

@@ -0,0 +1,31 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('categorias')
export class Categoria {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 200 })
nome: string;
@Column({ length: 200, nullable: true })
subcategoria: string;
@Column({ length: 20, nullable: true })
criticidade_padrao: string;
@Column({ default: 30 })
sla_dias: number;
@Column({ nullable: true })
categoria_pai_id: string;
@Column({ default: true })
ativo: boolean;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}

View File

@@ -0,0 +1,13 @@
import { Controller, Get, Post, Patch, Delete, Body, Param } from '@nestjs/common';
import { CentrosCustoService } from './centros-custo.service';
@Controller('centros-custo')
export class CentrosCustoController {
constructor(private svc: CentrosCustoService) {}
@Get() findAll() { return this.svc.findAll(); }
@Get(':id') findOne(@Param('id') id: string) { return this.svc.findOne(id); }
@Post() create(@Body() body: any) { return this.svc.create(body); }
@Patch(':id') update(@Param('id') id: string, @Body() body: any) { return this.svc.update(id, body); }
@Delete(':id') remove(@Param('id') id: string) { return this.svc.remove(id); }
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CentroCusto } from './entities/centro-custo.entity';
import { CentrosCustoController } from './centros-custo.controller';
import { CentrosCustoService } from './centros-custo.service';
@Module({
imports: [TypeOrmModule.forFeature([CentroCusto])],
controllers: [CentrosCustoController],
providers: [CentrosCustoService],
exports: [CentrosCustoService],
})
export class CentrosCustoModule {}

View File

@@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CentroCusto } from './entities/centro-custo.entity';
@Injectable()
export class CentrosCustoService {
constructor(@InjectRepository(CentroCusto) private repo: Repository<CentroCusto>) {}
findAll() { return this.repo.find({ where: { ativo: true } }); }
findOne(id: string) { return this.repo.findOne({ where: { id } }); }
create(data: Partial<CentroCusto>) { return this.repo.save(data); }
async update(id: string, data: Partial<CentroCusto>) { await this.repo.update(id, data); return this.findOne(id); }
async remove(id: string) { await this.repo.update(id, { ativo: false }); }
}

View File

@@ -0,0 +1,25 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('centros_custo')
export class CentroCusto {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true, length: 20 })
codigo: string;
@Column({ length: 200 })
nome: string;
@Column({ nullable: true })
responsavel_id: string;
@Column({ default: true })
ativo: boolean;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}

View File

@@ -0,0 +1,13 @@
import { Controller, Get, Patch, Param, Query } from '@nestjs/common';
import { DashboardService } from './dashboard.service';
@Controller('dashboard')
export class DashboardController {
constructor(private svc: DashboardService) {}
@Get('indicadores') indicadores() { return this.svc.indicadores(); }
@Get('demandas-por-status') demandasPorStatus() { return this.svc.demandasPorStatus(); }
@Get('consumo-orcamento') consumoOrcamento(@Query('ano') ano: string) { return this.svc.consumoOrcamento(parseInt(ano) || 2026); }
@Get('alertas') alertas(@Query('usuario_id') uid: string) { return this.svc.alertas(uid); }
@Patch('alertas/:id/ler') marcarLido(@Param('id') id: string) { return this.svc.marcarAlertaLido(id); }
}

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Demanda } from '../demandas/entities/demanda.entity';
import { Proposta } from '../propostas/entities/proposta.entity';
import { OrdemServico } from '../ordens-servico/entities/ordem-servico.entity';
import { OrcamentoPlanejado } from '../orcamento/entities/orcamento-planejado.entity';
import { WorkflowAprovacao } from '../workflow/entities/workflow-aprovacao.entity';
import { Alerta } from './entities/alerta.entity';
import { AuditLog } from './entities/audit-log.entity';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
imports: [TypeOrmModule.forFeature([Demanda, Proposta, OrdemServico, OrcamentoPlanejado, WorkflowAprovacao, Alerta, AuditLog])],
controllers: [DashboardController],
providers: [DashboardService],
exports: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Demanda } from '../demandas/entities/demanda.entity';
import { Proposta } from '../propostas/entities/proposta.entity';
import { OrdemServico } from '../ordens-servico/entities/ordem-servico.entity';
import { OrcamentoPlanejado } from '../orcamento/entities/orcamento-planejado.entity';
import { WorkflowAprovacao } from '../workflow/entities/workflow-aprovacao.entity';
import { Alerta } from './entities/alerta.entity';
@Injectable()
export class DashboardService {
constructor(
@InjectRepository(Demanda) private demandaRepo: Repository<Demanda>,
@InjectRepository(Proposta) private propostaRepo: Repository<Proposta>,
@InjectRepository(OrdemServico) private osRepo: Repository<OrdemServico>,
@InjectRepository(OrcamentoPlanejado) private orcRepo: Repository<OrcamentoPlanejado>,
@InjectRepository(WorkflowAprovacao) private wfRepo: Repository<WorkflowAprovacao>,
@InjectRepository(Alerta) private alertaRepo: Repository<Alerta>,
) {}
async indicadores() {
const demandas = await this.demandaRepo.find();
const os = await this.osRepo.find();
const wf = await this.wfRepo.find();
const alertas = await this.alertaRepo.find({ where: { lido: false } });
return {
demandas_abertas: demandas.filter(d => ['aberta', 'em_escopo'].includes(d.status)).length,
em_cotacao: demandas.filter(d => d.status === 'em_cotacao').length,
pendentes: demandas.filter(d => ['propostas_recebidas', 'em_comparacao'].includes(d.status)).length,
em_aprovacao: wf.filter(w => ['pendente', 'em_andamento'].includes(w.status)).length,
os_ativas: os.filter(o => ['emitida', 'em_execucao'].includes(o.status)).length,
alertas: alertas.length,
};
}
async demandasPorStatus() {
const demandas = await this.demandaRepo.find();
const statusCount: Record<string, number> = {};
for (const d of demandas) {
statusCount[d.status] = (statusCount[d.status] || 0) + 1;
}
return Object.entries(statusCount).map(([name, value]) => ({ name, value }));
}
async consumoOrcamento(ano: number) {
const items = await this.orcRepo.find({ where: { ano } });
const meses = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
const byMonth: Record<number, any> = {};
for (const item of items) {
if (!byMonth[item.mes]) byMonth[item.mes] = { name: meses[item.mes - 1], planejado: 0, comprometido: 0, realizado: 0 };
byMonth[item.mes].planejado += item.valor_planejado;
byMonth[item.mes].comprometido += item.valor_comprometido;
byMonth[item.mes].realizado += item.valor_realizado;
}
return Object.values(byMonth).sort((a: any, b: any) => {
const ai = meses.indexOf(a.name);
const bi = meses.indexOf(b.name);
return ai - bi;
});
}
async alertas(usuarioId?: string) {
const where: any = { lido: false };
if (usuarioId) where.usuario_id = usuarioId;
return this.alertaRepo.find({ where, order: { created_at: 'DESC' }, take: 20 });
}
async marcarAlertaLido(id: string) {
await this.alertaRepo.update(id, { lido: true });
}
}

View File

@@ -0,0 +1,31 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('alertas')
export class Alerta {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
usuario_id: string;
@Column({ length: 50 })
tipo: string;
@Column({ length: 300 })
titulo: string;
@Column({ type: 'text', nullable: true })
mensagem: string;
@Column({ length: 50, nullable: true })
entidade: string;
@Column({ nullable: true })
entidade_id: string;
@Column({ default: false })
lido: boolean;
@CreateDateColumn()
created_at: Date;
}

View File

@@ -0,0 +1,34 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('audit_log')
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true })
usuario_id: string;
@Column({ length: 50 })
acao: string;
@Column({ length: 50 })
entidade: string;
@Column({ nullable: true })
entidade_id: string;
@Column({ type: 'simple-json', nullable: true })
dados_antes: any;
@Column({ type: 'simple-json', nullable: true })
dados_depois: any;
@Column({ length: 45, nullable: true })
ip: string;
@Column({ type: 'text', nullable: true })
user_agent: string;
@CreateDateColumn()
created_at: Date;
}

View File

@@ -0,0 +1,27 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { DemandasService } from './demandas.service';
@Controller('demandas')
export class DemandasController {
constructor(private svc: DemandasService) {}
@Get() findAll(@Query() query: any) { return this.svc.findAll(query); }
@Get(':id') findOne(@Param('id') id: string) { return this.svc.findOne(id); }
@Post() create(@Body() body: any) { return this.svc.create(body); }
@Patch(':id') update(@Param('id') id: string, @Body() body: any) { return this.svc.update(id, body); }
@Post(':id/publicar') publicar(@Param('id') id: string) { return this.svc.updateStatus(id, 'aberta'); }
@Post(':id/cancelar') cancelar(@Param('id') id: string) { return this.svc.updateStatus(id, 'cancelada'); }
@Post(':id/enviar-cotacao') enviarCotacao(@Param('id') id: string) { return this.svc.updateStatus(id, 'em_cotacao'); }
@Get(':id/itens-linha') itens(@Param('id') id: string) { return this.svc.findItens(id); }
@Post(':id/itens-linha') createItem(@Param('id') id: string, @Body() body: any) {
return this.svc.createItem({ ...body, demanda_id: id });
}
@Patch(':id/itens-linha/:itemId') updateItem(@Param('itemId') itemId: string, @Body() body: any) {
return this.svc.updateItem(itemId, body);
}
@Delete(':id/itens-linha/:itemId') removeItem(@Param('itemId') itemId: string) {
return this.svc.removeItem(itemId);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Demanda } from './entities/demanda.entity';
import { ItemLinha } from './entities/item-linha.entity';
import { DemandasController } from './demandas.controller';
import { DemandasService } from './demandas.service';
@Module({
imports: [TypeOrmModule.forFeature([Demanda, ItemLinha])],
controllers: [DemandasController],
providers: [DemandasService],
exports: [DemandasService],
})
export class DemandasModule {}

View File

@@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Demanda } from './entities/demanda.entity';
import { ItemLinha } from './entities/item-linha.entity';
@Injectable()
export class DemandasService {
constructor(
@InjectRepository(Demanda) private repo: Repository<Demanda>,
@InjectRepository(ItemLinha) private itemRepo: Repository<ItemLinha>,
) {}
findAll(query?: any) {
const where: any = {};
if (query?.status) where.status = query.status;
if (query?.centro_custo_id) where.centro_custo_id = query.centro_custo_id;
if (query?.categoria_id) where.categoria_id = query.categoria_id;
return this.repo.find({ where, relations: ['itens_linha'], order: { created_at: 'DESC' } });
}
findOne(id: string) { return this.repo.findOne({ where: { id }, relations: ['itens_linha'] }); }
create(data: Partial<Demanda>) { return this.repo.save(data); }
async update(id: string, data: Partial<Demanda>) { await this.repo.update(id, data); return this.findOne(id); }
async updateStatus(id: string, status: string) {
await this.repo.update(id, { status });
return this.findOne(id);
}
findItens(demandaId: string) { return this.itemRepo.find({ where: { demanda_id: demandaId }, order: { ordem: 'ASC' } }); }
createItem(data: Partial<ItemLinha>) { return this.itemRepo.save(data); }
async updateItem(id: string, data: Partial<ItemLinha>) { await this.itemRepo.update(id, data); return this.itemRepo.findOne({ where: { id } }); }
async removeItem(id: string) { await this.itemRepo.delete(id); }
}

View File

@@ -0,0 +1,53 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { ItemLinha } from './item-linha.entity';
@Entity('demandas')
export class Demanda {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'integer', unique: true, nullable: true })
numero: number;
@Column({ length: 300 })
titulo: string;
@Column({ type: 'text', nullable: true })
descricao: string;
@Column()
local_id: string;
@Column()
centro_custo_id: string;
@Column()
categoria_id: string;
@Column({ length: 20 })
criticidade: string;
@Column({ nullable: true })
data_desejada: string;
@Column({ length: 30, default: 'rascunho' })
status: string;
@Column()
solicitante_id: string;
@Column({ nullable: true })
gestor_id: string;
@Column({ type: 'simple-json', default: '[]' })
documentos: any;
@OneToMany(() => ItemLinha, i => i.demanda)
itens_linha: ItemLinha[];
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}

View File

@@ -0,0 +1,36 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Demanda } from './demanda.entity';
@Entity('itens_linha')
export class ItemLinha {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
demanda_id: string;
@ManyToOne(() => Demanda, d => d.itens_linha)
@JoinColumn({ name: 'demanda_id' })
demanda: Demanda;
@Column({ length: 300 })
descricao: string;
@Column({ length: 50, nullable: true })
tipo: string;
@Column({ type: 'float', nullable: true })
quantidade: number;
@Column({ length: 30, nullable: true })
unidade: string;
@Column({ type: 'text', nullable: true })
observacoes: string;
@Column({ default: 0 })
ordem: number;
@CreateDateColumn()
created_at: Date;
}

View File

@@ -0,0 +1,36 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Fornecedor } from './fornecedor.entity';
@Entity('certidoes')
export class Certidao {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
fornecedor_id: string;
@ManyToOne(() => Fornecedor, f => f.certidoes)
@JoinColumn({ name: 'fornecedor_id' })
fornecedor: Fornecedor;
@Column({ length: 100 })
tipo: string;
@Column({ length: 100, nullable: true })
numero: string;
@Column()
data_emissao: string;
@Column()
data_validade: string;
@Column({ type: 'text', nullable: true })
arquivo_url: string;
@Column({ length: 20, default: 'vigente' })
status: string;
@CreateDateColumn()
created_at: Date;
}

View File

@@ -0,0 +1,50 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { Certidao } from './certidao.entity';
@Entity('fornecedores')
export class Fornecedor {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 10 })
tipo_pessoa: string;
@Column({ unique: true, length: 18 })
cpf_cnpj: string;
@Column({ length: 300 })
razao_social: string;
@Column({ length: 300, nullable: true })
nome_fantasia: string;
@Column({ length: 255, nullable: true })
email: string;
@Column({ length: 20, nullable: true })
telefone: string;
@Column({ type: 'text', nullable: true })
endereco: string;
@Column({ type: 'simple-json', nullable: true })
categorias_atendidas: string[];
@Column({ type: 'float', default: 3.0 })
rating: number;
@Column({ nullable: true })
usuario_id: string;
@Column({ default: true })
ativo: boolean;
@OneToMany(() => Certidao, c => c.fornecedor)
certidoes: Certidao[];
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get, Post, Patch, Delete, Body, Param } from '@nestjs/common';
import { FornecedoresService } from './fornecedores.service';
@Controller('fornecedores')
export class FornecedoresController {
constructor(private svc: FornecedoresService) {}
@Get() findAll() { return this.svc.findAll(); }
@Get(':id') findOne(@Param('id') id: string) { return this.svc.findOne(id); }
@Post() create(@Body() body: any) { return this.svc.create(body); }
@Patch(':id') update(@Param('id') id: string, @Body() body: any) { return this.svc.update(id, body); }
@Delete(':id') remove(@Param('id') id: string) { return this.svc.remove(id); }
@Get(':id/certidoes') certidoes(@Param('id') id: string) { return this.svc.findCertidoes(id); }
@Post(':id/certidoes') createCertidao(@Param('id') id: string, @Body() body: any) {
return this.svc.createCertidao({ ...body, fornecedor_id: id });
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Fornecedor } from './entities/fornecedor.entity';
import { Certidao } from './entities/certidao.entity';
import { FornecedoresController } from './fornecedores.controller';
import { FornecedoresService } from './fornecedores.service';
@Module({
imports: [TypeOrmModule.forFeature([Fornecedor, Certidao])],
controllers: [FornecedoresController],
providers: [FornecedoresService],
exports: [FornecedoresService],
})
export class FornecedoresModule {}

View File

@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Fornecedor } from './entities/fornecedor.entity';
import { Certidao } from './entities/certidao.entity';
@Injectable()
export class FornecedoresService {
constructor(
@InjectRepository(Fornecedor) private repo: Repository<Fornecedor>,
@InjectRepository(Certidao) private certRepo: Repository<Certidao>,
) {}
findAll() { return this.repo.find({ where: { ativo: true }, relations: ['certidoes'] }); }
findOne(id: string) { return this.repo.findOne({ where: { id }, relations: ['certidoes'] }); }
create(data: Partial<Fornecedor>) { return this.repo.save(data); }
async update(id: string, data: Partial<Fornecedor>) { await this.repo.update(id, data); return this.findOne(id); }
async remove(id: string) { await this.repo.update(id, { ativo: false }); }
findCertidoes(fornecedorId: string) { return this.certRepo.find({ where: { fornecedor_id: fornecedorId } }); }
createCertidao(data: Partial<Certidao>) { return this.certRepo.save(data); }
}

View File

@@ -0,0 +1,28 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
@Entity('locais')
export class Local {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 200 })
nome: string;
@Column({ type: 'text', nullable: true })
endereco: string;
@Column({ nullable: true })
centro_custo_id: string;
@Column({ nullable: true })
responsavel_id: string;
@Column({ default: true })
ativo: boolean;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}

View File

@@ -0,0 +1,13 @@
import { Controller, Get, Post, Patch, Delete, Body, Param } from '@nestjs/common';
import { LocaisService } from './locais.service';
@Controller('locais')
export class LocaisController {
constructor(private svc: LocaisService) {}
@Get() findAll() { return this.svc.findAll(); }
@Get(':id') findOne(@Param('id') id: string) { return this.svc.findOne(id); }
@Post() create(@Body() body: any) { return this.svc.create(body); }
@Patch(':id') update(@Param('id') id: string, @Body() body: any) { return this.svc.update(id, body); }
@Delete(':id') remove(@Param('id') id: string) { return this.svc.remove(id); }
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Local } from './entities/local.entity';
import { LocaisController } from './locais.controller';
import { LocaisService } from './locais.service';
@Module({
imports: [TypeOrmModule.forFeature([Local])],
controllers: [LocaisController],
providers: [LocaisService],
exports: [LocaisService],
})
export class LocaisModule {}

View File

@@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Local } from './entities/local.entity';
@Injectable()
export class LocaisService {
constructor(@InjectRepository(Local) private repo: Repository<Local>) {}
findAll() { return this.repo.find({ where: { ativo: true } }); }
findOne(id: string) { return this.repo.findOne({ where: { id } }); }
create(data: Partial<Local>) { return this.repo.save(data); }
async update(id: string, data: Partial<Local>) { await this.repo.update(id, data); return this.findOne(id); }
async remove(id: string) { await this.repo.update(id, { ativo: false }); }
}

View File

@@ -0,0 +1,34 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('orcamento_planejado')
export class OrcamentoPlanejado {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
ano: number;
@Column()
mes: number;
@Column()
centro_custo_id: string;
@Column()
categoria_id: string;
@Column({ type: 'float' })
valor_planejado: number;
@Column({ type: 'float', default: 0 })
valor_comprometido: number;
@Column({ type: 'float', default: 0 })
valor_realizado: number;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}

View File

@@ -0,0 +1,13 @@
import { Controller, Get, Post, Patch, Body, Param, Query } from '@nestjs/common';
import { OrcamentoService } from './orcamento.service';
@Controller('orcamento')
export class OrcamentoController {
constructor(private svc: OrcamentoService) {}
@Get() findAll(@Query() query: any) { return this.svc.findAll(query); }
@Get('resumo') resumo(@Query('ano') ano: string) { return this.svc.resumo(parseInt(ano) || 2026); }
@Get(':id') findOne(@Param('id') id: string) { return this.svc.findOne(id); }
@Post() create(@Body() body: any) { return this.svc.create(body); }
@Patch(':id') update(@Param('id') id: string, @Body() body: any) { return this.svc.update(id, body); }
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrcamentoPlanejado } from './entities/orcamento-planejado.entity';
import { OrcamentoController } from './orcamento.controller';
import { OrcamentoService } from './orcamento.service';
@Module({
imports: [TypeOrmModule.forFeature([OrcamentoPlanejado])],
controllers: [OrcamentoController],
providers: [OrcamentoService],
exports: [OrcamentoService],
})
export class OrcamentoModule {}

View File

@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OrcamentoPlanejado } from './entities/orcamento-planejado.entity';
@Injectable()
export class OrcamentoService {
constructor(@InjectRepository(OrcamentoPlanejado) private repo: Repository<OrcamentoPlanejado>) {}
findAll(query?: any) {
const where: any = {};
if (query?.ano) where.ano = query.ano;
if (query?.mes) where.mes = query.mes;
if (query?.centro_custo_id) where.centro_custo_id = query.centro_custo_id;
return this.repo.find({ where });
}
findOne(id: string) { return this.repo.findOne({ where: { id } }); }
create(data: Partial<OrcamentoPlanejado>) { return this.repo.save(data); }
async update(id: string, data: Partial<OrcamentoPlanejado>) { await this.repo.update(id, data); return this.findOne(id); }
async resumo(ano: number) {
const items = await this.repo.find({ where: { ano } });
const byMonth: any = {};
for (const item of items) {
if (!byMonth[item.mes]) byMonth[item.mes] = { mes: item.mes, planejado: 0, comprometido: 0, realizado: 0 };
byMonth[item.mes].planejado += item.valor_planejado;
byMonth[item.mes].comprometido += item.valor_comprometido;
byMonth[item.mes].realizado += item.valor_realizado;
}
return Object.values(byMonth).sort((a: any, b: any) => a.mes - b.mes);
}
}

View File

@@ -0,0 +1,25 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('avaliacoes')
export class Avaliacao {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
ordem_servico_id: string;
@Column()
fornecedor_id: string;
@Column()
avaliador_id: string;
@Column({ type: 'float' })
nota: number;
@Column({ type: 'text', nullable: true })
comentario: string;
@CreateDateColumn()
created_at: Date;
}

View File

@@ -0,0 +1,40 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('ordens_servico')
export class OrdemServico {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'integer', unique: true, nullable: true })
numero: number;
@Column()
demanda_id: string;
@Column()
proposta_id: string;
@Column()
fornecedor_id: string;
@Column({ type: 'float' })
valor: number;
@Column({ length: 30, default: 'emitida' })
status: string;
@Column({ nullable: true })
data_inicio: string;
@Column({ nullable: true })
data_conclusao: string;
@Column({ type: 'text', nullable: true })
observacoes: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}

View File

@@ -0,0 +1,17 @@
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { OrdensServicoService } from './ordens-servico.service';
@Controller('ordens-servico')
export class OrdensServicoController {
constructor(private svc: OrdensServicoService) {}
@Get() findAll() { return this.svc.findAll(); }
@Get(':id') findOne(@Param('id') id: string) { return this.svc.findOne(id); }
@Post() create(@Body() body: any) { return this.svc.create(body); }
@Post(':id/iniciar') iniciar(@Param('id') id: string) { return this.svc.iniciar(id); }
@Post(':id/concluir') concluir(@Param('id') id: string) { return this.svc.concluir(id); }
@Post(':id/cancelar') cancelar(@Param('id') id: string) { return this.svc.cancelar(id); }
@Post(':id/avaliacao') avaliacao(@Param('id') id: string, @Body() body: any) {
return this.svc.createAvaliacao({ ...body, ordem_servico_id: id });
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrdemServico } from './entities/ordem-servico.entity';
import { Avaliacao } from './entities/avaliacao.entity';
import { OrdensServicoController } from './ordens-servico.controller';
import { OrdensServicoService } from './ordens-servico.service';
@Module({
imports: [TypeOrmModule.forFeature([OrdemServico, Avaliacao])],
controllers: [OrdensServicoController],
providers: [OrdensServicoService],
exports: [OrdensServicoService],
})
export class OrdensServicoModule {}

View File

@@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OrdemServico } from './entities/ordem-servico.entity';
import { Avaliacao } from './entities/avaliacao.entity';
@Injectable()
export class OrdensServicoService {
constructor(
@InjectRepository(OrdemServico) private repo: Repository<OrdemServico>,
@InjectRepository(Avaliacao) private avalRepo: Repository<Avaliacao>,
) {}
findAll() { return this.repo.find(); }
findOne(id: string) { return this.repo.findOne({ where: { id } }); }
create(data: Partial<OrdemServico>) { return this.repo.save(data); }
async iniciar(id: string) { await this.repo.update(id, { status: 'em_execucao', data_inicio: new Date().toISOString().split('T')[0] }); return this.findOne(id); }
async concluir(id: string) { await this.repo.update(id, { status: 'concluida', data_conclusao: new Date().toISOString().split('T')[0] }); return this.findOne(id); }
async cancelar(id: string) { await this.repo.update(id, { status: 'cancelada' }); return this.findOne(id); }
createAvaliacao(data: Partial<Avaliacao>) { return this.avalRepo.save(data); }
}

View File

@@ -0,0 +1,52 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('propostas')
export class Proposta {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
demanda_id: string;
@Column()
fornecedor_id: string;
@Column({ default: 1 })
versao_atual: number;
@Column({ type: 'float', nullable: true })
valor_bruto: number;
@Column({ type: 'float', nullable: true })
valor_liquido: number;
@Column({ type: 'simple-json', default: '{}' })
impostos: any;
@Column({ type: 'text', nullable: true })
condicao_pagamento: string;
@Column({ nullable: true })
prazo_execucao_dias: number;
@Column({ nullable: true })
data_entrega_estimada: string;
@Column({ type: 'float', nullable: true })
match_escopo_pct: number;
@Column({ type: 'float', nullable: true })
confiabilidade_ocr: number;
@Column({ default: false })
selecionada: boolean;
@Column({ length: 20, default: 'recebida' })
status: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common';
import { PropostasService } from './propostas.service';
@Controller()
export class PropostasController {
constructor(private svc: PropostasService) {}
@Get('propostas') findAll() { return this.svc.findAll(); }
@Get('propostas/:id') findOne(@Param('id') id: string) { return this.svc.findOne(id); }
@Post('propostas') create(@Body() body: any) { return this.svc.create(body); }
@Patch('propostas/:id') update(@Param('id') id: string, @Body() body: any) { return this.svc.update(id, body); }
@Post('propostas/:id/selecionar') selecionar(@Param('id') id: string) { return this.svc.selecionar(id); }
@Get('demandas/:demandaId/propostas') byDemanda(@Param('demandaId') id: string) { return this.svc.findByDemanda(id); }
@Post('demandas/:demandaId/propostas') createForDemanda(@Param('demandaId') demandaId: string, @Body() body: any) {
return this.svc.create({ ...body, demanda_id: demandaId });
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Proposta } from './entities/proposta.entity';
import { PropostasController } from './propostas.controller';
import { PropostasService } from './propostas.service';
@Module({
imports: [TypeOrmModule.forFeature([Proposta])],
controllers: [PropostasController],
providers: [PropostasService],
exports: [PropostasService],
})
export class PropostasModule {}

View File

@@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Proposta } from './entities/proposta.entity';
@Injectable()
export class PropostasService {
constructor(@InjectRepository(Proposta) private repo: Repository<Proposta>) {}
findByDemanda(demandaId: string) { return this.repo.find({ where: { demanda_id: demandaId } }); }
findAll() { return this.repo.find(); }
findOne(id: string) { return this.repo.findOne({ where: { id } }); }
create(data: Partial<Proposta>) { return this.repo.save(data); }
async update(id: string, data: Partial<Proposta>) { await this.repo.update(id, data); return this.findOne(id); }
async selecionar(id: string) {
const prop = await this.findOne(id);
// Deselect others for same demanda
await this.repo.update({ demanda_id: prop.demanda_id }, { selecionada: false });
await this.repo.update(id, { selecionada: true, status: 'selecionada' });
return this.findOne(id);
}
}

View File

@@ -0,0 +1,26 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { Usuario } from './usuario.entity';
@Entity('perfis')
export class Perfil {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true, length: 50 })
nome: string;
@Column({ type: 'text', nullable: true })
descricao: string;
@Column({ type: 'simple-json', default: '{}' })
permissoes: any;
@OneToMany(() => Usuario, u => u.perfil)
usuarios: Usuario[];
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}

View File

@@ -0,0 +1,36 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Perfil } from './perfil.entity';
@Entity('usuarios')
export class Usuario {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 200 })
nome: string;
@Column({ unique: true, length: 255 })
email: string;
@Column({ length: 255 })
senha_hash: string;
@Column()
perfil_id: string;
@ManyToOne(() => Perfil, p => p.usuarios)
@JoinColumn({ name: 'perfil_id' })
perfil: Perfil;
@Column({ default: true })
ativo: boolean;
@Column({ nullable: true })
ultimo_acesso: Date;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}

View File

@@ -0,0 +1,25 @@
import { Controller, Get, Post, Patch, Delete, Body, Param } from '@nestjs/common';
import { UsersService } from './users.service';
@Controller()
export class UsersController {
constructor(private svc: UsersService) {}
@Get('users')
findAll() { return this.svc.findAll(); }
@Get('users/:id')
findOne(@Param('id') id: string) { return this.svc.findOne(id); }
@Post('users')
create(@Body() body: any) { return this.svc.create(body); }
@Patch('users/:id')
update(@Param('id') id: string, @Body() body: any) { return this.svc.update(id, body); }
@Delete('users/:id')
remove(@Param('id') id: string) { return this.svc.remove(id); }
@Get('perfis')
perfis() { return this.svc.findAllPerfis(); }
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Usuario } from './entities/usuario.entity';
import { Perfil } from './entities/perfil.entity';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [TypeOrmModule.forFeature([Usuario, Perfil])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Usuario } from './entities/usuario.entity';
import { Perfil } from './entities/perfil.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(Usuario) private userRepo: Repository<Usuario>,
@InjectRepository(Perfil) private perfilRepo: Repository<Perfil>,
) {}
findAll() { return this.userRepo.find({ relations: ['perfil'], where: { ativo: true } }); }
findOne(id: string) { return this.userRepo.findOne({ where: { id }, relations: ['perfil'] }); }
create(data: Partial<Usuario>) { return this.userRepo.save(data); }
async update(id: string, data: Partial<Usuario>) { await this.userRepo.update(id, data); return this.findOne(id); }
async remove(id: string) { await this.userRepo.update(id, { ativo: false }); }
findAllPerfis() { return this.perfilRepo.find(); }
}

View File

@@ -0,0 +1,37 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('workflow_aprovacao')
export class WorkflowAprovacao {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
demanda_id: string;
@Column({ nullable: true })
proposta_id: string;
@Column({ type: 'float' })
valor_total: number;
@Column({ length: 30, default: 'pendente' })
status: string;
@Column({ default: 1 })
etapa_atual: number;
@Column({ type: 'simple-json', default: '[]' })
etapas: any;
@Column({ default: false })
emergencial: boolean;
@Column({ type: 'text', nullable: true })
justificativa_emergencial: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}

View File

@@ -0,0 +1,14 @@
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { WorkflowService } from './workflow.service';
@Controller('workflow')
export class WorkflowController {
constructor(private svc: WorkflowService) {}
@Get() findAll() { return this.svc.findAll(); }
@Get('pendentes') pendentes() { return this.svc.findPendentes(); }
@Get(':id') findOne(@Param('id') id: string) { return this.svc.findOne(id); }
@Post() create(@Body() body: any) { return this.svc.create(body); }
@Post(':id/aprovar') aprovar(@Param('id') id: string, @Body() body: any) { return this.svc.aprovar(id, body?.observacao); }
@Post(':id/reprovar') reprovar(@Param('id') id: string, @Body() body: any) { return this.svc.reprovar(id, body?.observacao); }
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkflowAprovacao } from './entities/workflow-aprovacao.entity';
import { WorkflowController } from './workflow.controller';
import { WorkflowService } from './workflow.service';
@Module({
imports: [TypeOrmModule.forFeature([WorkflowAprovacao])],
controllers: [WorkflowController],
providers: [WorkflowService],
exports: [WorkflowService],
})
export class WorkflowModule {}

View File

@@ -0,0 +1,51 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkflowAprovacao } from './entities/workflow-aprovacao.entity';
@Injectable()
export class WorkflowService {
constructor(@InjectRepository(WorkflowAprovacao) private repo: Repository<WorkflowAprovacao>) {}
findAll() { return this.repo.find(); }
findOne(id: string) { return this.repo.findOne({ where: { id } }); }
findByDemanda(demandaId: string) { return this.repo.findOne({ where: { demanda_id: demandaId } }); }
create(data: Partial<WorkflowAprovacao>) { return this.repo.save(data); }
async aprovar(id: string, observacao?: string) {
const wf = await this.repo.findOneOrFail({ where: { id } });
const etapas = wf.etapas as any[];
const current = etapas[wf.etapa_atual - 1];
if (current) {
current.status = 'aprovado';
current.data_acao = new Date().toISOString();
current.observacao = observacao || 'Aprovado';
}
if (wf.etapa_atual >= etapas.length) {
wf.status = 'aprovado';
} else {
wf.etapa_atual++;
wf.status = 'em_andamento';
}
wf.etapas = etapas;
return this.repo.save(wf);
}
async reprovar(id: string, observacao: string) {
const wf = await this.repo.findOneOrFail({ where: { id } });
const etapas = wf.etapas as any[];
const current = etapas[wf.etapa_atual - 1];
if (current) {
current.status = 'reprovado';
current.data_acao = new Date().toISOString();
current.observacao = observacao;
}
wf.status = 'reprovado';
wf.etapas = etapas;
return this.repo.save(wf);
}
findPendentes() {
return this.repo.find({ where: { status: 'pendente' } });
}
}

View File

@@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

View File

@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}

1104
docs/ARQUITETURA-TECNICA.md Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

304
docs/MANUAL-NEGOCIOS.md Normal file
View File

@@ -0,0 +1,304 @@
# HEFESTO — Manual de Negócios
**Sistema de Controle Orçamentário para Facilities**
Versão 1.0 | Fevereiro 2025
---
## 1. Visão Geral do Produto
O **HEFESTO** é uma plataforma web para gestão integrada de Facilities, cobrindo todo o ciclo de vida de uma demanda de serviço — da abertura até a avaliação pós-execução — com controle orçamentário, workflow de aprovações e gestão de fornecedores.
O nome faz referência a **Hefesto**, o deus grego da forja e da construção, simbolizando a solidez e precisão que o sistema traz à gestão de instalações e infraestrutura.
## 2. Problema que Resolve
### Cenário Atual (sem HEFESTO)
A gestão de Facilities em empresas de médio e grande porte sofre com:
- **Demandas por e-mail e WhatsApp:** Solicitações de manutenção, reformas e serviços chegam de forma desorganizada, sem rastreabilidade
- **Propostas em planilhas Excel:** Comparação manual de cotações, sujeita a erros e vieses
- **Orçamento sem visibilidade:** Gestores não sabem quanto já gastaram vs. quanto planejaram até consultar o financeiro
- **Aprovações por e-mail:** Cadeia de aprovação informal, sem registro de quem aprovou o quê e quando
- **Fornecedores sem controle:** Certidões vencidas, avaliações subjetivas, concentração em poucos prestadores
- **Zero rastreabilidade:** Impossível saber o status de uma demanda sem ligar para alguém
### Com o HEFESTO
| Antes | Depois |
|---|---|
| Demanda por e-mail | Formulário digital com SLA automático |
| Planilha de cotação | Comparativo automático com equalização |
| Aprovação informal | Workflow com alçadas e registro completo |
| Orçamento no escuro | Dashboard em tempo real, planejado vs. realizado |
| Fornecedor sem controle | Cadastro com certidões, rating e histórico |
| Sem visibilidade | Painel executivo com KPIs e alertas |
## 3. Perfis de Usuário e Jornadas
### 3.1 Solicitante
**Quem é:** Gerente de unidade, coordenador, colaborador que identifica uma necessidade.
**Jornada típica:**
1. Faz login no HEFESTO
2. Abre nova demanda descrevendo o serviço necessário
3. Seleciona local, categoria e prioridade
4. Acompanha o status da demanda em tempo real
5. Recebe notificações de cada mudança de status
6. Avalia o serviço após conclusão
### 3.2 Gestor de Facilities
**Quem é:** Profissional responsável pela operação de Facilities.
**Jornada típica:**
1. Visualiza dashboard com demandas pendentes e indicadores
2. Analisa e complementa o escopo das demandas recebidas
3. Seleciona fornecedores e envia pedidos de cotação
4. Recebe e compara propostas (com auxílio de OCR e equalização)
5. Seleciona a melhor proposta e encaminha para aprovação
6. Após aprovação, emite Ordem de Serviço
7. Acompanha execução e prazos
8. Registra conclusão e solicita avaliação
### 3.3 Aprovador Financeiro
**Quem é:** Controller, gerente financeiro, analista de orçamento.
**Jornada típica:**
1. Recebe notificação de demanda aguardando aprovação financeira
2. Analisa valor vs. orçamento disponível no centro de custo
3. Consulta comparativo de propostas e justificativa
4. Aprova, rejeita ou devolve para correção
5. Monitora orçamento planejado vs. realizado
### 3.4 Diretoria
**Quem é:** Diretor de operações, CFO, CEO.
**Jornada típica:**
1. Aprova demandas de alto valor (acima da alçada do financeiro)
2. Acompanha dashboard executivo com visão macro
3. Analisa tendências de gastos e projeções
### 3.5 Fornecedor
**Quem é:** Prestador de serviços cadastrado no sistema.
**Jornada típica:**
1. Recebe convite de cotação por e-mail
2. Acessa o HEFESTO e submete proposta com valores e documentos
3. Acompanha se foi selecionado
4. Recebe Ordem de Serviço
5. Registra conclusão do trabalho
### 3.6 Admin
**Quem é:** Administrador do sistema (TI ou gestor principal).
**Jornada típica:**
1. Configura locais, centros de custo e categorias
2. Gerencia usuários e perfis de acesso
3. Define alçadas de aprovação
4. Monitora audit log e saúde do sistema
## 4. Fluxo Completo de uma Demanda
```
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐
│ ABERTURA │───►│ ESCOPO │───►│ COTAÇÃO │───►│ PROPOSTAS │
│ │ │ │ │ │ │ RECEBIDAS │
└──────────┘ └──────────┘ └──────────┘ └──────┬───────┘
Solicitante Gestor Fac. Gestor Fac. │
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐
│AVALIAÇÃO │◄───│CONCLUÍDA │◄───│EXECUÇÃO │◄───│ APROVAÇÃO │
│ │ │ │ │ │ │ (Workflow) │
└──────────┘ └──────────┘ └──────────┘ └──────────────┘
Solicitante Gestor Fac. Fornecedor Financ./Diret.
```
### Etapas Detalhadas
1. **Abertura:** Solicitante descreve a necessidade, seleciona local, categoria e prioridade
2. **Escopo:** Gestor de Facilities detalha os itens de linha, quantidades e especificações técnicas
3. **Cotação:** Gestor seleciona 3+ fornecedores e envia convite de cotação
4. **Propostas Recebidas:** Fornecedores submetem suas propostas com valores e documentos
5. **Comparação:** Sistema gera quadro comparativo automático com equalização de itens
6. **Aprovação:** Demanda segue workflow de alçadas conforme valor total
7. **Ordem de Serviço:** Após aprovação, OS é emitida automaticamente para o fornecedor selecionado
8. **Execução:** Fornecedor executa o serviço; gestor acompanha prazos e SLA
9. **Avaliação:** Solicitante e gestor avaliam qualidade, prazo e comunicação do fornecedor
## 5. Funcionalidades por Módulo
### 5.1 Dashboard
- KPIs em tempo real (demandas abertas, em aprovação, gastos do mês)
- Gráfico de gastos por local e categoria
- Evolução mensal de gastos (planejado vs. realizado)
- Ranking de fornecedores por nota
- Indicadores de SLA (% dentro do prazo)
- Alertas de certidões vencendo e orçamento estourando
### 5.2 Demandas
- Criação com formulário guiado (wizard)
- Itens de linha com quantidade, unidade e valor estimado
- Filtros por status, local, categoria, período e responsável
- Timeline visual de cada demanda
- Anexos e comentários
### 5.3 Propostas
- Submissão pelo fornecedor com upload de documentos
- **OCR automático:** Extração de valores de propostas em PDF/imagem
- **Equalização automática:** Normalização de itens para comparação justa
- Quadro comparativo lado a lado
- Seleção com justificativa obrigatória
### 5.4 Orçamento
- Planejamento anual por centro de custo e mês
- Acompanhamento realizado vs. planejado em tempo real
- Projeção de gastos com base no pipeline de demandas
- Alertas automáticos ao atingir 80% e 100% do orçamento
- Remanejamento entre centros de custo (com aprovação)
### 5.5 Fornecedores
- Cadastro completo (razão social, CNPJ, contato, especialidades)
- Gestão de certidões com alerta de vencimento
- Rating automático baseado em avaliações
- Histórico de OS e valores contratados
- Blacklist e restrições
### 5.6 Workflow
- Configuração de alçadas por faixa de valor
- Aprovação em cadeia (sequencial) ou paralela
- Notificações por e-mail e no sistema
- Prazo para aprovação com escalação automática
- Histórico completo de aprovações e rejeições
### 5.7 Ordens de Serviço
- Emissão automática após aprovação
- Número sequencial por ano
- Controle de datas (início, previsão, conclusão real)
- Status: Emitida → Em Execução → Concluída → Avaliada
### 5.8 Relatórios
- Gastos por local, categoria e fornecedor
- Economia gerada (valor estimado vs. contratado)
- Tempo médio de ciclo por etapa
- Exportação em PDF e Excel
## 6. Modelo de Negócio
### 6.1 Formato
**SaaS B2B** (Software as a Service) com cobrança recorrente mensal.
### 6.2 Pricing
| Plano | Unidades | Usuários | Preço/mês |
|---|---|---|---|
| **Starter** | Até 5 | Até 20 | R$ 1.500 |
| **Professional** | Até 20 | Até 100 | R$ 4.500 |
| **Enterprise** | Ilimitado | Ilimitado | Sob consulta |
**Adicional por unidade:** R$ 200/mês (plano Starter e Professional)
**Adicional por usuário:** R$ 50/mês
### 6.3 Modelo de Receita
- Assinatura mensal recorrente (MRR)
- Setup fee (implantação e treinamento): 2x o valor mensal
- Customizações sob demanda (hora técnica)
### 6.4 Mercado-Alvo
- Empresas com 10+ unidades (shoppings, escritórios, fábricas, hospitais)
- Áreas de Facilities / Manutenção / Infraestrutura
- Empresas de administração de condomínios corporativos
- Redes de varejo com muitas lojas
## 7. Diferenciais Competitivos
### 7.1 OCR em Propostas
Tecnologia de reconhecimento óptico para extrair valores de propostas enviadas em PDF ou imagem. Elimina a digitação manual e reduz erros.
### 7.2 Equalização Automática
Algoritmo que normaliza itens de diferentes propostas para comparação justa, mesmo quando fornecedores usam unidades, descrições ou agrupamentos diferentes.
### 7.3 Workflow de Alçadas
Motor de aprovação configurável por faixa de valor, com escalação automática, prazos e notificações. Garante conformidade e rastreabilidade total.
### 7.4 Orçamento em Tempo Real
Visão instantânea de planejado vs. realizado, com projeções baseadas no pipeline de demandas aprovadas e em andamento.
### 7.5 Rating de Fornecedores
Sistema de avaliação baseado em critérios objetivos (prazo, qualidade, comunicação) que alimenta o ranking automaticamente.
### 7.6 Audit Trail Completo
Toda ação no sistema é registrada com timestamp, usuário, IP e dados antes/depois. Compliance total para auditorias.
## 8. Roadmap de Evolução
### Fase 1 — MVP (Atual) ✅
- [x] Autenticação e RBAC
- [x] CRUD de demandas, fornecedores, propostas
- [x] Workflow de aprovação com alçadas
- [x] Dashboard com KPIs básicos
- [x] Orçamento planejado vs. realizado
- [x] Ordens de serviço
### Fase 2 — Q2 2025
- [ ] OCR para extração de valores de propostas
- [ ] App mobile (React Native) para aprovações rápidas
- [ ] Integração com ERP (SAP, TOTVS) via API
- [ ] Notificações push
### Fase 3 — Q3 2025
- [ ] Módulo de contratos (gestão de contratos recorrentes)
- [ ] Equalização automática de propostas com IA
- [ ] Portal do fornecedor (self-service)
- [ ] Multi-idioma (PT/EN/ES)
### Fase 4 — Q4 2025
- [ ] BI avançado com drill-down
- [ ] Predição de manutenção (ML)
- [ ] Marketplace de fornecedores
- [ ] White-label para revendas
## 9. Métricas de Sucesso
### 9.1 Métricas de Produto
| Métrica | Meta | Como medir |
|---|---|---|
| Tempo médio de ciclo da demanda | < 15 dias | Média de created_at até status CONCLUÍDA |
| % de demandas com 3+ propostas | > 80% | Demandas com 3+ propostas / total |
| % de aprovações no prazo | > 95% | Aprovações dentro do SLA configurado |
| Economia média por demanda | > 12% | (Valor estimado - valor contratado) / estimado |
| NPS do solicitante | > 70 | Pesquisa trimestral |
### 9.2 Métricas de Negócio
| Métrica | Meta Ano 1 | Meta Ano 2 |
|---|---|---|
| MRR (Receita Mensal Recorrente) | R$ 30.000 | R$ 150.000 |
| Clientes ativos | 10 | 40 |
| Churn mensal | < 3% | < 2% |
| CAC (Custo de Aquisição) | < R$ 5.000 | < R$ 4.000 |
| LTV (Lifetime Value) | > R$ 50.000 | > R$ 80.000 |
| LTV/CAC | > 10x | > 20x |
### 9.3 Métricas de Impacto no Cliente
- **Redução de 70%** no tempo gasto com gestão manual de cotações
- **Redução de 40%** no ciclo de aprovação
- **Economia média de 15%** em contratações por comparação efetiva
- **100% de rastreabilidade** em auditorias
- **Zero** propostas perdidas ou esquecidas
---
*Documento gerado automaticamente — HEFESTO v1.0*

BIN
docs/MANUAL-NEGOCIOS.pdf Normal file

Binary file not shown.

508
docs/MANUAL-TECNICO.md Normal file
View File

@@ -0,0 +1,508 @@
# HEFESTO — Manual Técnico
**Sistema de Controle Orçamentário para Facilities**
Versão 1.0 | Fevereiro 2025
---
## 1. Visão Geral da Arquitetura
O HEFESTO é uma aplicação web fullstack composta por:
- **Backend:** API REST em NestJS (Node.js) com TypeORM
- **Frontend:** SPA em React + TypeScript com Vite
- **Banco de Dados:** SQLite (desenvolvimento) / PostgreSQL (produção)
- **Autenticação:** JWT com RBAC (Role-Based Access Control)
```
┌─────────────────┐ HTTP/REST ┌─────────────────┐
│ React SPA │ ◄──────────────► │ NestJS API │
│ (Vite) │ JWT Bearer │ (TypeORM) │
│ Port 5173 │ │ Port 3000 │
└─────────────────┘ └────────┬────────┘
┌────────▼────────┐
│ SQLite / PG │
└─────────────────┘
```
## 2. Stack Completa
| Componente | Tecnologia | Versão |
|---|---|---|
| Runtime | Node.js | 22.x LTS |
| Backend Framework | NestJS | 10.x |
| ORM | TypeORM | 0.3.x |
| Frontend Framework | React | 18.x |
| Build Tool | Vite | 5.x |
| Linguagem | TypeScript | 5.x |
| UI Components | Tailwind CSS | 3.x |
| BD Desenvolvimento | SQLite3 | 5.x |
| BD Produção | PostgreSQL | 16.x |
| Auth | @nestjs/jwt + passport | 10.x |
| HTTP Client | Axios | 1.x |
| Validação | class-validator | 0.14.x |
| Documentação API | @nestjs/swagger | 7.x |
## 3. Estrutura de Pastas
### 3.1 Backend
```
backend/
├── src/
│ ├── main.ts # Bootstrap da aplicação
│ ├── app.module.ts # Módulo raiz
│ ├── common/ # Guards, decorators, pipes, interceptors
│ │ ├── guards/
│ │ │ ├── jwt-auth.guard.ts
│ │ │ └── roles.guard.ts
│ │ ├── decorators/
│ │ │ ├── roles.decorator.ts
│ │ │ └── current-user.decorator.ts
│ │ └── interceptors/
│ │ └── audit.interceptor.ts
│ ├── database/ # Configuração TypeORM, migrations, seeds
│ │ ├── database.module.ts
│ │ ├── migrations/
│ │ └── seeds/
│ └── modules/
│ ├── auth/ # Autenticação JWT, login, refresh token
│ │ ├── auth.controller.ts
│ │ ├── auth.service.ts
│ │ ├── auth.module.ts
│ │ ├── strategies/
│ │ │ └── jwt.strategy.ts
│ │ └── dto/
│ ├── users/ # CRUD de usuários e perfis
│ │ ├── users.controller.ts
│ │ ├── users.service.ts
│ │ ├── entities/
│ │ │ ├── usuario.entity.ts
│ │ │ └── perfil.entity.ts
│ │ └── dto/
│ ├── locais/ # Gestão de locais/unidades
│ ├── centros-custo/ # Centros de custo vinculados a locais
│ ├── categorias/ # Categorias de serviço
│ ├── fornecedores/ # Cadastro de fornecedores e certidões
│ ├── demandas/ # Abertura e gestão de demandas
│ ├── propostas/ # Recebimento e comparação de propostas
│ ├── orcamento/ # Orçamento planejado vs realizado
│ ├── workflow/ # Máquina de estados de aprovação
│ ├── dashboard/ # Indicadores e relatórios
│ └── ordens-servico/ # Emissão e acompanhamento de OS
├── test/
├── nest-cli.json
├── tsconfig.json
└── package.json
```
### 3.2 Frontend
```
frontend/
├── src/
│ ├── main.tsx # Entry point
│ ├── App.tsx # Router + Layout
│ ├── assets/ # Imagens, ícones
│ ├── components/ # Componentes reutilizáveis
│ │ ├── Layout/
│ │ ├── Sidebar/
│ │ ├── Header/
│ │ ├── DataTable/
│ │ ├── StatusBadge/
│ │ └── Charts/
│ ├── pages/
│ │ ├── Login.tsx # Tela de autenticação
│ │ ├── Dashboard.tsx # Painel de indicadores
│ │ ├── Demandas.tsx # Lista de demandas com filtros
│ │ ├── Landing.tsx # Página inicial / nova demanda
│ │ ├── Fornecedores.tsx # Gestão de fornecedores
│ │ ├── Orcamentos.tsx # Orçamento planejado vs realizado
│ │ ├── OrdensServico.tsx # Ordens de serviço
│ │ ├── Relatorios.tsx # Relatórios gerenciais
│ │ └── Usuarios.tsx # Administração de usuários
│ ├── services/ # Axios clients e API calls
│ │ └── api.ts
│ ├── types/ # Interfaces TypeScript
│ └── index.css # Tailwind directives
├── vite.config.ts
├── tailwind.config.js
├── tsconfig.json
└── package.json
```
## 4. Modelo de Dados
### 4.1 Diagrama de Entidades
O sistema possui 16 entidades principais:
```
perfis ──< usuarios ──< demandas ──< itens_linha
│ │
│ ├──< propostas
│ │
│ ├──< workflow_aprovacao
│ │
│ └──< ordens_servico ──< avaliacoes
└──< audit_log
locais ──< centros_custo ──< orcamento_planejado
categorias ──< demandas
fornecedores ──< certidoes
fornecedores ──< propostas
alertas (standalone)
```
### 4.2 Descrição das Entidades
| # | Entidade | Descrição | Campos Principais |
|---|---|---|---|
| 1 | **perfis** | Perfis de acesso (RBAC) | id, nome, descricao, permissoes (JSON) |
| 2 | **usuarios** | Usuários do sistema | id, nome, email, senha_hash, perfil_id, ativo, ultimo_acesso |
| 3 | **locais** | Unidades/edifícios | id, nome, endereco, cidade, estado, cnpj, ativo |
| 4 | **centros_custo** | Centros de custo por local | id, codigo, descricao, local_id, ativo |
| 5 | **categorias** | Categorias de serviço | id, nome, descricao, sla_dias, ativo |
| 6 | **fornecedores** | Cadastro de fornecedores | id, razao_social, cnpj, contato, email, telefone, ativo, rating |
| 7 | **certidoes** | Certidões de fornecedores | id, fornecedor_id, tipo, arquivo_url, validade, status |
| 8 | **orcamento_planejado** | Budget por centro de custo/ano | id, centro_custo_id, ano, mes, valor_planejado, valor_realizado |
| 9 | **demandas** | Demandas de serviço | id, titulo, descricao, local_id, categoria_id, solicitante_id, status, prioridade, valor_estimado, created_at |
| 10 | **itens_linha** | Itens detalhados da demanda | id, demanda_id, descricao, quantidade, unidade, valor_unitario |
| 11 | **propostas** | Propostas de fornecedores | id, demanda_id, fornecedor_id, valor_total, arquivo_url, data_validade, status, observacoes |
| 12 | **workflow_aprovacao** | Etapas de aprovação | id, demanda_id, etapa, aprovador_id, status, comentario, data_acao |
| 13 | **ordens_servico** | Ordens de serviço emitidas | id, demanda_id, proposta_id, numero_os, data_inicio, data_fim_prevista, data_fim_real, status |
| 14 | **avaliacoes** | Avaliação pós-execução | id, ordem_servico_id, avaliador_id, nota, comentario, created_at |
| 15 | **audit_log** | Log de auditoria | id, usuario_id, acao, entidade, entidade_id, dados_antes, dados_depois, ip, created_at |
| 16 | **alertas** | Notificações e alertas | id, usuario_id, tipo, mensagem, lido, referencia_tipo, referencia_id, created_at |
### 4.3 Perfis de Acesso (RBAC)
| Perfil | Permissões |
|---|---|
| **Admin** | Acesso total ao sistema, gestão de usuários e configurações |
| **Gestor Facilities** | CRUD de demandas, fornecedores, propostas, OS; aprovação nível 1 |
| **Aprovador Financeiro** | Aprovação nível 2 (financeiro), visualização de orçamento |
| **Diretoria** | Aprovação nível 3 (alçada máxima), dashboard executivo |
| **Solicitante** | Abertura de demandas, acompanhamento do próprio pedido |
| **Fornecedor** | Envio de propostas, acompanhamento de OS atribuídas |
## 5. API — Endpoints
### 5.1 Autenticação (`/api/auth`)
| Método | Rota | Descrição |
|---|---|---|
| POST | `/api/auth/login` | Login com email/senha → JWT |
| POST | `/api/auth/refresh` | Renovar token |
| POST | `/api/auth/logout` | Invalidar token |
| GET | `/api/auth/me` | Dados do usuário logado |
| POST | `/api/auth/forgot-password` | Solicitar reset de senha |
| POST | `/api/auth/reset-password` | Resetar senha com token |
### 5.2 Usuários (`/api/users`)
| Método | Rota | Descrição |
|---|---|---|
| GET | `/api/users` | Listar usuários (paginado) |
| GET | `/api/users/:id` | Detalhes do usuário |
| POST | `/api/users` | Criar usuário |
| PATCH | `/api/users/:id` | Atualizar usuário |
| DELETE | `/api/users/:id` | Desativar usuário |
| GET | `/api/perfis` | Listar perfis |
### 5.3 Locais (`/api/locais`)
| Método | Rota | Descrição |
|---|---|---|
| GET | `/api/locais` | Listar locais |
| GET | `/api/locais/:id` | Detalhes do local |
| POST | `/api/locais` | Criar local |
| PATCH | `/api/locais/:id` | Atualizar local |
| DELETE | `/api/locais/:id` | Desativar local |
### 5.4 Centros de Custo (`/api/centros-custo`)
| Método | Rota | Descrição |
|---|---|---|
| GET | `/api/centros-custo` | Listar centros de custo |
| GET | `/api/centros-custo/:id` | Detalhes |
| POST | `/api/centros-custo` | Criar centro de custo |
| PATCH | `/api/centros-custo/:id` | Atualizar |
| DELETE | `/api/centros-custo/:id` | Desativar |
### 5.5 Categorias (`/api/categorias`)
| Método | Rota | Descrição |
|---|---|---|
| GET | `/api/categorias` | Listar categorias |
| GET | `/api/categorias/:id` | Detalhes |
| POST | `/api/categorias` | Criar categoria |
| PATCH | `/api/categorias/:id` | Atualizar |
| DELETE | `/api/categorias/:id` | Desativar |
### 5.6 Fornecedores (`/api/fornecedores`)
| Método | Rota | Descrição |
|---|---|---|
| GET | `/api/fornecedores` | Listar fornecedores |
| GET | `/api/fornecedores/:id` | Detalhes com certidões |
| POST | `/api/fornecedores` | Cadastrar fornecedor |
| PATCH | `/api/fornecedores/:id` | Atualizar fornecedor |
| DELETE | `/api/fornecedores/:id` | Desativar |
| POST | `/api/fornecedores/:id/certidoes` | Upload de certidão |
| GET | `/api/fornecedores/:id/certidoes` | Listar certidões |
| DELETE | `/api/fornecedores/:id/certidoes/:certidaoId` | Remover certidão |
### 5.7 Demandas (`/api/demandas`)
| Método | Rota | Descrição |
|---|---|---|
| GET | `/api/demandas` | Listar demandas (filtros: status, local, categoria, período) |
| GET | `/api/demandas/:id` | Detalhes completos da demanda |
| POST | `/api/demandas` | Criar nova demanda |
| PATCH | `/api/demandas/:id` | Atualizar demanda |
| DELETE | `/api/demandas/:id` | Cancelar demanda |
| POST | `/api/demandas/:id/itens` | Adicionar item de linha |
| PATCH | `/api/demandas/:id/itens/:itemId` | Atualizar item |
| DELETE | `/api/demandas/:id/itens/:itemId` | Remover item |
| POST | `/api/demandas/:id/enviar-cotacao` | Enviar para cotação |
| GET | `/api/demandas/:id/comparativo` | Comparativo de propostas |
### 5.8 Propostas (`/api/propostas`)
| Método | Rota | Descrição |
|---|---|---|
| GET | `/api/propostas` | Listar propostas |
| GET | `/api/propostas/:id` | Detalhes da proposta |
| POST | `/api/propostas` | Submeter proposta (fornecedor) |
| PATCH | `/api/propostas/:id` | Atualizar proposta |
| DELETE | `/api/propostas/:id` | Retirar proposta |
| POST | `/api/propostas/:id/aceitar` | Aceitar proposta |
| POST | `/api/propostas/:id/rejeitar` | Rejeitar proposta |
| POST | `/api/propostas/:id/ocr` | Processar OCR do arquivo |
### 5.9 Orçamento (`/api/orcamento`)
| Método | Rota | Descrição |
|---|---|---|
| GET | `/api/orcamento` | Orçamento geral (filtros: ano, local, centro_custo) |
| GET | `/api/orcamento/:id` | Detalhes da linha orçamentária |
| POST | `/api/orcamento` | Criar linha orçamentária |
| PATCH | `/api/orcamento/:id` | Atualizar valores |
| GET | `/api/orcamento/resumo` | Resumo planejado vs realizado |
| GET | `/api/orcamento/projecao` | Projeção de gastos |
### 5.10 Workflow (`/api/workflow`)
| Método | Rota | Descrição |
|---|---|---|
| GET | `/api/workflow/pendentes` | Aprovações pendentes do usuário logado |
| GET | `/api/workflow/demanda/:demandaId` | Histórico de aprovações da demanda |
| POST | `/api/workflow/aprovar` | Aprovar etapa |
| POST | `/api/workflow/rejeitar` | Rejeitar etapa |
| POST | `/api/workflow/devolver` | Devolver para correção |
| GET | `/api/workflow/alçadas` | Consultar alçadas configuradas |
### 5.11 Dashboard (`/api/dashboard`)
| Método | Rota | Descrição |
|---|---|---|
| GET | `/api/dashboard/indicadores` | KPIs gerais |
| GET | `/api/dashboard/demandas-por-status` | Demandas agrupadas por status |
| GET | `/api/dashboard/gastos-por-local` | Gastos por unidade |
| GET | `/api/dashboard/gastos-por-categoria` | Gastos por categoria |
| GET | `/api/dashboard/evolucao-mensal` | Série temporal de gastos |
| GET | `/api/dashboard/top-fornecedores` | Ranking de fornecedores |
| GET | `/api/dashboard/sla` | Indicadores de SLA |
### 5.12 Ordens de Serviço (`/api/ordens-servico`)
| Método | Rota | Descrição |
|---|---|---|
| GET | `/api/ordens-servico` | Listar OS |
| GET | `/api/ordens-servico/:id` | Detalhes da OS |
| POST | `/api/ordens-servico` | Emitir OS |
| PATCH | `/api/ordens-servico/:id` | Atualizar OS |
| POST | `/api/ordens-servico/:id/iniciar` | Marcar início da execução |
| POST | `/api/ordens-servico/:id/concluir` | Marcar conclusão |
| POST | `/api/ordens-servico/:id/avaliar` | Avaliar serviço prestado |
### 5.13 Alertas (`/api/alertas`)
| Método | Rota | Descrição |
|---|---|---|
| GET | `/api/alertas` | Listar alertas do usuário |
| PATCH | `/api/alertas/:id/lido` | Marcar como lido |
| DELETE | `/api/alertas/:id` | Remover alerta |
**Total: 68 endpoints**
## 6. Autenticação e Autorização
### 6.1 Fluxo JWT
1. Usuário faz POST `/api/auth/login` com email e senha
2. Backend valida credenciais e retorna `{ accessToken, refreshToken }`
3. Frontend armazena tokens e envia `Authorization: Bearer <accessToken>` em todas as requests
4. Token expira em 1h; refresh token expira em 7d
5. Frontend usa `/api/auth/refresh` para renovar automaticamente
### 6.2 Guards NestJS
```typescript
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'gestor_facilities')
@Get('demandas')
findAll() { ... }
```
- **JwtAuthGuard**: valida o token JWT
- **RolesGuard**: verifica se o perfil do usuário está na lista de roles permitidas
- **@CurrentUser()**: decorator customizado que injeta o usuário logado
## 7. Workflow de Aprovação
### 7.1 Máquina de Estados
```
RASCUNHO → EM_ESCOPO → EM_COTAÇÃO → PROPOSTAS_RECEBIDAS
→ EM_COMPARAÇÃO → AGUARDANDO_APROVAÇÃO → APROVADA
→ OS_EMITIDA → EM_EXECUÇÃO → CONCLUÍDA → AVALIADA
Estados alternativos:
AGUARDANDO_APROVAÇÃO → REJEITADA
AGUARDANDO_APROVAÇÃO → DEVOLVIDA → EM_ESCOPO
Qualquer estado → CANCELADA
```
### 7.2 Alçadas de Aprovação
| Faixa de Valor | Aprovador |
|---|---|
| Até R$ 5.000 | Gestor Facilities |
| R$ 5.001 — R$ 50.000 | Gestor Facilities + Aprovador Financeiro |
| R$ 50.001 — R$ 200.000 | Gestor + Financeiro + Diretoria |
| Acima de R$ 200.000 | Gestor + Financeiro + Diretoria + CEO |
Cada etapa gera um registro em `workflow_aprovacao` com timestamp, aprovador e comentário.
## 8. Como Rodar Localmente
### 8.1 Pré-requisitos
- Node.js 22.x
- npm 10.x
- Git
### 8.2 Backend
```bash
cd backend
cp .env.example .env # Ajustar variáveis
npm install
npm run build
npm run migration:run # Criar tabelas
npm run seed # Dados iniciais
npm start # Porta 3000
```
### 8.3 Frontend
```bash
cd frontend
cp .env.example .env # VITE_API_URL=http://localhost:3000/api
npm install
npm run dev # Porta 5173
```
### 8.4 Acesso Inicial
- **Admin:** admin@hefesto.com.br / admin123
- **Swagger:** http://localhost:3000/api/docs
## 9. Deploy em Produção
### 9.1 Infraestrutura
- **Servidor:** DigitalOcean Droplet (Ubuntu 24.04, 2 vCPU, 4GB RAM)
- **Proxy reverso:** Nginx
- **SSL:** Let's Encrypt (Certbot)
- **Processo:** PM2 (backend) / Nginx serve build estático (frontend)
- **BD:** PostgreSQL 16
### 9.2 Nginx Config
```nginx
server {
listen 443 ssl;
server_name hefesto.exemplo.com.br;
ssl_certificate /etc/letsencrypt/live/hefesto.exemplo.com.br/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hefesto.exemplo.com.br/privkey.pem;
# Frontend (build estático)
location / {
root /var/www/hefesto/frontend/dist;
try_files $uri $uri/ /index.html;
}
# API Backend
location /api {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### 9.3 PM2
```bash
cd /var/www/hefesto/backend
pm2 start dist/main.js --name hefesto-api
pm2 save
pm2 startup
```
## 10. Variáveis de Ambiente
### Backend (`.env`)
| Variável | Descrição | Exemplo |
|---|---|---|
| `NODE_ENV` | Ambiente | `production` |
| `PORT` | Porta da API | `3000` |
| `DB_TYPE` | Tipo de banco | `postgres` |
| `DB_HOST` | Host do banco | `localhost` |
| `DB_PORT` | Porta do banco | `5432` |
| `DB_USERNAME` | Usuário do banco | `hefesto` |
| `DB_PASSWORD` | Senha do banco | `***` |
| `DB_DATABASE` | Nome do banco | `hefesto_prod` |
| `JWT_SECRET` | Chave secreta JWT | `minha-chave-secreta-256bits` |
| `JWT_EXPIRATION` | Tempo de expiração do access token | `3600s` |
| `JWT_REFRESH_EXPIRATION` | Tempo de expiração do refresh token | `7d` |
| `CORS_ORIGIN` | Origem permitida | `https://hefesto.exemplo.com.br` |
| `OCR_API_KEY` | Chave da API de OCR | `***` |
| `SMTP_HOST` | Servidor SMTP | `smtp.gmail.com` |
| `SMTP_PORT` | Porta SMTP | `587` |
| `SMTP_USER` | Usuário SMTP | `noreply@hefesto.com.br` |
| `SMTP_PASS` | Senha SMTP | `***` |
### Frontend (`.env`)
| Variável | Descrição | Exemplo |
|---|---|---|
| `VITE_API_URL` | URL base da API | `https://hefesto.exemplo.com.br/api` |
| `VITE_APP_NAME` | Nome da aplicação | `HEFESTO` |
---
*Documento gerado automaticamente — HEFESTO v1.0*

BIN
docs/MANUAL-TECNICO.pdf Normal file

Binary file not shown.

105
docs/PESQUISA-MERCADO.md Normal file
View File

@@ -0,0 +1,105 @@
# PESQUISA DE MERCADO: HEFESTO (Facilities Management System)
> **Nota:** Este documento foi gerado com base em conhecimento de mercado pré-existente e análise estratégica interna. Dados numéricos específicos em tempo real (2024-2025) podem requerer validação adicional, pois o acesso à pesquisa web estava indisponível no momento da criação.
## 1. Visão Geral do Mercado
O mercado de **Facilities Management (FM)** está em expansão acelerada, impulsionado pela necessidade de eficiência operacional, redução de custos e adaptação aos novos modelos de trabalho híbrido.
### Tamanho e Crescimento
* **Global:** O mercado global de FM foi avaliado em aproximadamente **USD 1,2 trilhão em 2022** e projeta-se que alcance cerca de **USD 1,8 trilhão até 2028-2030**, com um CAGR (Taxa de Crescimento Anual Composta) estimado entre **5% e 7%**.
* **Brasil:** O Brasil representa o maior mercado da América Latina. O setor de serviços e terceirização (onde o FM se insere fortemente) continua sendo um dos pilares do PIB. A demanda por soluções de software (IWMS - Integrated Workplace Management Systems) está crescendo à medida que empresas buscam digitalizar processos manuais (planilhas/papel).
### Tendências Principais
1. **Digitalização e IoT:** Uso de sensores para manutenção preditiva e gestão de energia.
2. **Sustentabilidade (ESG):** Pressão para que edifícios sejam "verdes" e eficientes energeticamente.
3. **Experiência do Ocupante:** O foco mudou da simples manutenção do prédio para o bem-estar e produtividade dos usuários (UX).
4. **Trabalho Híbrido:** Gestão flexível de espaços (hot-desking, reserva de salas) tornou-se crítica pós-pandemia.
---
## 2. Análise da Concorrência
O mercado possui players consolidados (Enterprise) e soluções de nicho.
### Principais Concorrentes
| Software | Perfil | Pontos Fortes | Pontos Fracos / Gaps |
| :--- | :--- | :--- | :--- |
| **IBM Tririga** | Enterprise / Global | Robustez, integração com IoT/Watson, gestão imobiliária completa. | Custo proibitivo para médias empresas; implementação complexa e longa; curva de aprendizado alta. |
| **Archibus** | Enterprise / Global | Líder tradicional em gestão de espaço e ativos; muito completo. | Interface pode parecer datada; excesso de funcionalidades que poluem a usabilidade (bloatware); alto custo. |
| **Planon** | Enterprise / Europeu | Forte em sustentabilidade e compliance; boa interface web. | Preço elevado; customização pode ser rígida dependendo do módulo. |
| **TrackVia** | Low-code / Flexível | Plataforma "faça você mesmo"; rapidez para criar formulários. | Não é um FM nativo; requer construção da lógica de facilities do zero; falta de "best practices" embutidas. |
| **Optii** | Nicho (Hotelaria) | Otimização de housekeeping; muito focado em operações de limpeza. | Muito específico para hotéis; não atende bem escritórios corporativos ou indústrias com manutenção técnica complexa. |
| **Software Local (BR)** | Pequenos Players | Preço em Reais; suporte local. | Geralmente limitados a gestão de ordens de serviço simples; falta de inteligência (IA/OCR) e workflows financeiros robustos. |
### Gaps Identificados no Mercado
* **Complexidade Excessiva:** As soluções Enterprise (IBM, Archibus) são "canhões para matar moscas" para muitas empresas brasileiras.
* **Falta de Inteligência Financeira:** Muitos softwares focam apenas na Ordem de Serviço (técnica) e esquecem a dor da **compra/cotação** (financeira).
* **Usabilidade:** Interfaces legadas que dificultam a adoção por equipes operacionais em campo.
---
## 3. Diferenciais do HEFESTO
O **HEFESTO** se posiciona para preencher o gap entre a "planilha de controle" e os "sistemas Enterprise caros", com foco em automação inteligente e controle financeiro.
### 1. OCR Inteligente em Propostas (O "Game Changer")
* **O Problema:** O gestor de facilities recebe dezenas de orçamentos em PDF/Imagem de fornecedores (limpeza, elétrica, ar-condicionado). Digitar isso no sistema é lento e propenso a erros.
* **A Solução HEFESTO:** O sistema lê os PDFs dos fornecedores, extrai os itens, valores unitários e totais automaticamente, populando o comparativo.
### 2. Equalização Automática de Propostas
* **Funcionalidade:** Após o OCR ler 3 orçamentos diferentes, o sistema gera um mapa de equalização (Mapa Comparativo) lado a lado, destacando o menor preço, o melhor prazo e discrepâncias técnicas.
* **Benefício:** Redução de 80% no tempo de análise de compras.
### 3. Workflow de Alçadas Dinâmico
* **Funcionalidade:** Aprovações configuráveis por valor, centro de custo ou categoria. (Ex: "Manutenção acima de R$ 5k precisa do Gerente; acima de R$ 50k precisa do Diretor").
* **Benefício:** Compliance e agilidade, tudo via mobile ou web, sem depender de trocas de e-mail.
### 4. Controle Orçamentário Integrado (Budget vs. Realizado)
* **Funcionalidade:** Cada aprovação ou OS executada desconta em tempo real do orçamento daquela conta (ex: Manutenção Predial).
* **Benefício:** O gestor sabe se tem verba *antes* de aprovar o serviço, evitando estouros de budget no fim do mês.
---
## 4. Modelo de Negócio
**Formato:** SaaS (Software as a Service) B2B.
### Estratégia de Pricing
A precificação será híbrida para garantir escalabilidade e receita recorrente previsível.
1. **Por Unidade/Site (Predominante):** Valor base por prédio/escritório gerenciado. Incentiva o cliente a colocar todos os usuários do prédio no sistema sem medo de "custo por assento".
2. **Por Usuário Admin (Opcional):** Cobrança apenas para usuários "Power" (Gestores, Compradores). Técnicos de campo e solicitantes (abertura de chamados) são **ilimitados/gratuitos** para reduzir barreira de entrada.
### Tiers (Planos) Sugeridos
* **IRON (Básico)**
* Gestão de Ordens de Serviço (Preventiva/Corretiva).
* App para Técnicos.
* Solicitantes ilimitados.
* *Ideal para: Pequenos escritórios, condomínios simples.*
* **STEEL (Intermediário)**
* Tudo do IRON +
* Gestão de Ativos (QR Code).
* Controle de Estoque básico.
* Relatórios de SLA.
* *Ideal para: Médias empresas, redes de varejo.*
* **TITANIUM (Avançado - O "Hefesto Completo")**
* Tudo do STEEL +
* **Módulo de Compras (OCR + Equalização).**
* Gestão Orçamentária (Budget Control).
* Workflows de Aprovação complexos.
* API para integração (ERP).
* *Ideal para: Grandes corporações, hospitais, indústrias.*
---
## 5. Identidade do Projeto
* **Nome:** **HEFESTO**
* **Origem:** Deus grego dos ferreiros, artesãos, escultores, metalurgia, fogo e vulcões.
* **Simbologia:** Representa a construção, a manutenção, a técnica e a habilidade de criar ferramentas funcionais.
* **Slogan Sugerido:** "Forjando a eficiência na gestão de facilities." ou "O controle total da sua operação."

24
frontend/.gitignore vendored Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

35
frontend/package.json Normal file
View 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"
}
}

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

View 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

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

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

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

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

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

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

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

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

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

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

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

Some files were not shown because too many files have changed in this diff Show More