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:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.next/
|
||||||
|
database.sqlite
|
||||||
|
*.sqlite
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
4
backend/.prettierrc
Normal file
4
backend/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
98
backend/README.md
Normal file
98
backend/README.md
Normal 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>
|
||||||
|
<!--[](https://opencollective.com/nest#backer)
|
||||||
|
[](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
35
backend/eslint.config.mjs
Normal 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
8
backend/nest-cli.json
Normal 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
11057
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
86
backend/package.json
Normal file
86
backend/package.json
Normal 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
61
backend/src/app.module.ts
Normal 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 {}
|
||||||
3
backend/src/common/decorators/roles.decorator.ts
Normal file
3
backend/src/common/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
19
backend/src/common/guards/jwt-auth.guard.ts
Normal file
19
backend/src/common/guards/jwt-auth.guard.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/src/common/guards/roles.guard.ts
Normal file
19
backend/src/common/guards/roles.guard.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
249
backend/src/database/seeds/seed.service.ts
Normal file
249
backend/src/database/seeds/seed.service.ts
Normal 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
18
backend/src/main.ts
Normal 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();
|
||||||
20
backend/src/modules/auth/auth.controller.ts
Normal file
20
backend/src/modules/auth/auth.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/src/modules/auth/auth.module.ts
Normal file
24
backend/src/modules/auth/auth.module.ts
Normal 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 {}
|
||||||
63
backend/src/modules/auth/auth.service.ts
Normal file
63
backend/src/modules/auth/auth.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/src/modules/auth/jwt.strategy.ts
Normal file
24
backend/src/modules/auth/jwt.strategy.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/modules/categorias/categorias.controller.ts
Normal file
13
backend/src/modules/categorias/categorias.controller.ts
Normal 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); }
|
||||||
|
}
|
||||||
13
backend/src/modules/categorias/categorias.module.ts
Normal file
13
backend/src/modules/categorias/categorias.module.ts
Normal 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 {}
|
||||||
14
backend/src/modules/categorias/categorias.service.ts
Normal file
14
backend/src/modules/categorias/categorias.service.ts
Normal 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 }); }
|
||||||
|
}
|
||||||
31
backend/src/modules/categorias/entities/categoria.entity.ts
Normal file
31
backend/src/modules/categorias/entities/categoria.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
13
backend/src/modules/centros-custo/centros-custo.module.ts
Normal file
13
backend/src/modules/centros-custo/centros-custo.module.ts
Normal 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 {}
|
||||||
14
backend/src/modules/centros-custo/centros-custo.service.ts
Normal file
14
backend/src/modules/centros-custo/centros-custo.service.ts
Normal 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 }); }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
13
backend/src/modules/dashboard/dashboard.controller.ts
Normal file
13
backend/src/modules/dashboard/dashboard.controller.ts
Normal 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); }
|
||||||
|
}
|
||||||
19
backend/src/modules/dashboard/dashboard.module.ts
Normal file
19
backend/src/modules/dashboard/dashboard.module.ts
Normal 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 {}
|
||||||
73
backend/src/modules/dashboard/dashboard.service.ts
Normal file
73
backend/src/modules/dashboard/dashboard.service.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/src/modules/dashboard/entities/alerta.entity.ts
Normal file
31
backend/src/modules/dashboard/entities/alerta.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
34
backend/src/modules/dashboard/entities/audit-log.entity.ts
Normal file
34
backend/src/modules/dashboard/entities/audit-log.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
27
backend/src/modules/demandas/demandas.controller.ts
Normal file
27
backend/src/modules/demandas/demandas.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/modules/demandas/demandas.module.ts
Normal file
14
backend/src/modules/demandas/demandas.module.ts
Normal 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 {}
|
||||||
35
backend/src/modules/demandas/demandas.service.ts
Normal file
35
backend/src/modules/demandas/demandas.service.ts
Normal 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); }
|
||||||
|
}
|
||||||
53
backend/src/modules/demandas/entities/demanda.entity.ts
Normal file
53
backend/src/modules/demandas/entities/demanda.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
36
backend/src/modules/demandas/entities/item-linha.entity.ts
Normal file
36
backend/src/modules/demandas/entities/item-linha.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
36
backend/src/modules/fornecedores/entities/certidao.entity.ts
Normal file
36
backend/src/modules/fornecedores/entities/certidao.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
18
backend/src/modules/fornecedores/fornecedores.controller.ts
Normal file
18
backend/src/modules/fornecedores/fornecedores.controller.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/modules/fornecedores/fornecedores.module.ts
Normal file
14
backend/src/modules/fornecedores/fornecedores.module.ts
Normal 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 {}
|
||||||
22
backend/src/modules/fornecedores/fornecedores.service.ts
Normal file
22
backend/src/modules/fornecedores/fornecedores.service.ts
Normal 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); }
|
||||||
|
}
|
||||||
28
backend/src/modules/locais/entities/local.entity.ts
Normal file
28
backend/src/modules/locais/entities/local.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
13
backend/src/modules/locais/locais.controller.ts
Normal file
13
backend/src/modules/locais/locais.controller.ts
Normal 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); }
|
||||||
|
}
|
||||||
13
backend/src/modules/locais/locais.module.ts
Normal file
13
backend/src/modules/locais/locais.module.ts
Normal 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 {}
|
||||||
14
backend/src/modules/locais/locais.service.ts
Normal file
14
backend/src/modules/locais/locais.service.ts
Normal 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 }); }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
13
backend/src/modules/orcamento/orcamento.controller.ts
Normal file
13
backend/src/modules/orcamento/orcamento.controller.ts
Normal 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); }
|
||||||
|
}
|
||||||
13
backend/src/modules/orcamento/orcamento.module.ts
Normal file
13
backend/src/modules/orcamento/orcamento.module.ts
Normal 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 {}
|
||||||
33
backend/src/modules/orcamento/orcamento.service.ts
Normal file
33
backend/src/modules/orcamento/orcamento.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/modules/ordens-servico/ordens-servico.module.ts
Normal file
14
backend/src/modules/ordens-servico/ordens-servico.module.ts
Normal 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 {}
|
||||||
23
backend/src/modules/ordens-servico/ordens-servico.service.ts
Normal file
23
backend/src/modules/ordens-servico/ordens-servico.service.ts
Normal 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); }
|
||||||
|
}
|
||||||
52
backend/src/modules/propostas/entities/proposta.entity.ts
Normal file
52
backend/src/modules/propostas/entities/proposta.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
18
backend/src/modules/propostas/propostas.controller.ts
Normal file
18
backend/src/modules/propostas/propostas.controller.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/modules/propostas/propostas.module.ts
Normal file
13
backend/src/modules/propostas/propostas.module.ts
Normal 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 {}
|
||||||
23
backend/src/modules/propostas/propostas.service.ts
Normal file
23
backend/src/modules/propostas/propostas.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/src/modules/users/entities/perfil.entity.ts
Normal file
26
backend/src/modules/users/entities/perfil.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
36
backend/src/modules/users/entities/usuario.entity.ts
Normal file
36
backend/src/modules/users/entities/usuario.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
25
backend/src/modules/users/users.controller.ts
Normal file
25
backend/src/modules/users/users.controller.ts
Normal 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(); }
|
||||||
|
}
|
||||||
14
backend/src/modules/users/users.module.ts
Normal file
14
backend/src/modules/users/users.module.ts
Normal 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 {}
|
||||||
20
backend/src/modules/users/users.service.ts
Normal file
20
backend/src/modules/users/users.service.ts
Normal 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(); }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
14
backend/src/modules/workflow/workflow.controller.ts
Normal file
14
backend/src/modules/workflow/workflow.controller.ts
Normal 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); }
|
||||||
|
}
|
||||||
13
backend/src/modules/workflow/workflow.module.ts
Normal file
13
backend/src/modules/workflow/workflow.module.ts
Normal 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 {}
|
||||||
51
backend/src/modules/workflow/workflow.service.ts
Normal file
51
backend/src/modules/workflow/workflow.service.ts
Normal 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' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/test/app.e2e-spec.ts
Normal file
25
backend/test/app.e2e-spec.ts
Normal 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!');
|
||||||
|
});
|
||||||
|
});
|
||||||
9
backend/test/jest-e2e.json
Normal file
9
backend/test/jest-e2e.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
backend/tsconfig.build.json
Normal file
4
backend/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
25
backend/tsconfig.json
Normal file
25
backend/tsconfig.json
Normal 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
1104
docs/ARQUITETURA-TECNICA.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
docs/ESPECIFICACAO-FUNCIONAL.pdf
Normal file
BIN
docs/ESPECIFICACAO-FUNCIONAL.pdf
Normal file
Binary file not shown.
304
docs/MANUAL-NEGOCIOS.md
Normal file
304
docs/MANUAL-NEGOCIOS.md
Normal 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
BIN
docs/MANUAL-NEGOCIOS.pdf
Normal file
Binary file not shown.
508
docs/MANUAL-TECNICO.md
Normal file
508
docs/MANUAL-TECNICO.md
Normal 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
BIN
docs/MANUAL-TECNICO.pdf
Normal file
Binary file not shown.
105
docs/PESQUISA-MERCADO.md
Normal file
105
docs/PESQUISA-MERCADO.md
Normal 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
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
16
frontend/README.md
Normal file
16
frontend/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
29
frontend/eslint.config.js
Normal file
29
frontend/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="HEFESTO - Sistema de Controle Orçamentário para Facilities" />
|
||||||
|
<title>HEFESTO - Controle Orçamentário</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4210
frontend/package-lock.json
generated
Normal file
4210
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
frontend/package.json
Normal file
35
frontend/package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
|
"tailwindcss": "^4.1.18"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^25.2.2",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/public/favicon.svg
Normal file
10
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="flame" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style="stop-color:#E65100"/>
|
||||||
|
<stop offset="100%" style="stop-color:#FF8F00"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="100" height="100" rx="20" fill="url(#flame)"/>
|
||||||
|
<path d="M50 20c-5 15 5 25 0 40-3-10-15-15-10-30 5 10 15 5 10-10z M50 25c8 12-2 22 5 35 2-8 12-12 8-25-4 8-12 4-13-10z" fill="white" opacity="0.9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 500 B |
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
39
frontend/src/App.tsx
Normal file
39
frontend/src/App.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import Landing from './pages/Landing'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import Layout from './components/Layout'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import Demandas from './pages/Demandas'
|
||||||
|
import Orcamentos from './pages/Orcamentos'
|
||||||
|
import OrdensServico from './pages/OrdensServico'
|
||||||
|
import Fornecedores from './pages/Fornecedores'
|
||||||
|
import Relatorios from './pages/Relatorios'
|
||||||
|
import Usuarios from './pages/Usuarios'
|
||||||
|
|
||||||
|
interface PrivateRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrivateRoute({ children }: PrivateRouteProps) {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
return token ? <>{children}</> : <Navigate to="/login" />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Landing />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/app" element={<PrivateRoute><Layout /></PrivateRoute>}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="demandas" element={<Demandas />} />
|
||||||
|
<Route path="orcamentos" element={<Orcamentos />} />
|
||||||
|
<Route path="ordens-servico" element={<OrdensServico />} />
|
||||||
|
<Route path="fornecedores" element={<Fornecedores />} />
|
||||||
|
<Route path="relatorios" element={<Relatorios />} />
|
||||||
|
<Route path="usuarios" element={<Usuarios />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
255
frontend/src/components/Layout.tsx
Normal file
255
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
FileText,
|
||||||
|
Wallet,
|
||||||
|
ClipboardList,
|
||||||
|
Building2,
|
||||||
|
BarChart3,
|
||||||
|
Users,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
Flame,
|
||||||
|
ChevronLeft,
|
||||||
|
Bell,
|
||||||
|
Search
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { User } from '../types'
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
adminOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ path: '/app', label: 'Dashboard', icon: <LayoutDashboard className="w-5 h-5" /> },
|
||||||
|
{ path: '/app/demandas', label: 'Demandas', icon: <FileText className="w-5 h-5" /> },
|
||||||
|
{ path: '/app/orcamentos', label: 'Orçamentos', icon: <Wallet className="w-5 h-5" /> },
|
||||||
|
{ path: '/app/ordens-servico', label: 'Ordens de Serviço', icon: <ClipboardList className="w-5 h-5" /> },
|
||||||
|
{ path: '/app/fornecedores', label: 'Fornecedores', icon: <Building2 className="w-5 h-5" /> },
|
||||||
|
{ path: '/app/relatorios', label: 'Relatórios', icon: <BarChart3 className="w-5 h-5" /> },
|
||||||
|
{ path: '/app/usuarios', label: 'Usuários', icon: <Users className="w-5 h-5" />, adminOnly: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const userData = localStorage.getItem('user')
|
||||||
|
if (userData) {
|
||||||
|
setUser(JSON.parse(userData))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = user?.perfil?.toLowerCase() === 'admin' || user?.perfil?.toLowerCase() === 'administrador'
|
||||||
|
|
||||||
|
const filteredNavItems = navItems.filter(item => !item.adminOnly || isAdmin)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex">
|
||||||
|
{/* Desktop Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={`hidden lg:flex flex-col bg-secondary fixed h-screen transition-all duration-300 z-40 ${
|
||||||
|
sidebarOpen ? 'w-64' : 'w-20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="p-4 flex items-center justify-between border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center shadow-lg">
|
||||||
|
<Flame className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
{sidebarOpen && (
|
||||||
|
<span className="font-bold text-white text-xl tracking-tight">HEFESTO</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
className="p-2 rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-all"
|
||||||
|
>
|
||||||
|
<ChevronLeft className={`w-5 h-5 transition-transform ${!sidebarOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 p-4 space-y-2 overflow-y-auto">
|
||||||
|
{filteredNavItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
end={item.path === '/app'}
|
||||||
|
className={({ isActive }) => `
|
||||||
|
flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200
|
||||||
|
${isActive
|
||||||
|
? 'bg-gradient-to-r from-primary to-accent text-white shadow-lg shadow-primary/30'
|
||||||
|
: 'text-white/70 hover:text-white hover:bg-white/10'
|
||||||
|
}
|
||||||
|
${!sidebarOpen ? 'justify-center px-3' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{sidebarOpen && <span className="font-medium">{item.label}</span>}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User section */}
|
||||||
|
<div className="p-4 border-t border-white/10">
|
||||||
|
{sidebarOpen ? (
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 flex items-center justify-center">
|
||||||
|
<span className="text-white font-semibold">
|
||||||
|
{user?.nome?.charAt(0).toUpperCase() || 'U'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white font-medium truncate">{user?.nome || 'Usuário'}</p>
|
||||||
|
<p className="text-white/50 text-sm truncate">{user?.perfil || 'Perfil'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className={`flex items-center gap-3 w-full px-4 py-3 rounded-xl text-white/70 hover:text-white hover:bg-red-500/20 transition-all ${
|
||||||
|
!sidebarOpen ? 'justify-center px-3' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
{sidebarOpen && <span className="font-medium">Sair</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile Menu Overlay */}
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<div
|
||||||
|
className="lg:hidden fixed inset-0 bg-black/50 z-40"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={`lg:hidden fixed inset-y-0 left-0 w-72 bg-secondary z-50 transform transition-transform duration-300 ${
|
||||||
|
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="p-4 flex items-center justify-between border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center">
|
||||||
|
<Flame className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-white text-xl">HEFESTO</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="p-2 rounded-lg text-white/60 hover:text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="p-4 space-y-2">
|
||||||
|
{filteredNavItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
end={item.path === '/app'}
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className={({ isActive }) => `
|
||||||
|
flex items-center gap-3 px-4 py-3 rounded-xl transition-all
|
||||||
|
${isActive
|
||||||
|
? 'bg-gradient-to-r from-primary to-accent text-white'
|
||||||
|
: 'text-white/70 hover:text-white hover:bg-white/10'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-white/10">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 flex items-center justify-center">
|
||||||
|
<span className="text-white font-semibold">
|
||||||
|
{user?.nome?.charAt(0).toUpperCase() || 'U'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white font-medium truncate">{user?.nome || 'Usuário'}</p>
|
||||||
|
<p className="text-white/50 text-sm truncate">{user?.perfil || 'Perfil'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-3 w-full px-4 py-3 rounded-xl text-white/70 hover:text-white hover:bg-red-500/20 transition-all"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
<span className="font-medium">Sair</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className={`flex-1 transition-all duration-300 ${sidebarOpen ? 'lg:ml-64' : 'lg:ml-20'}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white border-b border-border sticky top-0 z-30">
|
||||||
|
<div className="flex items-center justify-between px-4 lg:px-6 h-16">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileMenuOpen(true)}
|
||||||
|
className="lg:hidden p-2 rounded-lg text-gray hover:text-text hover:bg-gray-100 transition-all"
|
||||||
|
>
|
||||||
|
<Menu className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
<div className="hidden sm:flex items-center gap-2 px-4 py-2 bg-gray-50 rounded-xl">
|
||||||
|
<Search className="w-5 h-5 text-gray-light" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar..."
|
||||||
|
className="bg-transparent border-none outline-none text-sm w-48 placeholder-gray-light"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button className="p-2 rounded-xl text-gray hover:text-primary hover:bg-primary/5 transition-all relative">
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
<span className="absolute top-1 right-1 w-2 h-2 bg-primary rounded-full"></span>
|
||||||
|
</button>
|
||||||
|
<div className="hidden sm:flex items-center gap-2 pl-3 border-l border-border">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-accent flex items-center justify-center">
|
||||||
|
<span className="text-white text-sm font-semibold">
|
||||||
|
{user?.nome?.charAt(0).toUpperCase() || 'U'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-text">{user?.nome?.split(' ')[0] || 'Usuário'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page Content */}
|
||||||
|
<main className="p-4 lg:p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
282
frontend/src/index.css
Normal file
282
frontend/src/index.css
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary: #E65100;
|
||||||
|
--color-primary-dark: #BF360C;
|
||||||
|
--color-secondary: #1A237E;
|
||||||
|
--color-secondary-light: #3949AB;
|
||||||
|
--color-accent: #FF8F00;
|
||||||
|
--color-accent-light: #FFB300;
|
||||||
|
--color-text: #212121;
|
||||||
|
--color-gray: #757575;
|
||||||
|
--color-gray-light: #9E9E9E;
|
||||||
|
--color-card: #FAFAFA;
|
||||||
|
--color-border: #E0E0E0;
|
||||||
|
--font-family-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family-sans);
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
color: var(--color-text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #F5F5F5;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #BDBDBD;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #9E9E9E;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-left {
|
||||||
|
from { opacity: 0; transform: translateX(-20px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% { box-shadow: 0 0 20px rgba(230, 81, 0, 0.3); }
|
||||||
|
50% { box-shadow: 0 0 40px rgba(230, 81, 0, 0.5); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.4s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-left {
|
||||||
|
animation: slide-in-left 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-glow {
|
||||||
|
animation: pulse-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styles */
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(to right, var(--color-primary), var(--color-accent));
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(230, 81, 0, 0.3);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--color-secondary);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: var(--color-secondary-light);
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(26, 35, 126, 0.2);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
border: 2px solid var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
color: var(--color-gray);
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background-color: #F5F5F5;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input styles */
|
||||||
|
.input-field {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background-color: white;
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field::placeholder {
|
||||||
|
color: var(--color-gray-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:focus {
|
||||||
|
outline: none;
|
||||||
|
ring: 2px;
|
||||||
|
ring-color: rgba(230, 81, 0, 0.2);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card styles */
|
||||||
|
.card {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
padding: 1.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
padding: 1.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover:hover {
|
||||||
|
border-color: rgba(230, 81, 0, 0.3);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge styles */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background-color: #DCFCE7;
|
||||||
|
color: #15803D;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background-color: #FEF3C7;
|
||||||
|
color: #B45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error {
|
||||||
|
background-color: #FEE2E2;
|
||||||
|
color: #DC2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
background-color: #DBEAFE;
|
||||||
|
color: #1D4ED8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-neutral {
|
||||||
|
background-color: #F3F4F6;
|
||||||
|
color: #4B5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table styles */
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header {
|
||||||
|
background-color: var(--color-card);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-gray);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row:hover {
|
||||||
|
background-color: rgba(250, 250, 250, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-cell {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton loading */
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(to right, #E5E7EB, #F3F4F6, #E5E7EB);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
297
frontend/src/pages/Dashboard.tsx
Normal file
297
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Wallet,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
Building2,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
ArrowUpRight,
|
||||||
|
Loader2
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts'
|
||||||
|
import api from '../services/api'
|
||||||
|
import { User } from '../types'
|
||||||
|
|
||||||
|
interface StatsCard {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
change?: string;
|
||||||
|
changeType?: 'positive' | 'negative' | 'neutral';
|
||||||
|
icon: React.ReactNode;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthlyData = [
|
||||||
|
{ name: 'Jan', previsto: 4000, realizado: 3800 },
|
||||||
|
{ name: 'Fev', previsto: 3500, realizado: 3200 },
|
||||||
|
{ name: 'Mar', previsto: 4200, realizado: 4100 },
|
||||||
|
{ name: 'Abr', previsto: 3800, realizado: 3900 },
|
||||||
|
{ name: 'Mai', previsto: 4500, realizado: 4200 },
|
||||||
|
{ name: 'Jun', previsto: 4000, realizado: 3700 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const categoryData = [
|
||||||
|
{ name: 'Manutenção', value: 35, color: '#E65100' },
|
||||||
|
{ name: 'Limpeza', value: 25, color: '#1A237E' },
|
||||||
|
{ name: 'Segurança', value: 20, color: '#FF8F00' },
|
||||||
|
{ name: 'Outros', value: 20, color: '#757575' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const recentActivities = [
|
||||||
|
{ id: 1, action: 'Nova demanda criada', description: 'Manutenção ar condicionado - Bloco A', time: 'Há 2 horas', status: 'pending' },
|
||||||
|
{ id: 2, action: 'Ordem de serviço aprovada', description: 'OS-2024-0156 - Troca de lâmpadas', time: 'Há 4 horas', status: 'success' },
|
||||||
|
{ id: 3, action: 'Fornecedor cadastrado', description: 'Tech Solutions Ltda', time: 'Há 6 horas', status: 'info' },
|
||||||
|
{ id: 4, action: 'Orçamento atualizado', description: 'Categoria: Manutenção Predial', time: 'Há 8 horas', status: 'warning' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [stats, setStats] = useState<any>(null)
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const userData = localStorage.getItem('user')
|
||||||
|
if (userData) {
|
||||||
|
setUser(JSON.parse(userData))
|
||||||
|
}
|
||||||
|
fetchDashboard()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchDashboard = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/dashboard')
|
||||||
|
setStats(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching dashboard:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statsCards: StatsCard[] = [
|
||||||
|
{
|
||||||
|
title: 'Orçamento Total',
|
||||||
|
value: stats?.total_orcamento ? `${(stats.total_orcamento / 1000).toFixed(0)}K` : '0',
|
||||||
|
change: '+12%',
|
||||||
|
changeType: 'positive',
|
||||||
|
icon: <Wallet className="w-6 h-6" />,
|
||||||
|
color: 'from-primary to-accent'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total Gasto',
|
||||||
|
value: stats?.total_gasto ? `${(stats.total_gasto / 1000).toFixed(0)}K` : '0',
|
||||||
|
change: '-5%',
|
||||||
|
changeType: 'positive',
|
||||||
|
icon: <TrendingDown className="w-6 h-6" />,
|
||||||
|
color: 'from-secondary to-secondary-light'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Economia',
|
||||||
|
value: stats?.economia ? `${(stats.economia / 1000).toFixed(0)}K` : '0',
|
||||||
|
change: '+8%',
|
||||||
|
changeType: 'positive',
|
||||||
|
icon: <TrendingUp className="w-6 h-6" />,
|
||||||
|
color: 'from-green-500 to-emerald-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pendências',
|
||||||
|
value: stats?.pendencias || stats?.demandas_pendentes || '0',
|
||||||
|
change: '-3',
|
||||||
|
changeType: 'neutral',
|
||||||
|
icon: <Clock className="w-6 h-6" />,
|
||||||
|
color: 'from-amber-500 to-orange-500'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* Welcome header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-text">
|
||||||
|
Olá, {user?.nome?.split(' ')[0] || 'Usuário'}! 👋
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray mt-1">Aqui está o resumo das suas operações de facilities.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray">
|
||||||
|
<span>Última atualização:</span>
|
||||||
|
<span className="font-medium text-text">Agora</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6">
|
||||||
|
{statsCards.map((card, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="card group hover:shadow-lg transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${card.color} flex items-center justify-center text-white shadow-lg`}>
|
||||||
|
{card.icon}
|
||||||
|
</div>
|
||||||
|
{card.change && (
|
||||||
|
<span className={`text-xs font-semibold px-2 py-1 rounded-full ${
|
||||||
|
card.changeType === 'positive' ? 'bg-green-100 text-green-700' :
|
||||||
|
card.changeType === 'negative' ? 'bg-red-100 text-red-700' :
|
||||||
|
'bg-gray-100 text-gray-700'
|
||||||
|
}`}>
|
||||||
|
{card.change}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray text-sm mb-1">{card.title}</p>
|
||||||
|
<p className="text-2xl sm:text-3xl font-bold text-text">{card.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Row */}
|
||||||
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
|
{/* Bar Chart */}
|
||||||
|
<div className="lg:col-span-2 card">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-text">Orçamento vs Realizado</h2>
|
||||||
|
<p className="text-sm text-gray">Comparativo mensal</p>
|
||||||
|
</div>
|
||||||
|
<button className="text-sm text-primary hover:underline flex items-center gap-1">
|
||||||
|
Ver detalhes
|
||||||
|
<ArrowUpRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-72">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={monthlyData} barGap={8}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
|
||||||
|
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
border: '1px solid #E0E0E0',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="previsto" fill="#1A237E" radius={[4, 4, 0, 0]} name="Previsto" />
|
||||||
|
<Bar dataKey="realizado" fill="#E65100" radius={[4, 4, 0, 0]} name="Realizado" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pie Chart */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-text">Por Categoria</h2>
|
||||||
|
<p className="text-sm text-gray">Distribuição de gastos</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-60">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={categoryData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={4}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{categoryData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Legend
|
||||||
|
verticalAlign="bottom"
|
||||||
|
iconType="circle"
|
||||||
|
iconSize={8}
|
||||||
|
formatter={(value) => <span className="text-sm text-gray">{value}</span>}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats & Activity */}
|
||||||
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-lg font-semibold text-text mb-4">Resumo Rápido</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-card rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||||
|
<FileText className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-text">Demandas Abertas</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-text">{stats?.demandas_pendentes || 12}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-card rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-text">OS Concluídas</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-text">{stats?.ordens_concluidas || 48}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-card rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center">
|
||||||
|
<Building2 className="w-5 h-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-text">Fornecedores Ativos</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-text">{stats?.fornecedores_ativos || 15}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<div className="lg:col-span-2 card">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-text">Atividade Recente</h2>
|
||||||
|
<button className="text-sm text-primary hover:underline">Ver todas</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recentActivities.map((activity) => (
|
||||||
|
<div key={activity.id} className="flex items-start gap-4 p-3 rounded-xl hover:bg-card transition-colors">
|
||||||
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||||
|
activity.status === 'success' ? 'bg-green-100' :
|
||||||
|
activity.status === 'warning' ? 'bg-amber-100' :
|
||||||
|
activity.status === 'info' ? 'bg-blue-100' :
|
||||||
|
'bg-gray-100'
|
||||||
|
}`}>
|
||||||
|
{activity.status === 'success' ? <CheckCircle2 className="w-5 h-5 text-green-600" /> :
|
||||||
|
activity.status === 'warning' ? <AlertCircle className="w-5 h-5 text-amber-600" /> :
|
||||||
|
activity.status === 'info' ? <Building2 className="w-5 h-5 text-blue-600" /> :
|
||||||
|
<Clock className="w-5 h-5 text-gray-600" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-text">{activity.action}</p>
|
||||||
|
<p className="text-sm text-gray truncate">{activity.description}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-light whitespace-nowrap">{activity.time}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
310
frontend/src/pages/Demandas.tsx
Normal file
310
frontend/src/pages/Demandas.tsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Filter,
|
||||||
|
Eye,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown
|
||||||
|
} from 'lucide-react'
|
||||||
|
import api from '../services/api'
|
||||||
|
import { Demanda } from '../types'
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { label: string; class: string; icon: React.ReactNode }> = {
|
||||||
|
'pendente': { label: 'Pendente', class: 'badge-warning', icon: <Clock className="w-3 h-3" /> },
|
||||||
|
'em_analise': { label: 'Em Análise', class: 'badge-info', icon: <AlertCircle className="w-3 h-3" /> },
|
||||||
|
'aprovada': { label: 'Aprovada', class: 'badge-success', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||||
|
'rejeitada': { label: 'Rejeitada', class: 'badge-error', icon: <X className="w-3 h-3" /> },
|
||||||
|
'concluida': { label: 'Concluída', class: 'badge-neutral', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||||
|
}
|
||||||
|
|
||||||
|
const prioridadeConfig: Record<string, { label: string; class: string }> = {
|
||||||
|
'baixa': { label: 'Baixa', class: 'text-gray bg-gray-100' },
|
||||||
|
'media': { label: 'Média', class: 'text-amber-700 bg-amber-100' },
|
||||||
|
'alta': { label: 'Alta', class: 'text-red-700 bg-red-100' },
|
||||||
|
'urgente': { label: 'Urgente', class: 'text-red-700 bg-red-200 animate-pulse' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockDemandas: Demanda[] = [
|
||||||
|
{ id: 1, titulo: 'Manutenção Ar Condicionado', descricao: 'Ar condicionado do bloco A não está funcionando', status: 'pendente', prioridade: 'alta', solicitante_id: 1, solicitante_nome: 'Maria Silva', data_criacao: '2024-01-15' },
|
||||||
|
{ id: 2, titulo: 'Troca de Lâmpadas', descricao: 'Lâmpadas queimadas no corredor do 3º andar', status: 'em_analise', prioridade: 'media', solicitante_id: 2, solicitante_nome: 'João Santos', data_criacao: '2024-01-14' },
|
||||||
|
{ id: 3, titulo: 'Vazamento Banheiro', descricao: 'Vazamento na torneira do banheiro masculino', status: 'aprovada', prioridade: 'urgente', solicitante_id: 3, solicitante_nome: 'Ana Oliveira', data_criacao: '2024-01-13' },
|
||||||
|
{ id: 4, titulo: 'Pintura Sala Reunião', descricao: 'Paredes da sala de reunião precisam de pintura', status: 'concluida', prioridade: 'baixa', solicitante_id: 1, solicitante_nome: 'Maria Silva', data_criacao: '2024-01-10' },
|
||||||
|
{ id: 5, titulo: 'Reparo Elevador', descricao: 'Elevador social com barulho estranho', status: 'pendente', prioridade: 'alta', solicitante_id: 4, solicitante_nome: 'Carlos Lima', data_criacao: '2024-01-16' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Demandas() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [demandas, setDemandas] = useState<Demanda[]>([])
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [filterStatus, setFilterStatus] = useState('todos')
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [selectedDemanda, setSelectedDemanda] = useState<Demanda | null>(null)
|
||||||
|
const [formData, setFormData] = useState({ titulo: '', descricao: '', prioridade: 'media' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDemandas()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchDemandas = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/demandas')
|
||||||
|
setDemandas(data.length > 0 ? data : mockDemandas)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching demandas:', err)
|
||||||
|
setDemandas(mockDemandas)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredDemandas = demandas.filter(demanda => {
|
||||||
|
const matchesSearch = demanda.titulo.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
demanda.descricao.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
const matchesStatus = filterStatus === 'todos' || demanda.status === filterStatus
|
||||||
|
return matchesSearch && matchesStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleOpenModal = (demanda?: Demanda) => {
|
||||||
|
if (demanda) {
|
||||||
|
setSelectedDemanda(demanda)
|
||||||
|
setFormData({ titulo: demanda.titulo, descricao: demanda.descricao, prioridade: demanda.prioridade })
|
||||||
|
} else {
|
||||||
|
setSelectedDemanda(null)
|
||||||
|
setFormData({ titulo: '', descricao: '', prioridade: 'media' })
|
||||||
|
}
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setShowModal(false)
|
||||||
|
setSelectedDemanda(null)
|
||||||
|
setFormData({ titulo: '', descricao: '', prioridade: 'media' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
// Mock save
|
||||||
|
if (selectedDemanda) {
|
||||||
|
setDemandas(demandas.map(d => d.id === selectedDemanda.id ? { ...d, ...formData } : d))
|
||||||
|
} else {
|
||||||
|
const newDemanda: Demanda = {
|
||||||
|
id: Date.now(),
|
||||||
|
...formData,
|
||||||
|
status: 'pendente',
|
||||||
|
solicitante_id: 1,
|
||||||
|
solicitante_nome: 'Usuário Atual',
|
||||||
|
data_criacao: new Date().toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
setDemandas([newDemanda, ...demandas])
|
||||||
|
}
|
||||||
|
handleCloseModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString('pt-BR')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-text">Demandas</h1>
|
||||||
|
<p className="text-gray mt-1">Gerencie as solicitações de facilities</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenModal()}
|
||||||
|
className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
Nova Demanda
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
{Object.entries(statusConfig).slice(0, 4).map(([key, config]) => {
|
||||||
|
const count = demandas.filter(d => d.status === key).length
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setFilterStatus(filterStatus === key ? 'todos' : key)}
|
||||||
|
className={`card text-left transition-all ${filterStatus === key ? 'ring-2 ring-primary' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{config.icon}
|
||||||
|
<span className="text-sm text-gray">{config.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-text">{count}</p>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar demandas..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="input-field pl-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
|
className="input-field appearance-none pr-10 w-full sm:w-48"
|
||||||
|
>
|
||||||
|
<option value="todos">Todos os status</option>
|
||||||
|
{Object.entries(statusConfig).map(([key, config]) => (
|
||||||
|
<option key={key} value={key}>{config.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Demandas List */}
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{filteredDemandas.map((demanda) => (
|
||||||
|
<div key={demanda.id} className="card hover:shadow-md transition-all">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||||
|
<FileText className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="font-semibold text-text">{demanda.titulo}</h3>
|
||||||
|
<span className={`${statusConfig[demanda.status]?.class || 'badge-neutral'} flex items-center gap-1`}>
|
||||||
|
{statusConfig[demanda.status]?.icon}
|
||||||
|
{statusConfig[demanda.status]?.label || demanda.status}
|
||||||
|
</span>
|
||||||
|
<span className={`badge ${prioridadeConfig[demanda.prioridade]?.class || ''}`}>
|
||||||
|
{prioridadeConfig[demanda.prioridade]?.label || demanda.prioridade}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray text-sm mt-1 line-clamp-1">{demanda.descricao}</p>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs text-gray-light">
|
||||||
|
<span>Solicitante: {demanda.solicitante_nome}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatDate(demanda.data_criacao)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 sm:flex-shrink-0">
|
||||||
|
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors">
|
||||||
|
<Eye className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenModal(demanda)}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 rounded-lg hover:bg-red-50 text-gray hover:text-red-500 transition-colors">
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filteredDemandas.length === 0 && (
|
||||||
|
<div className="card text-center py-12">
|
||||||
|
<FileText className="w-12 h-12 text-gray-light mx-auto mb-4" />
|
||||||
|
<p className="text-gray">Nenhuma demanda encontrada</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||||
|
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
|
<h2 className="text-xl font-semibold text-text">
|
||||||
|
{selectedDemanda ? 'Editar Demanda' : 'Nova Demanda'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Título</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.titulo}
|
||||||
|
onChange={(e) => setFormData({ ...formData, titulo: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="Título da demanda"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Descrição</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.descricao}
|
||||||
|
onChange={(e) => setFormData({ ...formData, descricao: e.target.value })}
|
||||||
|
className="input-field resize-none"
|
||||||
|
rows={4}
|
||||||
|
placeholder="Descreva a demanda em detalhes..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Prioridade</label>
|
||||||
|
<select
|
||||||
|
value={formData.prioridade}
|
||||||
|
onChange={(e) => setFormData({ ...formData, prioridade: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
>
|
||||||
|
<option value="baixa">Baixa</option>
|
||||||
|
<option value="media">Média</option>
|
||||||
|
<option value="alta">Alta</option>
|
||||||
|
<option value="urgente">Urgente</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="button" onClick={handleCloseModal} className="btn-ghost flex-1">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn-primary flex-1">
|
||||||
|
{selectedDemanda ? 'Salvar' : 'Criar Demanda'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
306
frontend/src/pages/Fornecedores.tsx
Normal file
306
frontend/src/pages/Fornecedores.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Eye,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
Star,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
X
|
||||||
|
} from 'lucide-react'
|
||||||
|
import api from '../services/api'
|
||||||
|
import { Fornecedor } from '../types'
|
||||||
|
|
||||||
|
const mockFornecedores: Fornecedor[] = [
|
||||||
|
{ id: 1, razao_social: 'Tech Solutions Ltda', cnpj: '12.345.678/0001-90', email: 'contato@techsolutions.com', telefone: '(11) 99999-1234', endereco: 'Av. Paulista, 1000 - São Paulo/SP', ativo: true, especialidades: ['Ar Condicionado', 'Elétrica'], avaliacao: 4.5 },
|
||||||
|
{ id: 2, razao_social: 'EletroFix Serviços', cnpj: '23.456.789/0001-01', email: 'eletrofix@email.com', telefone: '(11) 98888-5678', endereco: 'Rua Augusta, 500 - São Paulo/SP', ativo: true, especialidades: ['Elétrica', 'Iluminação'], avaliacao: 4.8 },
|
||||||
|
{ id: 3, razao_social: 'HidroServ Manutenção', cnpj: '34.567.890/0001-12', email: 'hidroserv@email.com', telefone: '(11) 97777-9012', endereco: 'Rua Oscar Freire, 200 - São Paulo/SP', ativo: true, especialidades: ['Hidráulica', 'Encanamento'], avaliacao: 4.2 },
|
||||||
|
{ id: 4, razao_social: 'ElevaTech Elevadores', cnpj: '45.678.901/0001-23', email: 'elevatech@email.com', telefone: '(11) 96666-3456', endereco: 'Av. Brasil, 1500 - São Paulo/SP', ativo: false, especialidades: ['Elevadores'], avaliacao: 3.9 },
|
||||||
|
{ id: 5, razao_social: 'CleanPro Limpeza', cnpj: '56.789.012/0001-34', email: 'cleanpro@email.com', telefone: '(11) 95555-7890', endereco: 'Rua Consolação, 800 - São Paulo/SP', ativo: true, especialidades: ['Limpeza', 'Conservação'], avaliacao: 4.6 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Fornecedores() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [fornecedores, setFornecedores] = useState<Fornecedor[]>([])
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [filterAtivo, setFilterAtivo] = useState('todos')
|
||||||
|
const [selectedFornecedor, setSelectedFornecedor] = useState<Fornecedor | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFornecedores()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchFornecedores = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/fornecedores')
|
||||||
|
setFornecedores(data.length > 0 ? data : mockFornecedores)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching fornecedores:', err)
|
||||||
|
setFornecedores(mockFornecedores)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredFornecedores = fornecedores.filter(forn => {
|
||||||
|
const matchesSearch = forn.razao_social.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
forn.cnpj.includes(searchTerm)
|
||||||
|
const matchesAtivo = filterAtivo === 'todos' ||
|
||||||
|
(filterAtivo === 'ativos' && forn.ativo) ||
|
||||||
|
(filterAtivo === 'inativos' && !forn.ativo)
|
||||||
|
return matchesSearch && matchesAtivo
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderStars = (rating: number = 0) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-300'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span className="text-sm text-gray ml-1">{rating?.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-text">Fornecedores</h1>
|
||||||
|
<p className="text-gray mt-1">Gerencie os fornecedores parceiros</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
Novo Fornecedor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-gray text-sm">Total</p>
|
||||||
|
<p className="text-2xl font-bold text-text">{fornecedores.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-gray text-sm">Ativos</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">{fornecedores.filter(f => f.ativo).length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-gray text-sm">Inativos</p>
|
||||||
|
<p className="text-2xl font-bold text-gray">{fornecedores.filter(f => !f.ativo).length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nome ou CNPJ..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="input-field pl-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{['todos', 'ativos', 'inativos'].map((filter) => (
|
||||||
|
<button
|
||||||
|
key={filter}
|
||||||
|
onClick={() => setFilterAtivo(filter)}
|
||||||
|
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||||
|
filterAtivo === filter
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-gray-100 text-gray hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fornecedores Grid */}
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredFornecedores.map((fornecedor) => (
|
||||||
|
<div
|
||||||
|
key={fornecedor.id}
|
||||||
|
className="card hover:shadow-lg transition-all cursor-pointer"
|
||||||
|
onClick={() => setSelectedFornecedor(fornecedor)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-secondary/10 to-secondary/20 flex items-center justify-center">
|
||||||
|
<Building2 className="w-6 h-6 text-secondary" />
|
||||||
|
</div>
|
||||||
|
<span className={`badge ${fornecedor.ativo ? 'badge-success' : 'badge-neutral'}`}>
|
||||||
|
{fornecedor.ativo ? 'Ativo' : 'Inativo'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-semibold text-text mb-1">{fornecedor.razao_social}</h3>
|
||||||
|
<p className="text-sm text-gray mb-3">{fornecedor.cnpj}</p>
|
||||||
|
|
||||||
|
{renderStars(fornecedor.avaliacao)}
|
||||||
|
|
||||||
|
{fornecedor.especialidades && fornecedor.especialidades.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-3">
|
||||||
|
{fornecedor.especialidades.slice(0, 2).map((esp, i) => (
|
||||||
|
<span key={i} className="text-xs px-2 py-1 bg-primary/10 text-primary rounded-lg">
|
||||||
|
{esp}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{fornecedor.especialidades.length > 2 && (
|
||||||
|
<span className="text-xs px-2 py-1 bg-gray-100 text-gray rounded-lg">
|
||||||
|
+{fornecedor.especialidades.length - 2}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setSelectedFornecedor(fornecedor); }}
|
||||||
|
className="flex-1 btn-ghost !py-2 text-sm"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 mr-1 inline" />
|
||||||
|
Ver
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex-1 btn-ghost !py-2 text-sm"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4 mr-1 inline" />
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filteredFornecedores.length === 0 && (
|
||||||
|
<div className="col-span-full card text-center py-12">
|
||||||
|
<Building2 className="w-12 h-12 text-gray-light mx-auto mb-4" />
|
||||||
|
<p className="text-gray">Nenhum fornecedor encontrado</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail Modal */}
|
||||||
|
{selectedFornecedor && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||||
|
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl animate-fade-in max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border sticky top-0 bg-white">
|
||||||
|
<h2 className="text-xl font-semibold text-text">Detalhes do Fornecedor</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedFornecedor(null)}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-secondary to-secondary-light flex items-center justify-center">
|
||||||
|
<Building2 className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-text">{selectedFornecedor.razao_social}</h3>
|
||||||
|
<p className="text-gray">{selectedFornecedor.cnpj}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{selectedFornecedor.ativo ? (
|
||||||
|
<span className="badge-success flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
Ativo
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge-neutral flex items-center gap-1">
|
||||||
|
<XCircle className="w-3 h-3" />
|
||||||
|
Inativo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-card rounded-xl">
|
||||||
|
<Mail className="w-5 h-5 text-gray" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray">E-mail</p>
|
||||||
|
<p className="text-text">{selectedFornecedor.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-card rounded-xl">
|
||||||
|
<Phone className="w-5 h-5 text-gray" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray">Telefone</p>
|
||||||
|
<p className="text-text">{selectedFornecedor.telefone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedFornecedor.endereco && (
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-card rounded-xl">
|
||||||
|
<MapPin className="w-5 h-5 text-gray" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray">Endereço</p>
|
||||||
|
<p className="text-text">{selectedFornecedor.endereco}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray mb-2">Avaliação</p>
|
||||||
|
{renderStars(selectedFornecedor.avaliacao)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedFornecedor.especialidades && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray mb-2">Especialidades</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedFornecedor.especialidades.map((esp, i) => (
|
||||||
|
<span key={i} className="px-3 py-1.5 bg-primary/10 text-primary rounded-lg text-sm font-medium">
|
||||||
|
{esp}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedFornecedor(null)}
|
||||||
|
className="btn-ghost flex-1"
|
||||||
|
>
|
||||||
|
Fechar
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary flex-1">
|
||||||
|
Editar Fornecedor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
253
frontend/src/pages/Landing.tsx
Normal file
253
frontend/src/pages/Landing.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Flame,
|
||||||
|
Shield,
|
||||||
|
BarChart3,
|
||||||
|
Zap,
|
||||||
|
CheckCircle2,
|
||||||
|
ArrowRight,
|
||||||
|
Building2,
|
||||||
|
FileText,
|
||||||
|
Wallet,
|
||||||
|
Users
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: <Wallet className="w-6 h-6" />,
|
||||||
|
title: 'Controle Orçamentário',
|
||||||
|
description: 'Gerencie seus orçamentos de facilities com precisão e visibilidade total.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FileText className="w-6 h-6" />,
|
||||||
|
title: 'Gestão de Demandas',
|
||||||
|
description: 'Acompanhe todas as solicitações desde a criação até a conclusão.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Building2 className="w-6 h-6" />,
|
||||||
|
title: 'Fornecedores',
|
||||||
|
description: 'Cadastro completo e avaliação de fornecedores parceiros.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BarChart3 className="w-6 h-6" />,
|
||||||
|
title: 'Relatórios Inteligentes',
|
||||||
|
description: 'Dashboards e relatórios para decisões estratégicas.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ value: '98%', label: 'Satisfação' },
|
||||||
|
{ value: '50+', label: 'Empresas' },
|
||||||
|
{ value: '10k+', label: 'Demandas' },
|
||||||
|
{ value: '24/7', label: 'Suporte' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const benefits = [
|
||||||
|
'Redução de custos operacionais',
|
||||||
|
'Maior controle e transparência',
|
||||||
|
'Processos automatizados',
|
||||||
|
'Relatórios em tempo real',
|
||||||
|
'Integração com fornecedores',
|
||||||
|
'Aprovações simplificadas'
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Landing() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="fixed top-0 left-0 right-0 bg-white/80 backdrop-blur-lg border-b border-border z-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center shadow-lg shadow-primary/20">
|
||||||
|
<Flame className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-xl text-text">HEFESTO</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="hidden sm:inline-flex text-gray hover:text-primary font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Entrar
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="btn-primary !py-2 !px-5 text-sm"
|
||||||
|
>
|
||||||
|
Começar Agora
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="pt-32 pb-20 px-4 sm:px-6 lg:px-8 relative overflow-hidden">
|
||||||
|
{/* Background decorations */}
|
||||||
|
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-gradient-to-br from-primary/10 to-accent/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/3"></div>
|
||||||
|
<div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-gradient-to-tr from-secondary/10 to-secondary/5 rounded-full blur-3xl translate-y-1/2 -translate-x-1/3"></div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto relative">
|
||||||
|
<div className="text-center max-w-4xl mx-auto">
|
||||||
|
{/* Badge */}
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium mb-8">
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
Sistema de Controle Orçamentário para Facilities
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Headline */}
|
||||||
|
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-text mb-6 leading-tight">
|
||||||
|
Forje o{' '}
|
||||||
|
<span className="bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
||||||
|
controle
|
||||||
|
</span>
|
||||||
|
{' '}dos seus custos
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Subheadline */}
|
||||||
|
<p className="text-lg sm:text-xl text-gray max-w-2xl mx-auto mb-10">
|
||||||
|
Gerencie orçamentos, demandas e fornecedores em uma única plataforma poderosa.
|
||||||
|
Transforme a gestão de facilities da sua empresa.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* CTAs */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-16">
|
||||||
|
<Link to="/login" className="btn-primary text-lg !py-4 !px-8 flex items-center gap-2 w-full sm:w-auto justify-center">
|
||||||
|
Acessar Sistema
|
||||||
|
<ArrowRight className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<a href="#features" className="btn-outline text-lg !py-4 !px-8 w-full sm:w-auto">
|
||||||
|
Conhecer Recursos
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-6 sm:gap-8">
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<div key={index} className="text-center">
|
||||||
|
<div className="text-3xl sm:text-4xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
||||||
|
{stat.value}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray text-sm mt-1">{stat.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
<section id="features" className="py-20 px-4 sm:px-6 lg:px-8 bg-card">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-bold text-text mb-4">
|
||||||
|
Tudo que você precisa para{' '}
|
||||||
|
<span className="text-primary">gestão de facilities</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray text-lg max-w-2xl mx-auto">
|
||||||
|
Recursos completos para otimizar seus processos e reduzir custos operacionais.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="card-hover group"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary/10 to-accent/10 flex items-center justify-center text-primary mb-4 group-hover:from-primary group-hover:to-accent group-hover:text-white transition-all duration-300">
|
||||||
|
{feature.icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-text mb-2">{feature.title}</h3>
|
||||||
|
<p className="text-gray text-sm">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Benefits Section */}
|
||||||
|
<section className="py-20 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-bold text-text mb-6">
|
||||||
|
Por que escolher o{' '}
|
||||||
|
<span className="text-primary">HEFESTO</span>?
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray text-lg mb-8">
|
||||||
|
Nossa plataforma foi desenvolvida especificamente para atender às necessidades
|
||||||
|
complexas da gestão de facilities, oferecendo controle total sobre orçamentos e processos.
|
||||||
|
</p>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
{benefits.map((benefit, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-3">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-primary flex-shrink-0" />
|
||||||
|
<span className="text-text">{benefit}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="bg-gradient-to-br from-primary to-accent rounded-3xl p-8 text-white">
|
||||||
|
<Shield className="w-12 h-12 mb-6 opacity-80" />
|
||||||
|
<h3 className="text-2xl font-bold mb-4">Segurança Garantida</h3>
|
||||||
|
<p className="text-white/80 mb-6">
|
||||||
|
Seus dados estão protegidos com as mais avançadas tecnologias de segurança.
|
||||||
|
Conformidade total com LGPD.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="w-8 h-8 rounded-full bg-white/20 border-2 border-white flex items-center justify-center">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-white/80 text-sm">+50 empresas confiam</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-6 -right-6 w-32 h-32 bg-accent/20 rounded-full blur-2xl"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA Section */}
|
||||||
|
<section className="py-20 px-4 sm:px-6 lg:px-8 bg-secondary">
|
||||||
|
<div className="max-w-4xl mx-auto text-center">
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-6">
|
||||||
|
Pronto para transformar sua gestão de facilities?
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/70 text-lg mb-8 max-w-2xl mx-auto">
|
||||||
|
Comece agora mesmo e descubra como o HEFESTO pode ajudar sua empresa
|
||||||
|
a ter mais controle e eficiência.
|
||||||
|
</p>
|
||||||
|
<Link to="/login" className="btn-primary text-lg !py-4 !px-8 inline-flex items-center gap-2">
|
||||||
|
Começar Gratuitamente
|
||||||
|
<ArrowRight className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="py-12 px-4 sm:px-6 lg:px-8 bg-card border-t border-border">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-accent flex items-center justify-center">
|
||||||
|
<Flame className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-text">HEFESTO</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray text-sm">
|
||||||
|
© 2026 HEFESTO. Todos os direitos reservados.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
172
frontend/src/pages/Login.tsx
Normal file
172
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { Lock, Mail, Flame, ArrowLeft, Eye, EyeOff, Loader2 } from 'lucide-react'
|
||||||
|
import api from '../services/api'
|
||||||
|
|
||||||
|
interface DemoUser {
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const demoUsers: DemoUser[] = [
|
||||||
|
{ email: 'admin@hefesto.com', role: 'Admin', color: '#E65100' },
|
||||||
|
{ email: 'joao.santos@hefesto.com', role: 'Gestor', color: '#1A237E' },
|
||||||
|
{ email: 'ana.oliveira@hefesto.com', role: 'Financeiro', color: '#2E7D32' },
|
||||||
|
{ email: 'maria.silva@hefesto.com', role: 'Solicitante', color: '#7B1FA2' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [senha, setSenha] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/auth/login', { email, senha })
|
||||||
|
localStorage.setItem('token', data.access_token)
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user))
|
||||||
|
navigate('/app')
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'Credenciais inválidas. Verifique e tente novamente.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillDemoUser = (userEmail: string) => {
|
||||||
|
setEmail(userEmail)
|
||||||
|
setSenha('123456')
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-white flex items-center justify-center p-4 relative overflow-hidden">
|
||||||
|
{/* Background decorations */}
|
||||||
|
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-gradient-to-br from-primary/10 to-accent/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/3 pointer-events-none"></div>
|
||||||
|
<div className="absolute bottom-0 left-0 w-[400px] h-[400px] bg-gradient-to-tr from-secondary/10 to-secondary/5 rounded-full blur-3xl translate-y-1/2 -translate-x-1/3 pointer-events-none"></div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-md relative animate-fade-in">
|
||||||
|
{/* Back to home */}
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-flex items-center gap-2 text-gray hover:text-primary transition-colors mb-8 group"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
||||||
|
Voltar ao início
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Logo and header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-gradient-to-br from-primary to-accent mb-6 shadow-xl shadow-primary/30 animate-pulse-glow">
|
||||||
|
<Flame className="w-10 h-10 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-text mb-2">HEFESTO</h1>
|
||||||
|
<p className="text-gray">Sistema de Controle Orçamentário</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login card */}
|
||||||
|
<div className="bg-white rounded-2xl p-8 shadow-xl border border-gray-100">
|
||||||
|
<h2 className="text-xl font-semibold text-text mb-6 text-center">Acesse sua conta</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-xl mb-6 text-sm flex items-start gap-3">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<span className="text-red-500 text-xs font-bold">!</span>
|
||||||
|
</div>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleLogin} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">E-mail</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
className="input-field pl-12"
|
||||||
|
placeholder="seu@email.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Senha</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={senha}
|
||||||
|
onChange={e => setSenha(e.target.value)}
|
||||||
|
className="input-field pl-12 pr-12"
|
||||||
|
placeholder="••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-light hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !email || !senha}
|
||||||
|
className="w-full btn-primary py-4 text-lg flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Entrar'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Demo users */}
|
||||||
|
<div className="mt-8 pt-6 border-t border-gray-100">
|
||||||
|
<p className="text-xs text-gray-light text-center mb-4">
|
||||||
|
Usuários para demonstração — senha:{' '}
|
||||||
|
<span className="text-primary font-semibold">123456</span>
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{demoUsers.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.email}
|
||||||
|
type="button"
|
||||||
|
onClick={() => fillDemoUser(user.email)}
|
||||||
|
className={`text-left px-3 py-2.5 rounded-xl bg-card hover:bg-gray-100 transition-all border-2 ${
|
||||||
|
email === user.email ? 'border-primary' : 'border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-xs text-text truncate font-medium">{user.email}</p>
|
||||||
|
<p className="text-[10px] font-semibold mt-0.5" style={{ color: user.color }}>
|
||||||
|
{user.role}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<p className="text-center text-gray-light text-sm mt-8">
|
||||||
|
© 2026 HEFESTO. Todos os direitos reservados.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
254
frontend/src/pages/Orcamentos.tsx
Normal file
254
frontend/src/pages/Orcamentos.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Wallet,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Plus,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
Calendar
|
||||||
|
} from 'lucide-react'
|
||||||
|
import api from '../services/api'
|
||||||
|
import { Orcamento } from '../types'
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { label: string; class: string }> = {
|
||||||
|
'dentro_limite': { label: 'Dentro do Limite', class: 'badge-success' },
|
||||||
|
'alerta': { label: 'Alerta', class: 'badge-warning' },
|
||||||
|
'excedido': { label: 'Excedido', class: 'badge-error' },
|
||||||
|
'disponivel': { label: 'Disponível', class: 'badge-info' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockOrcamentos: Orcamento[] = [
|
||||||
|
{ id: 1, ano: 2024, mes: 1, categoria: 'Manutenção Predial', valor_previsto: 50000, valor_realizado: 45000, status: 'dentro_limite' },
|
||||||
|
{ id: 2, ano: 2024, mes: 1, categoria: 'Limpeza', valor_previsto: 30000, valor_realizado: 28500, status: 'dentro_limite' },
|
||||||
|
{ id: 3, ano: 2024, mes: 1, categoria: 'Segurança', valor_previsto: 25000, valor_realizado: 26500, status: 'alerta' },
|
||||||
|
{ id: 4, ano: 2024, mes: 1, categoria: 'Jardinagem', valor_previsto: 10000, valor_realizado: 12500, status: 'excedido' },
|
||||||
|
{ id: 5, ano: 2024, mes: 2, categoria: 'Manutenção Predial', valor_previsto: 55000, valor_realizado: 42000, status: 'disponivel' },
|
||||||
|
{ id: 6, ano: 2024, mes: 2, categoria: 'Utilities', valor_previsto: 35000, valor_realizado: 33000, status: 'dentro_limite' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Orcamentos() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [orcamentos, setOrcamentos] = useState<Orcamento[]>([])
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [selectedYear, setSelectedYear] = useState(2024)
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState(0) // 0 = todos
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchOrcamentos()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchOrcamentos = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/orcamento')
|
||||||
|
setOrcamentos(data.length > 0 ? data : mockOrcamentos)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching orcamentos:', err)
|
||||||
|
setOrcamentos(mockOrcamentos)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredOrcamentos = orcamentos.filter(orc => {
|
||||||
|
const matchesSearch = orc.categoria.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
const matchesYear = orc.ano === selectedYear
|
||||||
|
const matchesMonth = selectedMonth === 0 || orc.mes === selectedMonth
|
||||||
|
return matchesSearch && matchesYear && matchesMonth
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPrevisto = filteredOrcamentos.reduce((acc, orc) => acc + orc.valor_previsto, 0)
|
||||||
|
const totalRealizado = filteredOrcamentos.reduce((acc, orc) => acc + orc.valor_realizado, 0)
|
||||||
|
const economia = totalPrevisto - totalRealizado
|
||||||
|
|
||||||
|
const months = [
|
||||||
|
'Todos', 'Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho',
|
||||||
|
'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'
|
||||||
|
]
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPercentage = (realizado: number, previsto: number) => {
|
||||||
|
return ((realizado / previsto) * 100).toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-text">Orçamentos</h1>
|
||||||
|
<p className="text-gray mt-1">Gerencie e acompanhe os orçamentos de facilities</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
Novo Orçamento
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div className="card bg-gradient-to-br from-primary to-accent text-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/80 text-sm">Total Previsto</p>
|
||||||
|
<p className="text-2xl font-bold mt-1">{formatCurrency(totalPrevisto)}</p>
|
||||||
|
</div>
|
||||||
|
<Wallet className="w-10 h-10 opacity-80" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card bg-gradient-to-br from-secondary to-secondary-light text-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/80 text-sm">Total Realizado</p>
|
||||||
|
<p className="text-2xl font-bold mt-1">{formatCurrency(totalRealizado)}</p>
|
||||||
|
</div>
|
||||||
|
<TrendingDown className="w-10 h-10 opacity-80" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`card ${economia >= 0 ? 'bg-gradient-to-br from-green-500 to-emerald-500' : 'bg-gradient-to-br from-red-500 to-rose-500'} text-white`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/80 text-sm">{economia >= 0 ? 'Economia' : 'Excedente'}</p>
|
||||||
|
<p className="text-2xl font-bold mt-1">{formatCurrency(Math.abs(economia))}</p>
|
||||||
|
</div>
|
||||||
|
<TrendingUp className="w-10 h-10 opacity-80" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por categoria..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="input-field pl-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<select
|
||||||
|
value={selectedYear}
|
||||||
|
onChange={(e) => setSelectedYear(Number(e.target.value))}
|
||||||
|
className="input-field w-32"
|
||||||
|
>
|
||||||
|
<option value={2024}>2024</option>
|
||||||
|
<option value={2023}>2023</option>
|
||||||
|
<option value={2022}>2022</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={selectedMonth}
|
||||||
|
onChange={(e) => setSelectedMonth(Number(e.target.value))}
|
||||||
|
className="input-field w-40"
|
||||||
|
>
|
||||||
|
{months.map((month, index) => (
|
||||||
|
<option key={index} value={index}>{month}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="card !p-0 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="table-header">
|
||||||
|
<tr>
|
||||||
|
<th className="table-cell">Categoria</th>
|
||||||
|
<th className="table-cell">Período</th>
|
||||||
|
<th className="table-cell text-right">Previsto</th>
|
||||||
|
<th className="table-cell text-right">Realizado</th>
|
||||||
|
<th className="table-cell text-center">% Utilizado</th>
|
||||||
|
<th className="table-cell text-center">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredOrcamentos.map((orcamento) => {
|
||||||
|
const percentage = Number(getPercentage(orcamento.valor_realizado, orcamento.valor_previsto))
|
||||||
|
return (
|
||||||
|
<tr key={orcamento.id} className="table-row">
|
||||||
|
<td className="table-cell">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<Wallet className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">{orcamento.categoria}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-gray" />
|
||||||
|
<span>{months[orcamento.mes]}/{orcamento.ano}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="table-cell text-right font-medium">
|
||||||
|
{formatCurrency(orcamento.valor_previsto)}
|
||||||
|
</td>
|
||||||
|
<td className="table-cell text-right font-medium">
|
||||||
|
{formatCurrency(orcamento.valor_realizado)}
|
||||||
|
</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="w-24 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
percentage > 100 ? 'bg-red-500' :
|
||||||
|
percentage > 85 ? 'bg-amber-500' :
|
||||||
|
'bg-green-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium w-14 text-right">{percentage}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="table-cell text-center">
|
||||||
|
<span className={statusConfig[orcamento.status]?.class || 'badge-neutral'}>
|
||||||
|
{statusConfig[orcamento.status]?.label || orcamento.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
||||||
|
<span className="text-sm text-gray">
|
||||||
|
Mostrando {filteredOrcamentos.length} de {orcamentos.length} registros
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray disabled:opacity-50" disabled>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button className="px-3 py-1 rounded-lg bg-primary text-white text-sm">1</button>
|
||||||
|
<button className="px-3 py-1 rounded-lg hover:bg-gray-100 text-gray text-sm">2</button>
|
||||||
|
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray">
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
216
frontend/src/pages/OrdensServico.tsx
Normal file
216
frontend/src/pages/OrdensServico.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
ClipboardList,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Eye,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
PlayCircle,
|
||||||
|
ChevronDown,
|
||||||
|
Building2,
|
||||||
|
Calendar
|
||||||
|
} from 'lucide-react'
|
||||||
|
import api from '../services/api'
|
||||||
|
import { OrdemServico } from '../types'
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { label: string; class: string; icon: React.ReactNode }> = {
|
||||||
|
'pendente': { label: 'Pendente', class: 'badge-warning', icon: <Clock className="w-3 h-3" /> },
|
||||||
|
'em_andamento': { label: 'Em Andamento', class: 'badge-info', icon: <PlayCircle className="w-3 h-3" /> },
|
||||||
|
'aguardando_aprovacao': { label: 'Aguard. Aprovação', class: 'badge-warning', icon: <AlertCircle className="w-3 h-3" /> },
|
||||||
|
'concluida': { label: 'Concluída', class: 'badge-success', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||||
|
'cancelada': { label: 'Cancelada', class: 'badge-error', icon: <AlertCircle className="w-3 h-3" /> },
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockOrdens: OrdemServico[] = [
|
||||||
|
{ id: 1, numero: 'OS-2024-0001', demanda_id: 1, fornecedor_id: 1, fornecedor_nome: 'Tech Solutions', status: 'em_andamento', data_criacao: '2024-01-16', descricao: 'Manutenção preventiva ar condicionado' },
|
||||||
|
{ id: 2, numero: 'OS-2024-0002', demanda_id: 2, fornecedor_id: 2, fornecedor_nome: 'EletroFix', status: 'pendente', data_criacao: '2024-01-15', descricao: 'Troca de lâmpadas LED' },
|
||||||
|
{ id: 3, numero: 'OS-2024-0003', demanda_id: 3, fornecedor_id: 3, fornecedor_nome: 'HidroServ', status: 'concluida', data_criacao: '2024-01-14', descricao: 'Reparo vazamento' },
|
||||||
|
{ id: 4, numero: 'OS-2024-0004', demanda_id: 4, fornecedor_id: 1, fornecedor_nome: 'Tech Solutions', status: 'aguardando_aprovacao', data_criacao: '2024-01-13', descricao: 'Pintura geral sala de reunião' },
|
||||||
|
{ id: 5, numero: 'OS-2024-0005', demanda_id: 5, fornecedor_id: 4, fornecedor_nome: 'ElevaTech', status: 'em_andamento', data_criacao: '2024-01-12', descricao: 'Manutenção elevador social' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function OrdensServico() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [ordens, setOrdens] = useState<OrdemServico[]>([])
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [filterStatus, setFilterStatus] = useState('todos')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchOrdens()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchOrdens = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/ordens-servico')
|
||||||
|
setOrdens(data.length > 0 ? data : mockOrdens)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching ordens:', err)
|
||||||
|
setOrdens(mockOrdens)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredOrdens = ordens.filter(ordem => {
|
||||||
|
const matchesSearch = ordem.numero.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
ordem.descricao?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
ordem.fornecedor_nome?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
const matchesStatus = filterStatus === 'todos' || ordem.status === filterStatus
|
||||||
|
return matchesSearch && matchesStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString('pt-BR')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-text">Ordens de Serviço</h1>
|
||||||
|
<p className="text-gray mt-1">Acompanhe todas as ordens de serviço</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
Nova OS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
||||||
|
{Object.entries(statusConfig).map(([key, config]) => {
|
||||||
|
const count = ordens.filter(o => o.status === key).length
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setFilterStatus(filterStatus === key ? 'todos' : key)}
|
||||||
|
className={`card text-left transition-all ${filterStatus === key ? 'ring-2 ring-primary' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{config.icon}
|
||||||
|
<span className="text-xs text-gray truncate">{config.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold text-text">{count}</p>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por número, descrição ou fornecedor..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="input-field pl-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
|
className="input-field appearance-none pr-10 w-full sm:w-48"
|
||||||
|
>
|
||||||
|
<option value="todos">Todos os status</option>
|
||||||
|
{Object.entries(statusConfig).map(([key, config]) => (
|
||||||
|
<option key={key} value={key}>{config.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="card !p-0 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="table-header">
|
||||||
|
<tr>
|
||||||
|
<th className="table-cell">Número</th>
|
||||||
|
<th className="table-cell">Descrição</th>
|
||||||
|
<th className="table-cell">Fornecedor</th>
|
||||||
|
<th className="table-cell">Data</th>
|
||||||
|
<th className="table-cell text-center">Status</th>
|
||||||
|
<th className="table-cell text-center">Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredOrdens.map((ordem) => (
|
||||||
|
<tr key={ordem.id} className="table-row">
|
||||||
|
<td className="table-cell">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-secondary/10 flex items-center justify-center">
|
||||||
|
<ClipboardList className="w-5 h-5 text-secondary" />
|
||||||
|
</div>
|
||||||
|
<span className="font-mono font-semibold text-primary">{ordem.numero}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
<span className="line-clamp-1">{ordem.descricao}</span>
|
||||||
|
</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-4 h-4 text-gray" />
|
||||||
|
<span>{ordem.fornecedor_nome}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-gray" />
|
||||||
|
<span>{formatDate(ordem.data_criacao)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="table-cell text-center">
|
||||||
|
<span className={`${statusConfig[ordem.status]?.class || 'badge-neutral'} inline-flex items-center gap-1`}>
|
||||||
|
{statusConfig[ordem.status]?.icon}
|
||||||
|
{statusConfig[ordem.status]?.label || ordem.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors">
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors">
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 rounded-lg hover:bg-red-50 text-gray hover:text-red-500 transition-colors">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredOrdens.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<ClipboardList className="w-12 h-12 text-gray-light mx-auto mb-4" />
|
||||||
|
<p className="text-gray">Nenhuma ordem de serviço encontrada</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
279
frontend/src/pages/Relatorios.tsx
Normal file
279
frontend/src/pages/Relatorios.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Download,
|
||||||
|
Calendar,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
PieChart,
|
||||||
|
FileText,
|
||||||
|
Filter
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
|
LineChart, Line, PieChart as RechartsPie, Pie, Cell, Legend,
|
||||||
|
AreaChart, Area
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
const monthlyData = [
|
||||||
|
{ name: 'Jan', orcamento: 120, gasto: 95, economia: 25 },
|
||||||
|
{ name: 'Fev', orcamento: 115, gasto: 110, economia: 5 },
|
||||||
|
{ name: 'Mar', orcamento: 130, gasto: 105, economia: 25 },
|
||||||
|
{ name: 'Abr', orcamento: 125, gasto: 120, economia: 5 },
|
||||||
|
{ name: 'Mai', orcamento: 140, gasto: 115, economia: 25 },
|
||||||
|
{ name: 'Jun', orcamento: 135, gasto: 125, economia: 10 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const categoryData = [
|
||||||
|
{ name: 'Manutenção', value: 35, color: '#E65100' },
|
||||||
|
{ name: 'Limpeza', value: 25, color: '#1A237E' },
|
||||||
|
{ name: 'Segurança', value: 20, color: '#FF8F00' },
|
||||||
|
{ name: 'Utilities', value: 12, color: '#2E7D32' },
|
||||||
|
{ name: 'Outros', value: 8, color: '#757575' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const trendData = [
|
||||||
|
{ name: 'Sem 1', demandas: 15, os: 12 },
|
||||||
|
{ name: 'Sem 2', demandas: 22, os: 18 },
|
||||||
|
{ name: 'Sem 3', demandas: 18, os: 16 },
|
||||||
|
{ name: 'Sem 4', demandas: 25, os: 22 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const fornecedorData = [
|
||||||
|
{ name: 'Tech Solutions', atendimentos: 45, satisfacao: 4.5 },
|
||||||
|
{ name: 'EletroFix', atendimentos: 38, satisfacao: 4.8 },
|
||||||
|
{ name: 'HidroServ', atendimentos: 32, satisfacao: 4.2 },
|
||||||
|
{ name: 'CleanPro', atendimentos: 28, satisfacao: 4.6 },
|
||||||
|
{ name: 'ElevaTech', atendimentos: 15, satisfacao: 3.9 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Relatorios() {
|
||||||
|
const [period, setPeriod] = useState('mensal')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-text">Relatórios</h1>
|
||||||
|
<p className="text-gray mt-1">Análises e métricas de facilities</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<select
|
||||||
|
value={period}
|
||||||
|
onChange={(e) => setPeriod(e.target.value)}
|
||||||
|
className="input-field w-40"
|
||||||
|
>
|
||||||
|
<option value="semanal">Semanal</option>
|
||||||
|
<option value="mensal">Mensal</option>
|
||||||
|
<option value="trimestral">Trimestral</option>
|
||||||
|
<option value="anual">Anual</option>
|
||||||
|
</select>
|
||||||
|
<button className="btn-primary flex items-center gap-2">
|
||||||
|
<Download className="w-5 h-5" />
|
||||||
|
Exportar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<TrendingUp className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray">Economia Total</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-text">95K</p>
|
||||||
|
<p className="text-xs text-green-600 mt-1">+12% vs período anterior</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-secondary/10 flex items-center justify-center">
|
||||||
|
<FileText className="w-5 h-5 text-secondary" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray">Demandas</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-text">156</p>
|
||||||
|
<p className="text-xs text-green-600 mt-1">+8% vs período anterior</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center">
|
||||||
|
<BarChart3 className="w-5 h-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray">OS Concluídas</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-text">142</p>
|
||||||
|
<p className="text-xs text-green-600 mt-1">91% taxa de conclusão</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center">
|
||||||
|
<TrendingDown className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray">Tempo Médio</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-text">3.2 dias</p>
|
||||||
|
<p className="text-xs text-green-600 mt-1">-15% vs período anterior</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Row 1 */}
|
||||||
|
<div className="grid lg:grid-cols-2 gap-6">
|
||||||
|
{/* Bar Chart - Orçamento vs Gasto */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-text">Orçamento vs Gasto</h2>
|
||||||
|
<p className="text-sm text-gray">Comparativo mensal (em milhares)</p>
|
||||||
|
</div>
|
||||||
|
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray">
|
||||||
|
<Filter className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-72">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={monthlyData} barGap={8}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
|
||||||
|
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
border: '1px solid #E0E0E0',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="orcamento" fill="#1A237E" radius={[4, 4, 0, 0]} name="Orçamento" />
|
||||||
|
<Bar dataKey="gasto" fill="#E65100" radius={[4, 4, 0, 0]} name="Gasto" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pie Chart - Por Categoria */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-text">Gastos por Categoria</h2>
|
||||||
|
<p className="text-sm text-gray">Distribuição percentual</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-72">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<RechartsPie>
|
||||||
|
<Pie
|
||||||
|
data={categoryData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={100}
|
||||||
|
paddingAngle={4}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{categoryData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Legend
|
||||||
|
verticalAlign="bottom"
|
||||||
|
iconType="circle"
|
||||||
|
iconSize={8}
|
||||||
|
formatter={(value) => <span className="text-sm text-gray">{value}</span>}
|
||||||
|
/>
|
||||||
|
<Tooltip />
|
||||||
|
</RechartsPie>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Row 2 */}
|
||||||
|
<div className="grid lg:grid-cols-2 gap-6">
|
||||||
|
{/* Area Chart - Tendência */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-text">Tendência Semanal</h2>
|
||||||
|
<p className="text-sm text-gray">Demandas vs Ordens de Serviço</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={trendData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
|
||||||
|
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
border: '1px solid #E0E0E0',
|
||||||
|
borderRadius: '12px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="demandas" stroke="#E65100" fill="#E65100" fillOpacity={0.2} name="Demandas" />
|
||||||
|
<Area type="monotone" dataKey="os" stroke="#1A237E" fill="#1A237E" fillOpacity={0.2} name="Ordens de Serviço" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bar Chart - Fornecedores */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-text">Top Fornecedores</h2>
|
||||||
|
<p className="text-sm text-gray">Por número de atendimentos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={fornecedorData} layout="vertical" barSize={20}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" horizontal={false} />
|
||||||
|
<XAxis type="number" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||||
|
<YAxis type="category" dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} width={100} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
border: '1px solid #E0E0E0',
|
||||||
|
borderRadius: '12px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="atendimentos" fill="#FF8F00" radius={[0, 4, 4, 0]} name="Atendimentos" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Reports */}
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-lg font-semibold text-text mb-4">Relatórios Disponíveis</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ name: 'Relatório Mensal', desc: 'Resumo completo do mês', icon: <Calendar className="w-5 h-5" /> },
|
||||||
|
{ name: 'Análise de Custos', desc: 'Detalhamento por categoria', icon: <PieChart className="w-5 h-5" /> },
|
||||||
|
{ name: 'Performance Fornecedores', desc: 'Avaliação e métricas', icon: <TrendingUp className="w-5 h-5" /> },
|
||||||
|
{ name: 'Histórico de Demandas', desc: 'Todas as solicitações', icon: <FileText className="w-5 h-5" /> },
|
||||||
|
].map((report, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-4 p-4 bg-card rounded-xl hover:bg-gray-100 transition-all text-left"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
{report.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-text">{report.name}</p>
|
||||||
|
<p className="text-xs text-gray">{report.desc}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
357
frontend/src/pages/Usuarios.tsx
Normal file
357
frontend/src/pages/Usuarios.tsx
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Shield,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
Mail,
|
||||||
|
User,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Key
|
||||||
|
} from 'lucide-react'
|
||||||
|
import api from '../services/api'
|
||||||
|
|
||||||
|
interface Usuario {
|
||||||
|
id: number;
|
||||||
|
nome: string;
|
||||||
|
email: string;
|
||||||
|
perfil: string;
|
||||||
|
perfil_id: number;
|
||||||
|
ativo?: boolean;
|
||||||
|
ultimo_acesso?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const perfilConfig: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||||
|
'admin': { label: 'Administrador', color: '#E65100', icon: <Shield className="w-4 h-4" /> },
|
||||||
|
'administrador': { label: 'Administrador', color: '#E65100', icon: <Shield className="w-4 h-4" /> },
|
||||||
|
'gestor': { label: 'Gestor', color: '#1A237E', icon: <Users className="w-4 h-4" /> },
|
||||||
|
'financeiro': { label: 'Financeiro', color: '#2E7D32', icon: <Key className="w-4 h-4" /> },
|
||||||
|
'solicitante': { label: 'Solicitante', color: '#7B1FA2', icon: <User className="w-4 h-4" /> },
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockUsuarios: Usuario[] = [
|
||||||
|
{ id: 1, nome: 'Admin Sistema', email: 'admin@hefesto.com', perfil: 'Administrador', perfil_id: 1, ativo: true, ultimo_acesso: '2024-01-16 14:30' },
|
||||||
|
{ id: 2, nome: 'João Santos', email: 'joao.santos@hefesto.com', perfil: 'Gestor', perfil_id: 2, ativo: true, ultimo_acesso: '2024-01-16 10:15' },
|
||||||
|
{ id: 3, nome: 'Ana Oliveira', email: 'ana.oliveira@hefesto.com', perfil: 'Financeiro', perfil_id: 3, ativo: true, ultimo_acesso: '2024-01-15 16:45' },
|
||||||
|
{ id: 4, nome: 'Maria Silva', email: 'maria.silva@hefesto.com', perfil: 'Solicitante', perfil_id: 4, ativo: true, ultimo_acesso: '2024-01-16 09:00' },
|
||||||
|
{ id: 5, nome: 'Carlos Lima', email: 'carlos.lima@hefesto.com', perfil: 'Solicitante', perfil_id: 4, ativo: false, ultimo_acesso: '2024-01-10 11:30' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Usuarios() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [usuarios, setUsuarios] = useState<Usuario[]>([])
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [filterPerfil, setFilterPerfil] = useState('todos')
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [selectedUser, setSelectedUser] = useState<Usuario | null>(null)
|
||||||
|
const [formData, setFormData] = useState({ nome: '', email: '', perfil_id: 4, senha: '' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Simulate loading
|
||||||
|
setTimeout(() => {
|
||||||
|
setUsuarios(mockUsuarios)
|
||||||
|
setLoading(false)
|
||||||
|
}, 500)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filteredUsuarios = usuarios.filter(user => {
|
||||||
|
const matchesSearch = user.nome.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
user.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
const matchesPerfil = filterPerfil === 'todos' || user.perfil.toLowerCase() === filterPerfil
|
||||||
|
return matchesSearch && matchesPerfil
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleOpenModal = (user?: Usuario) => {
|
||||||
|
if (user) {
|
||||||
|
setSelectedUser(user)
|
||||||
|
setFormData({ nome: user.nome, email: user.email, perfil_id: user.perfil_id, senha: '' })
|
||||||
|
} else {
|
||||||
|
setSelectedUser(null)
|
||||||
|
setFormData({ nome: '', email: '', perfil_id: 4, senha: '' })
|
||||||
|
}
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setShowModal(false)
|
||||||
|
setSelectedUser(null)
|
||||||
|
setFormData({ nome: '', email: '', perfil_id: 4, senha: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
// Mock save
|
||||||
|
if (selectedUser) {
|
||||||
|
setUsuarios(usuarios.map(u => u.id === selectedUser.id ? { ...u, ...formData, perfil: getPerfilLabel(formData.perfil_id) } : u))
|
||||||
|
} else {
|
||||||
|
const newUser: Usuario = {
|
||||||
|
id: Date.now(),
|
||||||
|
...formData,
|
||||||
|
perfil: getPerfilLabel(formData.perfil_id),
|
||||||
|
ativo: true,
|
||||||
|
ultimo_acesso: 'Nunca'
|
||||||
|
}
|
||||||
|
setUsuarios([...usuarios, newUser])
|
||||||
|
}
|
||||||
|
handleCloseModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPerfilLabel = (id: number) => {
|
||||||
|
const perfis: Record<number, string> = { 1: 'Administrador', 2: 'Gestor', 3: 'Financeiro', 4: 'Solicitante' }
|
||||||
|
return perfis[id] || 'Solicitante'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPerfilConfig = (perfil: string) => {
|
||||||
|
const key = perfil.toLowerCase()
|
||||||
|
return perfilConfig[key] || { label: perfil, color: '#757575', icon: <User className="w-4 h-4" /> }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-text">Usuários</h1>
|
||||||
|
<p className="text-gray mt-1">Gerencie os usuários do sistema</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenModal()}
|
||||||
|
className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
Novo Usuário
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-gray text-sm">Total</p>
|
||||||
|
<p className="text-2xl font-bold text-text">{usuarios.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-gray text-sm">Ativos</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">{usuarios.filter(u => u.ativo !== false).length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-gray text-sm">Admins</p>
|
||||||
|
<p className="text-2xl font-bold text-primary">{usuarios.filter(u => u.perfil.toLowerCase().includes('admin')).length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-gray text-sm">Inativos</p>
|
||||||
|
<p className="text-2xl font-bold text-gray">{usuarios.filter(u => u.ativo === false).length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nome ou e-mail..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="input-field pl-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{['todos', 'administrador', 'gestor', 'financeiro', 'solicitante'].map((filter) => (
|
||||||
|
<button
|
||||||
|
key={filter}
|
||||||
|
onClick={() => setFilterPerfil(filter)}
|
||||||
|
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||||
|
filterPerfil === filter
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-gray-100 text-gray hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="card !p-0 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="table-header">
|
||||||
|
<tr>
|
||||||
|
<th className="table-cell">Usuário</th>
|
||||||
|
<th className="table-cell">E-mail</th>
|
||||||
|
<th className="table-cell">Perfil</th>
|
||||||
|
<th className="table-cell">Último Acesso</th>
|
||||||
|
<th className="table-cell text-center">Status</th>
|
||||||
|
<th className="table-cell text-center">Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredUsuarios.map((user) => {
|
||||||
|
const config = getPerfilConfig(user.perfil)
|
||||||
|
return (
|
||||||
|
<tr key={user.id} className="table-row">
|
||||||
|
<td className="table-cell">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-xl flex items-center justify-center text-white font-semibold"
|
||||||
|
style={{ backgroundColor: config.color }}
|
||||||
|
>
|
||||||
|
{user.nome.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">{user.nome}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail className="w-4 h-4 text-gray" />
|
||||||
|
<span>{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold"
|
||||||
|
style={{ backgroundColor: `${config.color}15`, color: config.color }}
|
||||||
|
>
|
||||||
|
{config.icon}
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="table-cell text-gray text-sm">
|
||||||
|
{user.ultimo_acesso}
|
||||||
|
</td>
|
||||||
|
<td className="table-cell text-center">
|
||||||
|
{user.ativo !== false ? (
|
||||||
|
<span className="badge-success inline-flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
Ativo
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge-neutral inline-flex items-center gap-1">
|
||||||
|
<XCircle className="w-3 h-3" />
|
||||||
|
Inativo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenModal(user)}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 rounded-lg hover:bg-red-50 text-gray hover:text-red-500 transition-colors">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredUsuarios.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Users className="w-12 h-12 text-gray-light mx-auto mb-4" />
|
||||||
|
<p className="text-gray">Nenhum usuário encontrado</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||||
|
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
|
<h2 className="text-xl font-semibold text-text">
|
||||||
|
{selectedUser ? 'Editar Usuário' : 'Novo Usuário'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Nome</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.nome}
|
||||||
|
onChange={(e) => setFormData({ ...formData, nome: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="Nome completo"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">E-mail</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="email@exemplo.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Perfil</label>
|
||||||
|
<select
|
||||||
|
value={formData.perfil_id}
|
||||||
|
onChange={(e) => setFormData({ ...formData, perfil_id: Number(e.target.value) })}
|
||||||
|
className="input-field"
|
||||||
|
>
|
||||||
|
<option value={1}>Administrador</option>
|
||||||
|
<option value={2}>Gestor</option>
|
||||||
|
<option value={3}>Financeiro</option>
|
||||||
|
<option value={4}>Solicitante</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{!selectedUser && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Senha</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.senha}
|
||||||
|
onChange={(e) => setFormData({ ...formData, senha: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="••••••"
|
||||||
|
required={!selectedUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="button" onClick={handleCloseModal} className="btn-ghost flex-1">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn-primary flex-1">
|
||||||
|
{selectedUser ? 'Salvar' : 'Criar Usuário'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
frontend/src/services/api.ts
Normal file
27
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
|
||||||
|
|
||||||
|
const api: AxiosInstance = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => response,
|
||||||
|
(error: AxiosError) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user