DuOrigin v2 - React + NestJS + Prisma + EUDR API Integration
This commit is contained in:
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# DuOrigin v2 Environment Variables
|
||||||
|
# Copy to .env and adjust values
|
||||||
|
|
||||||
|
# Database (PostgreSQL)
|
||||||
|
# Via túnel SSH (dev local): localhost:5433
|
||||||
|
# Produção: endereço real do servidor
|
||||||
|
DATABASE_URL="postgresql://duorigin:PASSWORD@localhost:5433/duorigin"
|
||||||
|
|
||||||
|
# JWT Secret (generate a secure random string for production)
|
||||||
|
JWT_SECRET="your-secure-jwt-secret-here"
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
NEXT_PUBLIC_API_URL="http://localhost:3000/api"
|
||||||
|
|
||||||
|
# NextAuth (if using)
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
NEXTAUTH_SECRET="your-nextauth-secret-here"
|
||||||
42
.gitignore
vendored
42
.gitignore
vendored
@@ -1,36 +1,6 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
node_modules/
|
||||||
|
dist/
|
||||||
# dependencies
|
.next/
|
||||||
/node_modules
|
*.log
|
||||||
/.pnp
|
.env
|
||||||
.pnp.js
|
.env.local
|
||||||
.yarn/install-state.gz
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# local env files
|
|
||||||
.env*.local
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
|
|||||||
33
backend/.env.example
Normal file
33
backend/.env.example
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/duorigin
|
||||||
|
|
||||||
|
JWT_SECRET=your_jwt_secret_here
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
PORT=8100
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# EUDR API Configuration
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# API URL - Use ACCEPTANCE for testing, PRODUCTION for live
|
||||||
|
# ACCEPTANCE: https://acceptance.eudr.webcloud.ec.europa.eu/tracesnt
|
||||||
|
# PRODUCTION: https://webgate.ec.europa.eu/tracesnt
|
||||||
|
EUDR_API_URL=https://acceptance.eudr.webcloud.ec.europa.eu/tracesnt
|
||||||
|
|
||||||
|
# Web Service Client ID (default: eudr-test for ACCEPTANCE)
|
||||||
|
EUDR_WS_CLIENT_ID=eudr-test
|
||||||
|
|
||||||
|
# EU Login credentials for the web service user
|
||||||
|
# Obtain these from TRACES NT after registering as operator
|
||||||
|
EUDR_USERNAME=your_eu_login_username
|
||||||
|
EUDR_AUTH_KEY=your_authentication_key
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# How to obtain EUDR credentials:
|
||||||
|
# ============================================
|
||||||
|
# 1. Register in TRACES NT (acceptance.eudr.webcloud.ec.europa.eu)
|
||||||
|
# 2. Create an Operator and User
|
||||||
|
# 3. Go to Edit Profile > Web Services Access
|
||||||
|
# 4. Click "Active" to enable Web Service access
|
||||||
|
# 5. Copy your Username and Authentication Key
|
||||||
|
# ============================================
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
10668
backend/package-lock.json
generated
Normal file
10668
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
85
backend/package.json
Normal file
85
backend/package.json
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"name": "duorigin-v2-backend",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "DuOrigin v2 - Sistema de Compliance EUDR para Agronegócio",
|
||||||
|
"author": "AIVertice",
|
||||||
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"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",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:push": "prisma db push",
|
||||||
|
"prisma:pull": "prisma db pull",
|
||||||
|
"prisma:studio": "prisma studio",
|
||||||
|
"seed": "ts-node prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.4.15",
|
||||||
|
"@nestjs/config": "^3.3.0",
|
||||||
|
"@nestjs/core": "^10.4.15",
|
||||||
|
"@nestjs/jwt": "^10.2.0",
|
||||||
|
"@nestjs/mapped-types": "^2.0.6",
|
||||||
|
"@nestjs/passport": "^10.0.3",
|
||||||
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
|
"@nestjs/swagger": "^8.1.0",
|
||||||
|
"@prisma/client": "^6.3.1",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"fast-xml-parser": "^4.5.1",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.4.9",
|
||||||
|
"@nestjs/schematics": "^10.2.3",
|
||||||
|
"@nestjs/testing": "^10.4.15",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.21.0",
|
||||||
|
"@typescript-eslint/parser": "^8.21.0",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prisma": "^6.3.1",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
133
backend/prisma/schema.prisma
Normal file
133
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
OPERADOR
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StatusAvaliacao {
|
||||||
|
RASCUNHO
|
||||||
|
EM_ANALISE
|
||||||
|
APROVADA
|
||||||
|
REPROVADA
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NivelRisco {
|
||||||
|
BAIXO
|
||||||
|
MEDIO
|
||||||
|
ALTO
|
||||||
|
CRITICO
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
nome String
|
||||||
|
role Role @default(OPERADOR)
|
||||||
|
ativo Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
avaliacoes Avaliacao[]
|
||||||
|
documentos Documento[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Empresa {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
razaoSocial String @map("razao_social")
|
||||||
|
nomeFantasia String? @map("nome_fantasia")
|
||||||
|
cnpj String @unique
|
||||||
|
inscricaoEstadual String? @map("inscricao_estadual")
|
||||||
|
atividadeAgricola String? @map("atividade_agricola")
|
||||||
|
endereco String?
|
||||||
|
cidade String?
|
||||||
|
estado String?
|
||||||
|
cep String?
|
||||||
|
telefone String?
|
||||||
|
email String?
|
||||||
|
responsavel String?
|
||||||
|
ativo Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
propriedades Propriedade[]
|
||||||
|
|
||||||
|
@@map("empresas")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Propriedade {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
nome String
|
||||||
|
empresaId Int @map("empresa_id")
|
||||||
|
codigoCar String? @map("codigo_car")
|
||||||
|
areaHa Float? @map("area_ha")
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
bioma String?
|
||||||
|
endereco String?
|
||||||
|
cidade String?
|
||||||
|
estado String?
|
||||||
|
nivelRisco NivelRisco @default(BAIXO) @map("nivel_risco")
|
||||||
|
flagDesmatamento String? @map("flag_desmatamento")
|
||||||
|
geojson String? @db.Text
|
||||||
|
ativo Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
empresa Empresa @relation(fields: [empresaId], references: [id], onDelete: Cascade)
|
||||||
|
avaliacoes Avaliacao[]
|
||||||
|
documentos Documento[]
|
||||||
|
|
||||||
|
@@map("propriedades")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Avaliacao {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
propriedadeId Int @map("propriedade_id")
|
||||||
|
userId Int @map("user_id")
|
||||||
|
referencia String @unique
|
||||||
|
status StatusAvaliacao @default(RASCUNHO)
|
||||||
|
nivelRisco NivelRisco @default(BAIXO) @map("nivel_risco")
|
||||||
|
dataAvaliacao DateTime? @map("data_avaliacao")
|
||||||
|
dataSubmissao DateTime? @map("data_submissao")
|
||||||
|
observacoes String? @db.Text
|
||||||
|
resultadoDds String? @map("resultado_dds") @db.Text
|
||||||
|
referenciaEu String? @map("referencia_eu")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
propriedade Propriedade @relation(fields: [propriedadeId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
documentos Documento[]
|
||||||
|
|
||||||
|
@@map("avaliacoes")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Documento {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
nome String
|
||||||
|
tipo String?
|
||||||
|
tamanho Int?
|
||||||
|
mimeType String? @map("mime_type")
|
||||||
|
path String
|
||||||
|
propriedadeId Int? @map("propriedade_id")
|
||||||
|
avaliacaoId Int? @map("avaliacao_id")
|
||||||
|
userId Int @map("user_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
propriedade Propriedade? @relation(fields: [propriedadeId], references: [id], onDelete: Cascade)
|
||||||
|
avaliacao Avaliacao? @relation(fields: [avaliacaoId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
@@map("documentos")
|
||||||
|
}
|
||||||
328
backend/prisma/seed.ts
Normal file
328
backend/prisma/seed.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import { PrismaClient, Role, NivelRisco, StatusAvaliacao } from '@prisma/client';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Seeding database...');
|
||||||
|
|
||||||
|
// Create users
|
||||||
|
const adminPassword = await bcrypt.hash('DuoDemo2026', 10);
|
||||||
|
const operadorPassword = await bcrypt.hash('DuoDemo2026', 10);
|
||||||
|
|
||||||
|
const admin = await prisma.user.upsert({
|
||||||
|
where: { email: 'demo@duorigin.com' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'demo@duorigin.com',
|
||||||
|
password: adminPassword,
|
||||||
|
nome: 'Administrador Demo',
|
||||||
|
role: Role.ADMIN,
|
||||||
|
ativo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const operador = await prisma.user.upsert({
|
||||||
|
where: { email: 'operador@duorigin.com' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'operador@duorigin.com',
|
||||||
|
password: operadorPassword,
|
||||||
|
nome: 'Operador Demo',
|
||||||
|
role: Role.OPERADOR,
|
||||||
|
ativo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Users created:', { admin: admin.email, operador: operador.email });
|
||||||
|
|
||||||
|
// Create empresas
|
||||||
|
const empresas = await Promise.all([
|
||||||
|
prisma.empresa.upsert({
|
||||||
|
where: { cnpj: '12.345.678/0001-90' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
razaoSocial: 'Fazenda Bom Sucesso Ltda',
|
||||||
|
nomeFantasia: 'Fazenda Bom Sucesso',
|
||||||
|
cnpj: '12.345.678/0001-90',
|
||||||
|
inscricaoEstadual: '123.456.789.012',
|
||||||
|
atividadeAgricola: 'Soja, Milho',
|
||||||
|
endereco: 'Rodovia BR-050, Km 45',
|
||||||
|
cidade: 'Uberlândia',
|
||||||
|
estado: 'MG',
|
||||||
|
cep: '38400-000',
|
||||||
|
telefone: '(34) 99999-0001',
|
||||||
|
email: 'contato@bomsucesso.com.br',
|
||||||
|
responsavel: 'João Silva',
|
||||||
|
ativo: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.empresa.upsert({
|
||||||
|
where: { cnpj: '23.456.789/0001-01' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
razaoSocial: 'Agropecuária São José S.A.',
|
||||||
|
nomeFantasia: 'São José Agro',
|
||||||
|
cnpj: '23.456.789/0001-01',
|
||||||
|
inscricaoEstadual: '234.567.890.123',
|
||||||
|
atividadeAgricola: 'Café, Pecuária',
|
||||||
|
endereco: 'Estrada Municipal SP-123, Km 10',
|
||||||
|
cidade: 'Ribeirão Preto',
|
||||||
|
estado: 'SP',
|
||||||
|
cep: '14000-000',
|
||||||
|
telefone: '(16) 99999-0002',
|
||||||
|
email: 'contato@saojoseagro.com.br',
|
||||||
|
responsavel: 'Maria Santos',
|
||||||
|
ativo: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.empresa.upsert({
|
||||||
|
where: { cnpj: '34.567.890/0001-12' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
razaoSocial: 'Cooperativa Agrícola Vale Verde',
|
||||||
|
nomeFantasia: 'Coop Vale Verde',
|
||||||
|
cnpj: '34.567.890/0001-12',
|
||||||
|
inscricaoEstadual: '345.678.901.234',
|
||||||
|
atividadeAgricola: 'Soja, Algodão, Milho',
|
||||||
|
endereco: 'Av. Principal, 1000',
|
||||||
|
cidade: 'Rondonópolis',
|
||||||
|
estado: 'MT',
|
||||||
|
cep: '78700-000',
|
||||||
|
telefone: '(66) 99999-0003',
|
||||||
|
email: 'contato@valeverde.coop.br',
|
||||||
|
responsavel: 'Carlos Oliveira',
|
||||||
|
ativo: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('✅ Empresas created:', empresas.length);
|
||||||
|
|
||||||
|
// Create propriedades
|
||||||
|
const propriedades = await Promise.all([
|
||||||
|
prisma.propriedade.create({
|
||||||
|
data: {
|
||||||
|
nome: 'Fazenda Primavera',
|
||||||
|
empresaId: empresas[0].id,
|
||||||
|
codigoCar: 'MG-3170206-A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6',
|
||||||
|
areaHa: 1500.5,
|
||||||
|
latitude: -18.9186,
|
||||||
|
longitude: -48.2772,
|
||||||
|
bioma: 'Cerrado',
|
||||||
|
endereco: 'Zona Rural, Lote 45',
|
||||||
|
cidade: 'Uberlândia',
|
||||||
|
estado: 'MG',
|
||||||
|
nivelRisco: NivelRisco.BAIXO,
|
||||||
|
flagDesmatamento: 'clean',
|
||||||
|
ativo: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.propriedade.create({
|
||||||
|
data: {
|
||||||
|
nome: 'Sítio Esperança',
|
||||||
|
empresaId: empresas[0].id,
|
||||||
|
codigoCar: 'MG-3170206-B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7',
|
||||||
|
areaHa: 320.0,
|
||||||
|
latitude: -18.8500,
|
||||||
|
longitude: -48.3000,
|
||||||
|
bioma: 'Cerrado',
|
||||||
|
cidade: 'Uberlândia',
|
||||||
|
estado: 'MG',
|
||||||
|
nivelRisco: NivelRisco.BAIXO,
|
||||||
|
ativo: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.propriedade.create({
|
||||||
|
data: {
|
||||||
|
nome: 'Fazenda São José',
|
||||||
|
empresaId: empresas[1].id,
|
||||||
|
codigoCar: 'SP-3543402-C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8',
|
||||||
|
areaHa: 850.75,
|
||||||
|
latitude: -21.1767,
|
||||||
|
longitude: -47.8108,
|
||||||
|
bioma: 'Mata Atlântica',
|
||||||
|
cidade: 'Ribeirão Preto',
|
||||||
|
estado: 'SP',
|
||||||
|
nivelRisco: NivelRisco.MEDIO,
|
||||||
|
flagDesmatamento: 'under_review',
|
||||||
|
ativo: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.propriedade.create({
|
||||||
|
data: {
|
||||||
|
nome: 'Fazenda Pioneira',
|
||||||
|
empresaId: empresas[2].id,
|
||||||
|
codigoCar: 'MT-5107602-D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9',
|
||||||
|
areaHa: 5200.0,
|
||||||
|
latitude: -16.4673,
|
||||||
|
longitude: -54.6372,
|
||||||
|
bioma: 'Cerrado',
|
||||||
|
cidade: 'Rondonópolis',
|
||||||
|
estado: 'MT',
|
||||||
|
nivelRisco: NivelRisco.BAIXO,
|
||||||
|
ativo: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.propriedade.create({
|
||||||
|
data: {
|
||||||
|
nome: 'Fazenda Horizonte',
|
||||||
|
empresaId: empresas[2].id,
|
||||||
|
codigoCar: 'MT-5107602-E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0',
|
||||||
|
areaHa: 3800.25,
|
||||||
|
latitude: -16.5000,
|
||||||
|
longitude: -54.7000,
|
||||||
|
bioma: 'Cerrado',
|
||||||
|
cidade: 'Rondonópolis',
|
||||||
|
estado: 'MT',
|
||||||
|
nivelRisco: NivelRisco.ALTO,
|
||||||
|
flagDesmatamento: 'flagged',
|
||||||
|
ativo: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('✅ Propriedades created:', propriedades.length);
|
||||||
|
|
||||||
|
// Create avaliacoes
|
||||||
|
const generateRef = () => {
|
||||||
|
const timestamp = Date.now().toString(36).toUpperCase();
|
||||||
|
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||||
|
return `DUO-${timestamp}-${random}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const avaliacoes = await Promise.all([
|
||||||
|
prisma.avaliacao.create({
|
||||||
|
data: {
|
||||||
|
propriedadeId: propriedades[0].id,
|
||||||
|
userId: admin.id,
|
||||||
|
referencia: generateRef(),
|
||||||
|
status: StatusAvaliacao.APROVADA,
|
||||||
|
nivelRisco: NivelRisco.BAIXO,
|
||||||
|
dataAvaliacao: new Date('2024-01-15'),
|
||||||
|
dataSubmissao: new Date('2024-01-20'),
|
||||||
|
observacoes: 'Propriedade em conformidade com EUDR.',
|
||||||
|
referenciaEu: 'EU-ABC123',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.avaliacao.create({
|
||||||
|
data: {
|
||||||
|
propriedadeId: propriedades[0].id,
|
||||||
|
userId: operador.id,
|
||||||
|
referencia: generateRef(),
|
||||||
|
status: StatusAvaliacao.EM_ANALISE,
|
||||||
|
nivelRisco: NivelRisco.BAIXO,
|
||||||
|
dataAvaliacao: new Date('2024-06-01'),
|
||||||
|
observacoes: 'Aguardando documentação adicional.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.avaliacao.create({
|
||||||
|
data: {
|
||||||
|
propriedadeId: propriedades[1].id,
|
||||||
|
userId: admin.id,
|
||||||
|
referencia: generateRef(),
|
||||||
|
status: StatusAvaliacao.RASCUNHO,
|
||||||
|
nivelRisco: NivelRisco.BAIXO,
|
||||||
|
dataAvaliacao: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.avaliacao.create({
|
||||||
|
data: {
|
||||||
|
propriedadeId: propriedades[2].id,
|
||||||
|
userId: operador.id,
|
||||||
|
referencia: generateRef(),
|
||||||
|
status: StatusAvaliacao.EM_ANALISE,
|
||||||
|
nivelRisco: NivelRisco.MEDIO,
|
||||||
|
dataAvaliacao: new Date('2024-05-10'),
|
||||||
|
dataSubmissao: new Date('2024-05-15'),
|
||||||
|
observacoes: 'Área com histórico de desmatamento em análise.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.avaliacao.create({
|
||||||
|
data: {
|
||||||
|
propriedadeId: propriedades[2].id,
|
||||||
|
userId: admin.id,
|
||||||
|
referencia: generateRef(),
|
||||||
|
status: StatusAvaliacao.REPROVADA,
|
||||||
|
nivelRisco: NivelRisco.ALTO,
|
||||||
|
dataAvaliacao: new Date('2024-03-01'),
|
||||||
|
dataSubmissao: new Date('2024-03-10'),
|
||||||
|
observacoes: 'Documentação irregular. Necessário revisão.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.avaliacao.create({
|
||||||
|
data: {
|
||||||
|
propriedadeId: propriedades[3].id,
|
||||||
|
userId: admin.id,
|
||||||
|
referencia: generateRef(),
|
||||||
|
status: StatusAvaliacao.APROVADA,
|
||||||
|
nivelRisco: NivelRisco.BAIXO,
|
||||||
|
dataAvaliacao: new Date('2024-02-20'),
|
||||||
|
dataSubmissao: new Date('2024-02-25'),
|
||||||
|
referenciaEu: 'EU-DEF456',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.avaliacao.create({
|
||||||
|
data: {
|
||||||
|
propriedadeId: propriedades[3].id,
|
||||||
|
userId: operador.id,
|
||||||
|
referencia: generateRef(),
|
||||||
|
status: StatusAvaliacao.RASCUNHO,
|
||||||
|
nivelRisco: NivelRisco.BAIXO,
|
||||||
|
dataAvaliacao: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.avaliacao.create({
|
||||||
|
data: {
|
||||||
|
propriedadeId: propriedades[4].id,
|
||||||
|
userId: admin.id,
|
||||||
|
referencia: generateRef(),
|
||||||
|
status: StatusAvaliacao.EM_ANALISE,
|
||||||
|
nivelRisco: NivelRisco.ALTO,
|
||||||
|
dataAvaliacao: new Date('2024-06-05'),
|
||||||
|
dataSubmissao: new Date('2024-06-10'),
|
||||||
|
observacoes: 'Área com risco alto de desmatamento identificado.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.avaliacao.create({
|
||||||
|
data: {
|
||||||
|
propriedadeId: propriedades[4].id,
|
||||||
|
userId: operador.id,
|
||||||
|
referencia: generateRef(),
|
||||||
|
status: StatusAvaliacao.REPROVADA,
|
||||||
|
nivelRisco: NivelRisco.CRITICO,
|
||||||
|
dataAvaliacao: new Date('2024-04-01'),
|
||||||
|
dataSubmissao: new Date('2024-04-05'),
|
||||||
|
observacoes: 'Desmatamento ilegal detectado. Propriedade bloqueada.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.avaliacao.create({
|
||||||
|
data: {
|
||||||
|
propriedadeId: propriedades[1].id,
|
||||||
|
userId: operador.id,
|
||||||
|
referencia: generateRef(),
|
||||||
|
status: StatusAvaliacao.APROVADA,
|
||||||
|
nivelRisco: NivelRisco.BAIXO,
|
||||||
|
dataAvaliacao: new Date('2024-01-05'),
|
||||||
|
dataSubmissao: new Date('2024-01-10'),
|
||||||
|
referenciaEu: 'EU-GHI789',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('✅ Avaliacoes created:', avaliacoes.length);
|
||||||
|
|
||||||
|
console.log('🎉 Seed completed successfully!');
|
||||||
|
console.log('\n📝 Login credentials:');
|
||||||
|
console.log(' Admin: demo@duorigin.com / DuoDemo2026');
|
||||||
|
console.log(' Operador: operador@duorigin.com / DuoDemo2026');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Seed error:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
29
backend/src/app.module.ts
Normal file
29
backend/src/app.module.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { UsersModule } from './users/users.module';
|
||||||
|
import { EmpresasModule } from './empresas/empresas.module';
|
||||||
|
import { PropriedadesModule } from './propriedades/propriedades.module';
|
||||||
|
import { AvaliacoesModule } from './avaliacoes/avaliacoes.module';
|
||||||
|
import { DocumentosModule } from './documentos/documentos.module';
|
||||||
|
import { DashboardModule } from './dashboard/dashboard.module';
|
||||||
|
import { EudrApiModule } from './eudr-api/eudr-api.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
}),
|
||||||
|
PrismaModule,
|
||||||
|
AuthModule,
|
||||||
|
UsersModule,
|
||||||
|
EmpresasModule,
|
||||||
|
PropriedadesModule,
|
||||||
|
AvaliacoesModule,
|
||||||
|
DocumentosModule,
|
||||||
|
DashboardModule,
|
||||||
|
EudrApiModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
38
backend/src/auth/auth.controller.ts
Normal file
38
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Controller, Post, Get, Body, UseGuards, Request } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { RegistroDto } from './dto/registro.dto';
|
||||||
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('auth')
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
@ApiOperation({ summary: 'Realizar login' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Login realizado com sucesso' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Credenciais inválidas' })
|
||||||
|
async login(@Body() loginDto: LoginDto) {
|
||||||
|
return this.authService.login(loginDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('registro')
|
||||||
|
@ApiOperation({ summary: 'Registrar novo usuário' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Usuário criado com sucesso' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Email já cadastrado' })
|
||||||
|
async registro(@Body() registroDto: RegistroDto) {
|
||||||
|
return this.authService.registro(registroDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Obter perfil do usuário logado' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Perfil retornado com sucesso' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Não autorizado' })
|
||||||
|
async getProfile(@Request() req: any) {
|
||||||
|
return this.authService.getProfile(req.user.sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
backend/src/auth/auth.module.ts
Normal file
29
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: async (configService: ConfigService) => ({
|
||||||
|
secret: configService.get<string>('JWT_SECRET'),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: configService.get<string>('JWT_EXPIRES_IN') || '7d',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
UsersModule,
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy],
|
||||||
|
exports: [AuthService, JwtModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
107
backend/src/auth/auth.service.ts
Normal file
107
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { RegistroDto } from './dto/registro.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private jwtService: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async login(loginDto: LoginDto) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { email: loginDto.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('Credenciais inválidas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await bcrypt.compare(loginDto.password, user.password);
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new UnauthorizedException('Credenciais inválidas');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.ativo) {
|
||||||
|
throw new UnauthorizedException('Usuário desativado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = { sub: user.id, email: user.email, role: user.role };
|
||||||
|
const accessToken = this.jwtService.sign(payload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
access_token: accessToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
nome: user.nome,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async registro(registroDto: RegistroDto) {
|
||||||
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
|
where: { email: registroDto.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ConflictException('Email já cadastrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(registroDto.password, 10);
|
||||||
|
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: registroDto.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
nome: registroDto.nome,
|
||||||
|
role: registroDto.role || 'OPERADOR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = { sub: user.id, email: user.email, role: user.role };
|
||||||
|
const accessToken = this.jwtService.sign(payload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
access_token: accessToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
nome: user.nome,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfile(userId: number) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nome: true,
|
||||||
|
role: true,
|
||||||
|
ativo: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('Usuário não encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateUser(userId: number) {
|
||||||
|
return this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/auth/dto/login.dto.ts
Normal file
13
backend/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@ApiProperty({ example: 'demo@duorigin.com', description: 'Email do usuário' })
|
||||||
|
@IsEmail({}, { message: 'Email inválido' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'DuoDemo2026', description: 'Senha do usuário' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6, { message: 'Senha deve ter no mínimo 6 caracteres' })
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
24
backend/src/auth/dto/registro.dto.ts
Normal file
24
backend/src/auth/dto/registro.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { IsEmail, IsString, MinLength, IsOptional, IsEnum } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
|
||||||
|
export class RegistroDto {
|
||||||
|
@ApiProperty({ example: 'usuario@duorigin.com', description: 'Email do usuário' })
|
||||||
|
@IsEmail({}, { message: 'Email inválido' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Senha123!', description: 'Senha do usuário' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6, { message: 'Senha deve ter no mínimo 6 caracteres' })
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'João Silva', description: 'Nome completo do usuário' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2, { message: 'Nome deve ter no mínimo 2 caracteres' })
|
||||||
|
nome: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: Role, example: 'OPERADOR', description: 'Papel do usuário' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(Role)
|
||||||
|
role?: Role;
|
||||||
|
}
|
||||||
30
backend/src/auth/jwt-auth.guard.ts
Normal file
30
backend/src/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable, ExecutionContext, UnauthorizedException } 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRequest(err: any, user: any, info: any) {
|
||||||
|
if (err || !user) {
|
||||||
|
throw err || new UnauthorizedException('Token inválido ou expirado');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/src/auth/jwt.strategy.ts
Normal file
41
backend/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
interface JwtPayload {
|
||||||
|
sub: number;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private prisma: PrismaService,
|
||||||
|
) {
|
||||||
|
const secret = configService.get<string>('JWT_SECRET');
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('JWT_SECRET not configured');
|
||||||
|
}
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: secret,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: JwtPayload) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: payload.sub },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.ativo) {
|
||||||
|
throw new UnauthorizedException('Usuário não autorizado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sub: payload.sub, email: payload.email, role: payload.role };
|
||||||
|
}
|
||||||
|
}
|
||||||
5
backend/src/auth/roles.decorator.ts
Normal file
5
backend/src/auth/roles.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
29
backend/src/auth/roles.guard.ts
Normal file
29
backend/src/auth/roles.guard.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!requiredRoles) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
if (!user || !requiredRoles.includes(user.role)) {
|
||||||
|
throw new ForbiddenException('Acesso negado. Permissão insuficiente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
backend/src/avaliacoes/avaliacoes.controller.ts
Normal file
92
backend/src/avaliacoes/avaliacoes.controller.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Controller, Get, Post, Put, Delete, Body, Param, Query, ParseIntPipe, UseGuards, Request } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||||
|
import { AvaliacoesService } from './avaliacoes.service';
|
||||||
|
import { CreateAvaliacaoDto } from './dto/create-avaliacao.dto';
|
||||||
|
import { UpdateAvaliacaoDto } from './dto/update-avaliacao.dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { StatusAvaliacao } from '@prisma/client';
|
||||||
|
|
||||||
|
@ApiTags('avaliacoes')
|
||||||
|
@Controller('avaliacoes')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class AvaliacoesController {
|
||||||
|
constructor(private avaliacoesService: AvaliacoesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Listar todas as avaliações' })
|
||||||
|
@ApiQuery({ name: 'propriedadeId', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'status', required: false, enum: StatusAvaliacao })
|
||||||
|
@ApiResponse({ status: 200, description: 'Lista de avaliações' })
|
||||||
|
findAll(
|
||||||
|
@Query('propriedadeId') propriedadeId?: number,
|
||||||
|
@Query('status') status?: StatusAvaliacao,
|
||||||
|
) {
|
||||||
|
return this.avaliacoesService.findAll(propriedadeId, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Buscar avaliação por ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Avaliação encontrada' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Avaliação não encontrada' })
|
||||||
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.avaliacoesService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Criar nova avaliação' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Avaliação criada com sucesso' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Propriedade não encontrada' })
|
||||||
|
create(@Body() createAvaliacaoDto: CreateAvaliacaoDto, @Request() req: any) {
|
||||||
|
return this.avaliacoesService.create(createAvaliacaoDto, req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@ApiOperation({ summary: 'Atualizar avaliação' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Avaliação atualizada' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Avaliação não encontrada' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() updateAvaliacaoDto: UpdateAvaliacaoDto,
|
||||||
|
) {
|
||||||
|
return this.avaliacoesService.update(id, updateAvaliacaoDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Remover avaliação' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Avaliação removida' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Avaliação não encontrada' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.avaliacoesService.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/gerar-dds')
|
||||||
|
@ApiOperation({ summary: 'Gerar DDS (Due Diligence Statement) para avaliação' })
|
||||||
|
@ApiResponse({ status: 200, description: 'DDS gerado com sucesso' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Avaliação não encontrada' })
|
||||||
|
gerarDds(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.avaliacoesService.gerarDds(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/submeter')
|
||||||
|
@ApiOperation({ summary: 'Submeter avaliação para análise' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Avaliação submetida' })
|
||||||
|
@ApiResponse({ status: 400, description: 'DDS não gerado' })
|
||||||
|
submeter(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.avaliacoesService.submeter(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/aprovar')
|
||||||
|
@ApiOperation({ summary: 'Aprovar avaliação' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Avaliação aprovada' })
|
||||||
|
aprovar(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.avaliacoesService.aprovar(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/reprovar')
|
||||||
|
@ApiOperation({ summary: 'Reprovar avaliação' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Avaliação reprovada' })
|
||||||
|
reprovar(@Param('id', ParseIntPipe) id: number, @Body('motivo') motivo?: string) {
|
||||||
|
return this.avaliacoesService.reprovar(id, motivo);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/avaliacoes/avaliacoes.module.ts
Normal file
10
backend/src/avaliacoes/avaliacoes.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AvaliacoesController } from './avaliacoes.controller';
|
||||||
|
import { AvaliacoesService } from './avaliacoes.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AvaliacoesController],
|
||||||
|
providers: [AvaliacoesService],
|
||||||
|
exports: [AvaliacoesService],
|
||||||
|
})
|
||||||
|
export class AvaliacoesModule {}
|
||||||
252
backend/src/avaliacoes/avaliacoes.service.ts
Normal file
252
backend/src/avaliacoes/avaliacoes.service.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateAvaliacaoDto } from './dto/create-avaliacao.dto';
|
||||||
|
import { UpdateAvaliacaoDto } from './dto/update-avaliacao.dto';
|
||||||
|
import { StatusAvaliacao, NivelRisco } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AvaliacoesService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
private generateReferencia(): string {
|
||||||
|
const timestamp = Date.now().toString(36).toUpperCase();
|
||||||
|
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||||
|
return `DUO-${timestamp}-${random}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(propriedadeId?: number, status?: StatusAvaliacao) {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (propriedadeId) {
|
||||||
|
where.propriedadeId = propriedadeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.avaliacao.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
propriedade: {
|
||||||
|
select: { id: true, nome: true, codigoCar: true },
|
||||||
|
include: {
|
||||||
|
empresa: {
|
||||||
|
select: { id: true, razaoSocial: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: { id: true, nome: true, email: true },
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { documentos: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: number) {
|
||||||
|
const avaliacao = await this.prisma.avaliacao.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
propriedade: {
|
||||||
|
include: {
|
||||||
|
empresa: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: { id: true, nome: true, email: true },
|
||||||
|
},
|
||||||
|
documentos: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!avaliacao) {
|
||||||
|
throw new NotFoundException('Avaliação não encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return avaliacao;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(createAvaliacaoDto: CreateAvaliacaoDto, userId: number) {
|
||||||
|
// Verify propriedade exists
|
||||||
|
const propriedade = await this.prisma.propriedade.findUnique({
|
||||||
|
where: { id: createAvaliacaoDto.propriedadeId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!propriedade) {
|
||||||
|
throw new BadRequestException('Propriedade não encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.avaliacao.create({
|
||||||
|
data: {
|
||||||
|
...createAvaliacaoDto,
|
||||||
|
userId,
|
||||||
|
referencia: this.generateReferencia(),
|
||||||
|
dataAvaliacao: new Date(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
propriedade: {
|
||||||
|
select: { id: true, nome: true, codigoCar: true },
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: { id: true, nome: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, updateAvaliacaoDto: UpdateAvaliacaoDto) {
|
||||||
|
await this.findOne(id);
|
||||||
|
|
||||||
|
if (updateAvaliacaoDto.propriedadeId) {
|
||||||
|
const propriedade = await this.prisma.propriedade.findUnique({
|
||||||
|
where: { id: updateAvaliacaoDto.propriedadeId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!propriedade) {
|
||||||
|
throw new BadRequestException('Propriedade não encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.avaliacao.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateAvaliacaoDto,
|
||||||
|
include: {
|
||||||
|
propriedade: {
|
||||||
|
select: { id: true, nome: true, codigoCar: true },
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: { id: true, nome: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number) {
|
||||||
|
await this.findOne(id);
|
||||||
|
await this.prisma.avaliacao.delete({ where: { id } });
|
||||||
|
return { message: 'Avaliação removida com sucesso' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async gerarDds(id: number) {
|
||||||
|
const avaliacao = await this.findOne(id);
|
||||||
|
|
||||||
|
// DDS generation logic
|
||||||
|
const propriedade = avaliacao.propriedade;
|
||||||
|
const empresa = propriedade.empresa;
|
||||||
|
|
||||||
|
const ddsData = {
|
||||||
|
referencia: avaliacao.referencia,
|
||||||
|
dataGeracao: new Date().toISOString(),
|
||||||
|
empresa: {
|
||||||
|
razaoSocial: empresa.razaoSocial,
|
||||||
|
cnpj: empresa.cnpj,
|
||||||
|
atividadeAgricola: empresa.atividadeAgricola,
|
||||||
|
},
|
||||||
|
propriedade: {
|
||||||
|
nome: propriedade.nome,
|
||||||
|
codigoCar: propriedade.codigoCar,
|
||||||
|
areaHa: propriedade.areaHa,
|
||||||
|
coordenadas: {
|
||||||
|
latitude: propriedade.latitude,
|
||||||
|
longitude: propriedade.longitude,
|
||||||
|
},
|
||||||
|
bioma: propriedade.bioma,
|
||||||
|
nivelRisco: propriedade.nivelRisco,
|
||||||
|
},
|
||||||
|
avaliacao: {
|
||||||
|
id: avaliacao.id,
|
||||||
|
status: avaliacao.status,
|
||||||
|
nivelRisco: avaliacao.nivelRisco,
|
||||||
|
dataAvaliacao: avaliacao.dataAvaliacao,
|
||||||
|
observacoes: avaliacao.observacoes,
|
||||||
|
},
|
||||||
|
eudr: {
|
||||||
|
conformidade: avaliacao.nivelRisco === 'BAIXO' || avaliacao.nivelRisco === 'MEDIO',
|
||||||
|
riscoDesmatamento: propriedade.flagDesmatamento || 'não verificado',
|
||||||
|
dataReferencia: '2020-12-31',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update avaliacao with DDS result
|
||||||
|
const updated = await this.prisma.avaliacao.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
resultadoDds: JSON.stringify(ddsData),
|
||||||
|
status: StatusAvaliacao.EM_ANALISE,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
propriedade: {
|
||||||
|
include: { empresa: true },
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: { id: true, nome: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
avaliacao: updated,
|
||||||
|
dds: ddsData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async submeter(id: number) {
|
||||||
|
const avaliacao = await this.findOne(id);
|
||||||
|
|
||||||
|
if (avaliacao.status === StatusAvaliacao.APROVADA) {
|
||||||
|
throw new BadRequestException('Avaliação já aprovada');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!avaliacao.resultadoDds) {
|
||||||
|
throw new BadRequestException('DDS ainda não foi gerado. Execute gerar-dds primeiro.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const euReference = `EU-${Date.now().toString(36).toUpperCase()}`;
|
||||||
|
|
||||||
|
return this.prisma.avaliacao.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: StatusAvaliacao.EM_ANALISE,
|
||||||
|
dataSubmissao: new Date(),
|
||||||
|
referenciaEu: euReference,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
propriedade: {
|
||||||
|
include: { empresa: true },
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: { id: true, nome: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async aprovar(id: number) {
|
||||||
|
await this.findOne(id);
|
||||||
|
|
||||||
|
return this.prisma.avaliacao.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: StatusAvaliacao.APROVADA,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async reprovar(id: number, motivo?: string) {
|
||||||
|
await this.findOne(id);
|
||||||
|
|
||||||
|
return this.prisma.avaliacao.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: StatusAvaliacao.REPROVADA,
|
||||||
|
observacoes: motivo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/src/avaliacoes/dto/create-avaliacao.dto.ts
Normal file
24
backend/src/avaliacoes/dto/create-avaliacao.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { IsNumber, IsOptional, IsEnum, IsString } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { StatusAvaliacao, NivelRisco } from '@prisma/client';
|
||||||
|
|
||||||
|
export class CreateAvaliacaoDto {
|
||||||
|
@ApiProperty({ example: 1, description: 'ID da propriedade' })
|
||||||
|
@IsNumber()
|
||||||
|
propriedadeId: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: StatusAvaliacao, default: 'RASCUNHO' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(StatusAvaliacao)
|
||||||
|
status?: StatusAvaliacao;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: NivelRisco, default: 'BAIXO' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(NivelRisco)
|
||||||
|
nivelRisco?: NivelRisco;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Observações sobre a avaliação' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
observacoes?: string;
|
||||||
|
}
|
||||||
4
backend/src/avaliacoes/dto/update-avaliacao.dto.ts
Normal file
4
backend/src/avaliacoes/dto/update-avaliacao.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateAvaliacaoDto } from './create-avaliacao.dto';
|
||||||
|
|
||||||
|
export class UpdateAvaliacaoDto extends PartialType(CreateAvaliacaoDto) {}
|
||||||
19
backend/src/dashboard/dashboard.controller.ts
Normal file
19
backend/src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { DashboardService } from './dashboard.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('dashboard')
|
||||||
|
@Controller('dashboard')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class DashboardController {
|
||||||
|
constructor(private dashboardService: DashboardService) {}
|
||||||
|
|
||||||
|
@Get('stats')
|
||||||
|
@ApiOperation({ summary: 'Obter estatísticas do dashboard' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Estatísticas retornadas' })
|
||||||
|
getStats() {
|
||||||
|
return this.dashboardService.getStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/dashboard/dashboard.module.ts
Normal file
9
backend/src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DashboardController } from './dashboard.controller';
|
||||||
|
import { DashboardService } from './dashboard.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DashboardController],
|
||||||
|
providers: [DashboardService],
|
||||||
|
})
|
||||||
|
export class DashboardModule {}
|
||||||
137
backend/src/dashboard/dashboard.service.ts
Normal file
137
backend/src/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DashboardService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getStats() {
|
||||||
|
// Get counts
|
||||||
|
const [
|
||||||
|
totalEmpresas,
|
||||||
|
totalPropriedades,
|
||||||
|
totalAvaliacoes,
|
||||||
|
totalDocumentos,
|
||||||
|
totalUsers,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.prisma.empresa.count({ where: { ativo: true } }),
|
||||||
|
this.prisma.propriedade.count({ where: { ativo: true } }),
|
||||||
|
this.prisma.avaliacao.count(),
|
||||||
|
this.prisma.documento.count(),
|
||||||
|
this.prisma.user.count({ where: { ativo: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Avaliacoes by status
|
||||||
|
const avaliacoesByStatus = await this.prisma.avaliacao.groupBy({
|
||||||
|
by: ['status'],
|
||||||
|
_count: { status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusCounts = {
|
||||||
|
RASCUNHO: 0,
|
||||||
|
EM_ANALISE: 0,
|
||||||
|
APROVADA: 0,
|
||||||
|
REPROVADA: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
avaliacoesByStatus.forEach((item) => {
|
||||||
|
statusCounts[item.status] = item._count.status;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Propriedades by nivel risco
|
||||||
|
const propriedadesByRisco = await this.prisma.propriedade.groupBy({
|
||||||
|
by: ['nivelRisco'],
|
||||||
|
_count: { nivelRisco: true },
|
||||||
|
where: { ativo: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const riscoCounts = {
|
||||||
|
BAIXO: 0,
|
||||||
|
MEDIO: 0,
|
||||||
|
ALTO: 0,
|
||||||
|
CRITICO: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
propriedadesByRisco.forEach((item) => {
|
||||||
|
riscoCounts[item.nivelRisco] = item._count.nivelRisco;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recent avaliacoes
|
||||||
|
const recentAvaliacoes = await this.prisma.avaliacao.findMany({
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
propriedade: {
|
||||||
|
select: { nome: true },
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: { nome: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recent empresas
|
||||||
|
const recentEmpresas = await this.prisma.empresa.findMany({
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
razaoSocial: true,
|
||||||
|
cnpj: true,
|
||||||
|
createdAt: true,
|
||||||
|
_count: {
|
||||||
|
select: { propriedades: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Monthly stats (last 6 months)
|
||||||
|
const sixMonthsAgo = new Date();
|
||||||
|
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
||||||
|
|
||||||
|
const monthlyAvaliacoes = await this.prisma.avaliacao.groupBy({
|
||||||
|
by: ['createdAt'],
|
||||||
|
_count: { id: true },
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: sixMonthsAgo },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group by month
|
||||||
|
const monthlyStats: Record<string, number> = {};
|
||||||
|
monthlyAvaliacoes.forEach((item) => {
|
||||||
|
const month = item.createdAt.toISOString().substring(0, 7); // YYYY-MM
|
||||||
|
monthlyStats[month] = (monthlyStats[month] || 0) + item._count.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totais: {
|
||||||
|
empresas: totalEmpresas,
|
||||||
|
propriedades: totalPropriedades,
|
||||||
|
avaliacoes: totalAvaliacoes,
|
||||||
|
documentos: totalDocumentos,
|
||||||
|
usuarios: totalUsers,
|
||||||
|
},
|
||||||
|
avaliacoesPorStatus: statusCounts,
|
||||||
|
propriedadesPorRisco: riscoCounts,
|
||||||
|
recentAvaliacoes: recentAvaliacoes.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
referencia: a.referencia,
|
||||||
|
status: a.status,
|
||||||
|
propriedade: a.propriedade.nome,
|
||||||
|
responsavel: a.user.nome,
|
||||||
|
createdAt: a.createdAt,
|
||||||
|
})),
|
||||||
|
recentEmpresas: recentEmpresas.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
razaoSocial: e.razaoSocial,
|
||||||
|
cnpj: e.cnpj,
|
||||||
|
propriedades: e._count.propriedades,
|
||||||
|
createdAt: e.createdAt,
|
||||||
|
})),
|
||||||
|
graficos: {
|
||||||
|
avaliacoesMensais: monthlyStats,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
98
backend/src/documentos/documentos.controller.ts
Normal file
98
backend/src/documentos/documentos.controller.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
ParseIntPipe,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
|
Request,
|
||||||
|
Res,
|
||||||
|
StreamableFile,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiConsumes, ApiQuery, ApiBody } from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { DocumentosService } from './documentos.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('documentos')
|
||||||
|
@Controller('documentos')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class DocumentosController {
|
||||||
|
constructor(private documentosService: DocumentosService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Listar todos os documentos' })
|
||||||
|
@ApiQuery({ name: 'propriedadeId', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'avaliacaoId', required: false, type: Number })
|
||||||
|
@ApiResponse({ status: 200, description: 'Lista de documentos' })
|
||||||
|
findAll(
|
||||||
|
@Query('propriedadeId') propriedadeId?: number,
|
||||||
|
@Query('avaliacaoId') avaliacaoId?: number,
|
||||||
|
) {
|
||||||
|
return this.documentosService.findAll(propriedadeId, avaliacaoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Buscar documento por ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Documento encontrado' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Documento não encontrado' })
|
||||||
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.documentosService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/download')
|
||||||
|
@ApiOperation({ summary: 'Download do arquivo' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Arquivo retornado' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Arquivo não encontrado' })
|
||||||
|
async download(@Param('id', ParseIntPipe) id: number, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const fileInfo = await this.documentosService.getFilePath(id);
|
||||||
|
|
||||||
|
const file = fs.createReadStream(fileInfo.path);
|
||||||
|
|
||||||
|
res.set({
|
||||||
|
'Content-Type': fileInfo.mimeType || 'application/octet-stream',
|
||||||
|
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileInfo.nome)}"`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new StreamableFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('upload')
|
||||||
|
@ApiOperation({ summary: 'Upload de documento' })
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file: { type: 'string', format: 'binary' },
|
||||||
|
propriedadeId: { type: 'number' },
|
||||||
|
avaliacaoId: { type: 'number' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 201, description: 'Documento enviado com sucesso' })
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
async upload(
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Request() req: any,
|
||||||
|
@Query('propriedadeId') propriedadeId?: number,
|
||||||
|
@Query('avaliacaoId') avaliacaoId?: number,
|
||||||
|
) {
|
||||||
|
return this.documentosService.create(file, req.user.sub, propriedadeId, avaliacaoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Remover documento' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Documento removido' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Documento não encontrado' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.documentosService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/documentos/documentos.module.ts
Normal file
10
backend/src/documentos/documentos.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DocumentosController } from './documentos.controller';
|
||||||
|
import { DocumentosService } from './documentos.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DocumentosController],
|
||||||
|
providers: [DocumentosService],
|
||||||
|
exports: [DocumentosService],
|
||||||
|
})
|
||||||
|
export class DocumentosModule {}
|
||||||
168
backend/src/documentos/documentos.service.ts
Normal file
168
backend/src/documentos/documentos.service.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
interface CreateDocumentoData {
|
||||||
|
nome: string;
|
||||||
|
tipo?: string;
|
||||||
|
tamanho?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
path: string;
|
||||||
|
propriedadeId?: number;
|
||||||
|
avaliacaoId?: number;
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DocumentosService {
|
||||||
|
private readonly uploadPath = path.join(process.cwd(), 'uploads');
|
||||||
|
|
||||||
|
constructor(private prisma: PrismaService) {
|
||||||
|
// Ensure uploads directory exists
|
||||||
|
if (!fs.existsSync(this.uploadPath)) {
|
||||||
|
fs.mkdirSync(this.uploadPath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(propriedadeId?: number, avaliacaoId?: number) {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (propriedadeId) {
|
||||||
|
where.propriedadeId = propriedadeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avaliacaoId) {
|
||||||
|
where.avaliacaoId = avaliacaoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.documento.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
propriedade: {
|
||||||
|
select: { id: true, nome: true },
|
||||||
|
},
|
||||||
|
avaliacao: {
|
||||||
|
select: { id: true, referencia: true },
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: { id: true, nome: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: number) {
|
||||||
|
const documento = await this.prisma.documento.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
propriedade: {
|
||||||
|
select: { id: true, nome: true },
|
||||||
|
},
|
||||||
|
avaliacao: {
|
||||||
|
select: { id: true, referencia: true },
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: { id: true, nome: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!documento) {
|
||||||
|
throw new NotFoundException('Documento não encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return documento;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
file: Express.Multer.File,
|
||||||
|
userId: number,
|
||||||
|
propriedadeId?: number,
|
||||||
|
avaliacaoId?: number,
|
||||||
|
) {
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('Arquivo é obrigatório');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate associations
|
||||||
|
if (propriedadeId) {
|
||||||
|
const propriedade = await this.prisma.propriedade.findUnique({
|
||||||
|
where: { id: propriedadeId },
|
||||||
|
});
|
||||||
|
if (!propriedade) {
|
||||||
|
throw new BadRequestException('Propriedade não encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avaliacaoId) {
|
||||||
|
const avaliacao = await this.prisma.avaliacao.findUnique({
|
||||||
|
where: { id: avaliacaoId },
|
||||||
|
});
|
||||||
|
if (!avaliacao) {
|
||||||
|
throw new BadRequestException('Avaliação não encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
const filename = `${timestamp}-${Math.random().toString(36).substring(7)}${ext}`;
|
||||||
|
const filePath = path.join(this.uploadPath, filename);
|
||||||
|
|
||||||
|
// Save file
|
||||||
|
fs.writeFileSync(filePath, file.buffer);
|
||||||
|
|
||||||
|
// Create database record
|
||||||
|
return this.prisma.documento.create({
|
||||||
|
data: {
|
||||||
|
nome: file.originalname,
|
||||||
|
tipo: ext.replace('.', '').toUpperCase(),
|
||||||
|
tamanho: file.size,
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
path: filePath,
|
||||||
|
propriedadeId,
|
||||||
|
avaliacaoId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
propriedade: {
|
||||||
|
select: { id: true, nome: true },
|
||||||
|
},
|
||||||
|
avaliacao: {
|
||||||
|
select: { id: true, referencia: true },
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: { id: true, nome: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number) {
|
||||||
|
const documento = await this.findOne(id);
|
||||||
|
|
||||||
|
// Delete file from filesystem
|
||||||
|
if (fs.existsSync(documento.path)) {
|
||||||
|
fs.unlinkSync(documento.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.documento.delete({ where: { id } });
|
||||||
|
return { message: 'Documento removido com sucesso' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFilePath(id: number): Promise<{ path: string; nome: string; mimeType: string | null }> {
|
||||||
|
const documento = await this.findOne(id);
|
||||||
|
|
||||||
|
if (!fs.existsSync(documento.path)) {
|
||||||
|
throw new NotFoundException('Arquivo não encontrado no servidor');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: documento.path,
|
||||||
|
nome: documento.nome,
|
||||||
|
mimeType: documento.mimeType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
69
backend/src/empresas/dto/create-empresa.dto.ts
Normal file
69
backend/src/empresas/dto/create-empresa.dto.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { IsString, IsOptional, IsBoolean, Matches, IsEmail, MinLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateEmpresaDto {
|
||||||
|
@ApiProperty({ example: 'Fazenda Bom Sucesso Ltda' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3, { message: 'Razão social deve ter no mínimo 3 caracteres' })
|
||||||
|
razaoSocial: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Fazenda Bom Sucesso' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nomeFantasia?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '12.345.678/0001-90' })
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}$/, { message: 'CNPJ inválido. Use o formato XX.XXX.XXX/XXXX-XX' })
|
||||||
|
cnpj: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '123.456.789.012' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
inscricaoEstadual?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Soja, Milho, Café' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
atividadeAgricola?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Rodovia BR-050, Km 45' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
endereco?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Uberlândia' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cidade?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'MG' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
estado?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '38400-000' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cep?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '(34) 99999-0000' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
telefone?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'contato@fazenda.com.br' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail({}, { message: 'Email inválido' })
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'João da Silva' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
responsavel?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
ativo?: boolean;
|
||||||
|
}
|
||||||
4
backend/src/empresas/dto/update-empresa.dto.ts
Normal file
4
backend/src/empresas/dto/update-empresa.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateEmpresaDto } from './create-empresa.dto';
|
||||||
|
|
||||||
|
export class UpdateEmpresaDto extends PartialType(CreateEmpresaDto) {}
|
||||||
61
backend/src/empresas/empresas.controller.ts
Normal file
61
backend/src/empresas/empresas.controller.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Controller, Get, Post, Put, Delete, Patch, Body, Param, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||||
|
import { EmpresasService } from './empresas.service';
|
||||||
|
import { CreateEmpresaDto } from './dto/create-empresa.dto';
|
||||||
|
import { UpdateEmpresaDto } from './dto/update-empresa.dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('empresas')
|
||||||
|
@Controller('empresas')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class EmpresasController {
|
||||||
|
constructor(private empresasService: EmpresasService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Listar todas as empresas' })
|
||||||
|
@ApiQuery({ name: 'includeInactive', required: false, type: Boolean })
|
||||||
|
@ApiResponse({ status: 200, description: 'Lista de empresas' })
|
||||||
|
findAll(@Query('includeInactive') includeInactive?: boolean) {
|
||||||
|
return this.empresasService.findAll(includeInactive);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Buscar empresa por ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Empresa encontrada' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Empresa não encontrada' })
|
||||||
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.empresasService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Criar nova empresa' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Empresa criada com sucesso' })
|
||||||
|
@ApiResponse({ status: 409, description: 'CNPJ já cadastrado' })
|
||||||
|
create(@Body() createEmpresaDto: CreateEmpresaDto) {
|
||||||
|
return this.empresasService.create(createEmpresaDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@ApiOperation({ summary: 'Atualizar empresa' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Empresa atualizada' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Empresa não encontrada' })
|
||||||
|
update(@Param('id', ParseIntPipe) id: number, @Body() updateEmpresaDto: UpdateEmpresaDto) {
|
||||||
|
return this.empresasService.update(id, updateEmpresaDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Remover empresa' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Empresa removida' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Empresa não encontrada' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.empresasService.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/toggle-active')
|
||||||
|
@ApiOperation({ summary: 'Ativar/Desativar empresa' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Status alterado' })
|
||||||
|
toggleActive(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.empresasService.toggleActive(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/empresas/empresas.module.ts
Normal file
10
backend/src/empresas/empresas.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { EmpresasController } from './empresas.controller';
|
||||||
|
import { EmpresasService } from './empresas.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [EmpresasController],
|
||||||
|
providers: [EmpresasService],
|
||||||
|
exports: [EmpresasService],
|
||||||
|
})
|
||||||
|
export class EmpresasModule {}
|
||||||
107
backend/src/empresas/empresas.service.ts
Normal file
107
backend/src/empresas/empresas.service.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateEmpresaDto } from './dto/create-empresa.dto';
|
||||||
|
import { UpdateEmpresaDto } from './dto/update-empresa.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmpresasService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll(includeInactive = false) {
|
||||||
|
return this.prisma.empresa.findMany({
|
||||||
|
where: includeInactive ? {} : { ativo: true },
|
||||||
|
include: {
|
||||||
|
propriedades: {
|
||||||
|
where: { ativo: true },
|
||||||
|
select: { id: true, nome: true },
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { propriedades: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: number) {
|
||||||
|
const empresa = await this.prisma.empresa.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
propriedades: {
|
||||||
|
where: { ativo: true },
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { propriedades: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!empresa) {
|
||||||
|
throw new NotFoundException('Empresa não encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return empresa;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(createEmpresaDto: CreateEmpresaDto) {
|
||||||
|
const existingCnpj = await this.prisma.empresa.findUnique({
|
||||||
|
where: { cnpj: createEmpresaDto.cnpj },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCnpj) {
|
||||||
|
throw new ConflictException('CNPJ já cadastrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.empresa.create({
|
||||||
|
data: createEmpresaDto,
|
||||||
|
include: {
|
||||||
|
propriedades: true,
|
||||||
|
_count: {
|
||||||
|
select: { propriedades: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, updateEmpresaDto: UpdateEmpresaDto) {
|
||||||
|
await this.findOne(id);
|
||||||
|
|
||||||
|
if (updateEmpresaDto.cnpj) {
|
||||||
|
const existingCnpj = await this.prisma.empresa.findFirst({
|
||||||
|
where: {
|
||||||
|
cnpj: updateEmpresaDto.cnpj,
|
||||||
|
NOT: { id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCnpj) {
|
||||||
|
throw new ConflictException('CNPJ já cadastrado');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.empresa.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateEmpresaDto,
|
||||||
|
include: {
|
||||||
|
propriedades: true,
|
||||||
|
_count: {
|
||||||
|
select: { propriedades: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number) {
|
||||||
|
await this.findOne(id);
|
||||||
|
await this.prisma.empresa.delete({ where: { id } });
|
||||||
|
return { message: 'Empresa removida com sucesso' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleActive(id: number) {
|
||||||
|
const empresa = await this.findOne(id);
|
||||||
|
return this.prisma.empresa.update({
|
||||||
|
where: { id },
|
||||||
|
data: { ativo: !empresa.ativo },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
58
backend/src/eudr-api/dto/amend-dds.dto.ts
Normal file
58
backend/src/eudr-api/dto/amend-dds.dto.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { IsString, IsOptional, IsBoolean, ValidateNested, IsArray, IsUUID } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { CommodityDto, OperatorAddressDto, ReferencedDdsDto } from './common.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for amending an existing DDS
|
||||||
|
* Note: Activity type cannot be changed
|
||||||
|
*/
|
||||||
|
export class AmendDdsRequestDto {
|
||||||
|
@IsUUID()
|
||||||
|
uuid: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
confidentialityFlag: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
companyInternalReference?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required when amending as Authorized Representative (V2)
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => OperatorAddressDto)
|
||||||
|
onBehalfOfOperator?: OperatorAddressDto;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CommodityDto)
|
||||||
|
commodities: CommodityDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ReferencedDdsDto)
|
||||||
|
referencedDds?: ReferencedDdsDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for DDS amendment
|
||||||
|
*/
|
||||||
|
export class AmendDdsResponseDto {
|
||||||
|
/**
|
||||||
|
* HTTP status code (200 = success)
|
||||||
|
*/
|
||||||
|
httpStatus: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Success indicator
|
||||||
|
*/
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw SOAP response for debugging
|
||||||
|
*/
|
||||||
|
rawResponse?: string;
|
||||||
|
}
|
||||||
196
backend/src/eudr-api/dto/common.dto.ts
Normal file
196
backend/src/eudr-api/dto/common.dto.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { IsString, IsOptional, IsNumber, IsEnum, IsBoolean, Min, Max, ValidateNested, IsArray } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity types allowed in EUDR DDS
|
||||||
|
*/
|
||||||
|
export enum ActivityType {
|
||||||
|
IMPORT = 'IMPORT',
|
||||||
|
EXPORT = 'EXPORT',
|
||||||
|
DOMESTIC = 'DOMESTIC',
|
||||||
|
TRADE = 'TRADE',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DDS Status values returned by the API
|
||||||
|
*/
|
||||||
|
export enum DDSStatus {
|
||||||
|
SUBMITTED = 'SUBMITTED',
|
||||||
|
AVAILABLE = 'AVAILABLE',
|
||||||
|
REJECTED = 'REJECTED',
|
||||||
|
WITHDRAWN = 'WITHDRAWN',
|
||||||
|
CANCELLED = 'CANCELLED',
|
||||||
|
ARCHIVED = 'ARCHIVED',
|
||||||
|
EXPIRED = 'EXPIRED',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supplementary Unit Types for measurements
|
||||||
|
*/
|
||||||
|
export enum SupplementaryUnitType {
|
||||||
|
KSD = 'KSD', // Kilogram of substance 90% dry
|
||||||
|
MTK = 'MTK', // Square metre
|
||||||
|
MTQ = 'MTQ', // Cubic metre
|
||||||
|
MTR = 'MTR', // Metre
|
||||||
|
NAR = 'NAR', // Number of items
|
||||||
|
NPR = 'NPR', // Number of pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator address for Authorized Representative submissions
|
||||||
|
*/
|
||||||
|
export class OperatorAddressDto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
streetNumber: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
postalCode: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
city: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
countryCode: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
eori?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeoLocation data for a production place
|
||||||
|
*/
|
||||||
|
export class GeoLocationDto {
|
||||||
|
@IsString()
|
||||||
|
geoJson: string; // GeoJSON FeatureCollection as string
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.1)
|
||||||
|
@Max(4)
|
||||||
|
area?: number; // Area in hectares (for points)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Producer information within a commodity
|
||||||
|
*/
|
||||||
|
export class ProducerDto {
|
||||||
|
@IsString()
|
||||||
|
countryCode: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
producerName?: string;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => GeoLocationDto)
|
||||||
|
geoLocation: GeoLocationDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Species information for timber products
|
||||||
|
*/
|
||||||
|
export class SpeciesInfoDto {
|
||||||
|
@IsString()
|
||||||
|
scientificName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
commonName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commodity data for DDS submission
|
||||||
|
*/
|
||||||
|
export class CommodityDto {
|
||||||
|
@IsString()
|
||||||
|
hsCode: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
netMass?: number; // Required for IMPORT/EXPORT
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(50)
|
||||||
|
percentageEstimateOrDeviation?: number; // Required for DOMESTIC/TRADE (0-25 typical)
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(SupplementaryUnitType)
|
||||||
|
supplementaryUnitType?: SupplementaryUnitType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
supplementaryUnitValue?: number;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ProducerDto)
|
||||||
|
producers: ProducerDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => SpeciesInfoDto)
|
||||||
|
speciesInfo?: SpeciesInfoDto[]; // Required for timber
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
referencedDdsNumbers?: string[]; // Reference numbers of upstream DDS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referenced DDS with verification number
|
||||||
|
*/
|
||||||
|
export class ReferencedDdsDto {
|
||||||
|
@IsString()
|
||||||
|
referenceNumber: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
verificationNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EUDR API Error response
|
||||||
|
*/
|
||||||
|
export class EudrApiErrorDto {
|
||||||
|
@IsString()
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
field?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base response for EUDR API calls
|
||||||
|
*/
|
||||||
|
export class EudrApiResponseDto<T = any> {
|
||||||
|
@IsBoolean()
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
data?: T;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => EudrApiErrorDto)
|
||||||
|
errors?: EudrApiErrorDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
rawResponse?: string; // For debugging
|
||||||
|
}
|
||||||
86
backend/src/eudr-api/dto/get-dds-data.dto.ts
Normal file
86
backend/src/eudr-api/dto/get-dds-data.dto.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
import { ActivityType, CommodityDto, DDSStatus } from './common.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for getting full DDS data by identifiers
|
||||||
|
*/
|
||||||
|
export class GetDdsDataRequestDto {
|
||||||
|
@IsString()
|
||||||
|
referenceNumber: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
verificationNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for getting referenced DDS without verification number
|
||||||
|
* Uses the referenceDdsVerificationNumber from previous getStatementByIdentifiers call
|
||||||
|
*/
|
||||||
|
export class GetReferencedDdsRequestDto {
|
||||||
|
@IsString()
|
||||||
|
referenceNumber: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
referenceDdsVerificationNumber: string; // Security number from previous call
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referenced DDS info with security number for chain traversal
|
||||||
|
*/
|
||||||
|
export class ReferencedDdsInfoDto {
|
||||||
|
referenceNumber: string;
|
||||||
|
referenceDdsVerificationNumber: string; // Use this to get the referenced DDS data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full DDS data returned by getStatementByIdentifiers
|
||||||
|
*/
|
||||||
|
export class DdsDataDto {
|
||||||
|
referenceNumber: string;
|
||||||
|
verificationNumber: string;
|
||||||
|
status: DDSStatus;
|
||||||
|
activityType: ActivityType;
|
||||||
|
availabilityDate?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator info
|
||||||
|
*/
|
||||||
|
operatorName?: string;
|
||||||
|
operatorCountry?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commodities with full details
|
||||||
|
*/
|
||||||
|
commodities: CommodityDto[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeoLocation visibility flag
|
||||||
|
*/
|
||||||
|
showGeoLocation: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referenced DDS in the supply chain
|
||||||
|
* Each includes a security number for recursive retrieval
|
||||||
|
*/
|
||||||
|
referencedDds: ReferencedDdsInfoDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for getStatementByIdentifiers / getReferencedDds
|
||||||
|
*/
|
||||||
|
export class GetDdsDataResponseDto {
|
||||||
|
/**
|
||||||
|
* HTTP status code
|
||||||
|
*/
|
||||||
|
httpStatus: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full DDS data
|
||||||
|
*/
|
||||||
|
ddsData?: DdsDataDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw SOAP response for debugging
|
||||||
|
*/
|
||||||
|
rawResponse?: string;
|
||||||
|
}
|
||||||
54
backend/src/eudr-api/dto/get-dds-info.dto.ts
Normal file
54
backend/src/eudr-api/dto/get-dds-info.dto.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { IsString, IsOptional, IsArray, IsUUID } from 'class-validator';
|
||||||
|
import { DDSStatus } from './common.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for getting DDS info by UUID(s)
|
||||||
|
*/
|
||||||
|
export class GetDdsInfoByUuidRequestDto {
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
uuids: string[]; // Max 100 UUIDs per request
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for getting DDS info by internal reference
|
||||||
|
*/
|
||||||
|
export class GetDdsInfoByReferenceRequestDto {
|
||||||
|
@IsString()
|
||||||
|
internalReference: string; // Min 3, max 50 chars - partial matching, case insensitive
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single DDS info returned by the API
|
||||||
|
*/
|
||||||
|
export class DdsInfoDto {
|
||||||
|
uuid: string;
|
||||||
|
status: DDSStatus;
|
||||||
|
referenceNumber?: string; // Only available when status is AVAILABLE/ARCHIVED/EXPIRED/WITHDRAWN
|
||||||
|
verificationNumber?: string;
|
||||||
|
internalReference?: string;
|
||||||
|
caMessage?: string; // Communication from Competent Authority
|
||||||
|
rejectionReason?: string; // If status is REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for getDDSInfo
|
||||||
|
*/
|
||||||
|
export class GetDdsInfoResponseDto {
|
||||||
|
/**
|
||||||
|
* HTTP status code
|
||||||
|
*/
|
||||||
|
httpStatus: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of DDS info records
|
||||||
|
* - Max 100 when queried by UUID
|
||||||
|
* - Max 1000 when queried by internal reference
|
||||||
|
*/
|
||||||
|
ddsInfoList: DdsInfoDto[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw SOAP response for debugging
|
||||||
|
*/
|
||||||
|
rawResponse?: string;
|
||||||
|
}
|
||||||
7
backend/src/eudr-api/dto/index.ts
Normal file
7
backend/src/eudr-api/dto/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Export all DTOs
|
||||||
|
export * from './submit-dds.dto';
|
||||||
|
export * from './amend-dds.dto';
|
||||||
|
export * from './retract-dds.dto';
|
||||||
|
export * from './get-dds-info.dto';
|
||||||
|
export * from './get-dds-data.dto';
|
||||||
|
export * from './common.dto';
|
||||||
31
backend/src/eudr-api/dto/retract-dds.dto.ts
Normal file
31
backend/src/eudr-api/dto/retract-dds.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for retracting (cancelling/withdrawing) a DDS
|
||||||
|
* - SUBMITTED status: Cancels the DDS
|
||||||
|
* - AVAILABLE status: Withdraws the DDS
|
||||||
|
*/
|
||||||
|
export class RetractDdsRequestDto {
|
||||||
|
@IsUUID()
|
||||||
|
uuid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for DDS retraction
|
||||||
|
*/
|
||||||
|
export class RetractDdsResponseDto {
|
||||||
|
/**
|
||||||
|
* HTTP status code (200 = success)
|
||||||
|
*/
|
||||||
|
httpStatus: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Success indicator
|
||||||
|
*/
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw SOAP response for debugging
|
||||||
|
*/
|
||||||
|
rawResponse?: string;
|
||||||
|
}
|
||||||
60
backend/src/eudr-api/dto/submit-dds.dto.ts
Normal file
60
backend/src/eudr-api/dto/submit-dds.dto.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { IsString, IsOptional, IsEnum, IsBoolean, ValidateNested, IsArray } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ActivityType, CommodityDto, OperatorAddressDto, ReferencedDdsDto } from './common.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for submitting a new DDS
|
||||||
|
*/
|
||||||
|
export class SubmitDdsRequestDto {
|
||||||
|
@IsEnum(ActivityType)
|
||||||
|
activityType: ActivityType;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
confidentialityFlag: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
companyInternalReference?: string; // Max 50 chars
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required when submitting as Authorized Representative
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => OperatorAddressDto)
|
||||||
|
onBehalfOfOperator?: OperatorAddressDto;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CommodityDto)
|
||||||
|
commodities: CommodityDto[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referenced DDS from upstream supply chain
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ReferencedDdsDto)
|
||||||
|
referencedDds?: ReferencedDdsDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for DDS submission
|
||||||
|
*/
|
||||||
|
export class SubmitDdsResponseDto {
|
||||||
|
/**
|
||||||
|
* UUID assigned by EUDR system - use this for subsequent operations
|
||||||
|
*/
|
||||||
|
uuid: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP status code from the API
|
||||||
|
*/
|
||||||
|
httpStatus: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw SOAP response for debugging
|
||||||
|
*/
|
||||||
|
rawResponse?: string;
|
||||||
|
}
|
||||||
137
backend/src/eudr-api/eudr-api.controller.ts
Normal file
137
backend/src/eudr-api/eudr-api.controller.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { Controller, Get, Post, Body, HttpCode, HttpStatus, UseGuards } from '@nestjs/common';
|
||||||
|
import { EudrApiService } from './eudr-api.service';
|
||||||
|
import { SubmitDdsRequestDto } from './dto/submit-dds.dto';
|
||||||
|
import { AmendDdsRequestDto } from './dto/amend-dds.dto';
|
||||||
|
import { RetractDdsRequestDto } from './dto/retract-dds.dto';
|
||||||
|
import { GetDdsInfoByUuidRequestDto, GetDdsInfoByReferenceRequestDto } from './dto/get-dds-info.dto';
|
||||||
|
import { GetDdsDataRequestDto, GetReferencedDdsRequestDto } from './dto/get-dds-data.dto';
|
||||||
|
// import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; // Uncomment when auth is ready
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EUDR API Controller
|
||||||
|
*
|
||||||
|
* Internal endpoints for DuOrigin to interact with the EU EUDR system.
|
||||||
|
* These endpoints act as a proxy/adapter between our REST API and the EUDR SOAP API.
|
||||||
|
*/
|
||||||
|
@Controller('api/eudr')
|
||||||
|
// @UseGuards(JwtAuthGuard) // Uncomment when auth is ready
|
||||||
|
export class EudrApiController {
|
||||||
|
constructor(private readonly eudrApiService: EudrApiService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get EUDR API configuration status
|
||||||
|
*/
|
||||||
|
@Get('status')
|
||||||
|
async getStatus() {
|
||||||
|
const envInfo = this.eudrApiService.getEnvironmentInfo();
|
||||||
|
const isConfigured = this.eudrApiService.isConfigured();
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured: isConfigured,
|
||||||
|
environment: envInfo.isProduction ? 'PRODUCTION' : 'ACCEPTANCE',
|
||||||
|
wsClientId: envInfo.wsClientId,
|
||||||
|
baseUrl: envInfo.baseUrl,
|
||||||
|
services: {
|
||||||
|
echo: !envInfo.isProduction,
|
||||||
|
submit: true,
|
||||||
|
amend: true,
|
||||||
|
retract: true,
|
||||||
|
getDdsInfo: true,
|
||||||
|
getDdsData: true,
|
||||||
|
getReferencedDds: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CF1 - Test connection and authentication (ACCEPTANCE only)
|
||||||
|
*/
|
||||||
|
@Post('echo')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async testEcho(@Body() body: { message?: string }) {
|
||||||
|
return this.eudrApiService.testEcho(body?.message || 'DuOrigin Echo Test');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CF2 - Submit a new DDS
|
||||||
|
*
|
||||||
|
* Submits a Due Diligence Statement to the EUDR system.
|
||||||
|
* Returns the UUID assigned by the system for tracking.
|
||||||
|
*/
|
||||||
|
@Post('submit-dds')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async submitDds(@Body() request: SubmitDdsRequestDto) {
|
||||||
|
return this.eudrApiService.submitDds(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CF5 - Amend an existing DDS
|
||||||
|
*
|
||||||
|
* Modifies a DDS that is in AVAILABLE status.
|
||||||
|
* Note: Activity type cannot be changed.
|
||||||
|
*/
|
||||||
|
@Post('amend-dds')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async amendDds(@Body() request: AmendDdsRequestDto) {
|
||||||
|
return this.eudrApiService.amendDds(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CF6 - Retract a DDS
|
||||||
|
*
|
||||||
|
* Cancels (if SUBMITTED) or withdraws (if AVAILABLE) a DDS.
|
||||||
|
*/
|
||||||
|
@Post('retract-dds')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async retractDds(@Body() request: RetractDdsRequestDto) {
|
||||||
|
return this.eudrApiService.retractDds(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CF3 - Get DDS info by UUID(s)
|
||||||
|
*
|
||||||
|
* Retrieves status and reference numbers for submitted DDS.
|
||||||
|
* Max 100 UUIDs per request.
|
||||||
|
*/
|
||||||
|
@Post('get-dds-info')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getDdsInfoByUuid(@Body() request: GetDdsInfoByUuidRequestDto) {
|
||||||
|
return this.eudrApiService.getDdsInfoByUuid(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CF3 - Get DDS info by internal reference
|
||||||
|
*
|
||||||
|
* Retrieves DDS matching the internal reference (partial match).
|
||||||
|
* Max 1000 results.
|
||||||
|
*/
|
||||||
|
@Post('get-dds-info-by-reference')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getDdsInfoByReference(@Body() request: GetDdsInfoByReferenceRequestDto) {
|
||||||
|
return this.eudrApiService.getDdsInfoByReference(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CF7 - Get full DDS data
|
||||||
|
*
|
||||||
|
* Retrieves complete DDS data by reference and verification numbers.
|
||||||
|
* Use this to get DDS from other operators in the supply chain.
|
||||||
|
*/
|
||||||
|
@Post('get-dds-data')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getDdsData(@Body() request: GetDdsDataRequestDto) {
|
||||||
|
return this.eudrApiService.getDdsData(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CF7 - Get referenced DDS in supply chain
|
||||||
|
*
|
||||||
|
* Retrieves DDS data without verification number.
|
||||||
|
* Uses the security number from previous getDdsData call.
|
||||||
|
*/
|
||||||
|
@Post('get-referenced-dds')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getReferencedDds(@Body() request: GetReferencedDdsRequestDto) {
|
||||||
|
return this.eudrApiService.getReferencedDds(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/eudr-api/eudr-api.module.ts
Normal file
12
backend/src/eudr-api/eudr-api.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { EudrApiService } from './eudr-api.service';
|
||||||
|
import { EudrApiController } from './eudr-api.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
controllers: [EudrApiController],
|
||||||
|
providers: [EudrApiService],
|
||||||
|
exports: [EudrApiService],
|
||||||
|
})
|
||||||
|
export class EudrApiModule {}
|
||||||
584
backend/src/eudr-api/eudr-api.service.ts
Normal file
584
backend/src/eudr-api/eudr-api.service.ts
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import {
|
||||||
|
buildSoapEnvelope,
|
||||||
|
buildEchoRequest,
|
||||||
|
buildSubmitDdsRequest,
|
||||||
|
buildGetDdsInfoRequest,
|
||||||
|
buildGetDdsInfoByRefRequest,
|
||||||
|
buildRetractDdsRequest,
|
||||||
|
buildGetStatementByIdentifiersRequest,
|
||||||
|
buildGetReferencedDdsRequest,
|
||||||
|
} from './soap/xml-builder';
|
||||||
|
import {
|
||||||
|
parseSoapFault,
|
||||||
|
parseEudrErrors,
|
||||||
|
parseEchoResponse,
|
||||||
|
parseSubmitDdsResponse,
|
||||||
|
parseGetDdsInfoResponse,
|
||||||
|
parseRetractDdsResponse,
|
||||||
|
parseAmendDdsResponse,
|
||||||
|
parseGetStatementByIdentifiersResponse,
|
||||||
|
isSuccessResponse,
|
||||||
|
} from './soap/xml-parser';
|
||||||
|
import { EudrApiErrorDto, EudrApiResponseDto, ActivityType } from './dto/common.dto';
|
||||||
|
import { SubmitDdsRequestDto, SubmitDdsResponseDto } from './dto/submit-dds.dto';
|
||||||
|
import { AmendDdsRequestDto, AmendDdsResponseDto } from './dto/amend-dds.dto';
|
||||||
|
import { RetractDdsRequestDto, RetractDdsResponseDto } from './dto/retract-dds.dto';
|
||||||
|
import {
|
||||||
|
GetDdsInfoByUuidRequestDto,
|
||||||
|
GetDdsInfoByReferenceRequestDto,
|
||||||
|
GetDdsInfoResponseDto,
|
||||||
|
DdsInfoDto,
|
||||||
|
} from './dto/get-dds-info.dto';
|
||||||
|
import {
|
||||||
|
GetDdsDataRequestDto,
|
||||||
|
GetReferencedDdsRequestDto,
|
||||||
|
GetDdsDataResponseDto,
|
||||||
|
} from './dto/get-dds-data.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EUDR API Service
|
||||||
|
*
|
||||||
|
* Provides SOAP client interface to the EU EUDR system for DDS management.
|
||||||
|
* Supports both ACCEPTANCE (test) and PRODUCTION environments.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EudrApiService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(EudrApiService.name);
|
||||||
|
private httpClient: AxiosInstance;
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
private baseUrl: string;
|
||||||
|
private wsClientId: string;
|
||||||
|
private username: string;
|
||||||
|
private authKey: string;
|
||||||
|
private isProduction: boolean;
|
||||||
|
|
||||||
|
// Service endpoints
|
||||||
|
private readonly ENDPOINTS = {
|
||||||
|
submission: '/services/EudrSubmissionServiceV2',
|
||||||
|
retrieval: '/services/EudrRetrievalServiceV2',
|
||||||
|
echo: '/services/EudrEchoService',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default URLs
|
||||||
|
private static readonly ACCEPTANCE_URL = 'https://acceptance.eudr.webcloud.ec.europa.eu/tracesnt';
|
||||||
|
private static readonly PRODUCTION_URL = 'https://webgate.ec.europa.eu/tracesnt';
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
// Load configuration
|
||||||
|
this.baseUrl = this.configService.get<string>('EUDR_API_URL') || EudrApiService.ACCEPTANCE_URL;
|
||||||
|
this.wsClientId = this.configService.get<string>('EUDR_WS_CLIENT_ID') || 'eudr-test';
|
||||||
|
this.username = this.configService.get<string>('EUDR_USERNAME') || '';
|
||||||
|
this.authKey = this.configService.get<string>('EUDR_AUTH_KEY') || '';
|
||||||
|
this.isProduction = this.baseUrl.includes('webgate.ec.europa.eu');
|
||||||
|
|
||||||
|
// Create HTTP client
|
||||||
|
this.httpClient = axios.create({
|
||||||
|
baseURL: this.baseUrl,
|
||||||
|
timeout: 60000, // 60 seconds timeout
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/xml; charset=utf-8',
|
||||||
|
'SOAPAction': '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`EUDR API Service initialized - Environment: ${this.isProduction ? 'PRODUCTION' : 'ACCEPTANCE'}`);
|
||||||
|
this.logger.log(`Base URL: ${this.baseUrl}`);
|
||||||
|
this.logger.log(`WS Client ID: ${this.wsClientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if credentials are configured
|
||||||
|
*/
|
||||||
|
isConfigured(): boolean {
|
||||||
|
return !!(this.username && this.authKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current environment info
|
||||||
|
*/
|
||||||
|
getEnvironmentInfo(): { isProduction: boolean; baseUrl: string; wsClientId: string } {
|
||||||
|
return {
|
||||||
|
isProduction: this.isProduction,
|
||||||
|
baseUrl: this.baseUrl,
|
||||||
|
wsClientId: this.wsClientId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CF1 - Echo Test
|
||||||
|
* Tests connection and authentication to the EUDR system.
|
||||||
|
* Only available in ACCEPTANCE environment.
|
||||||
|
*/
|
||||||
|
async testEcho(message: string = 'DuOrigin Test'): Promise<EudrApiResponseDto<{ message: string }>> {
|
||||||
|
if (this.isProduction) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'NOT_AVAILABLE', message: 'Echo service not available in production' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'NOT_CONFIGURED', message: 'EUDR credentials not configured' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = buildEchoRequest(message, this.wsClientId);
|
||||||
|
const envelope = buildSoapEnvelope(this.username, this.authKey, body, 'v1');
|
||||||
|
|
||||||
|
const response = await this.httpClient.post(this.ENDPOINTS.echo, envelope, {
|
||||||
|
headers: { 'SOAPAction': 'testEcho' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseEchoResponse(response.data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { message: result.message || message },
|
||||||
|
rawResponse: response.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fault = parseSoapFault(response.data);
|
||||||
|
const errors = parseEudrErrors(response.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: fault ? [fault] : errors,
|
||||||
|
rawResponse: response.data,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Echo test failed', error.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'HTTP_ERROR', message: error.message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CF2 - Submit DDS
|
||||||
|
* Submits a new Due Diligence Statement to the EUDR system.
|
||||||
|
*/
|
||||||
|
async submitDds(request: SubmitDdsRequestDto): Promise<EudrApiResponseDto<SubmitDdsResponseDto>> {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'NOT_CONFIGURED', message: 'EUDR credentials not configured' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build request body
|
||||||
|
const body = buildSubmitDdsRequest(this.wsClientId, {
|
||||||
|
activityType: request.activityType,
|
||||||
|
confidentialityFlag: request.confidentialityFlag,
|
||||||
|
companyInternalReference: request.companyInternalReference,
|
||||||
|
onBehalfOfOperator: request.onBehalfOfOperator ? {
|
||||||
|
name: request.onBehalfOfOperator.name,
|
||||||
|
streetNumber: request.onBehalfOfOperator.streetNumber,
|
||||||
|
postalCode: request.onBehalfOfOperator.postalCode,
|
||||||
|
city: request.onBehalfOfOperator.city,
|
||||||
|
countryCode: request.onBehalfOfOperator.countryCode,
|
||||||
|
eori: request.onBehalfOfOperator.eori,
|
||||||
|
} : undefined,
|
||||||
|
commodities: request.commodities.map(c => ({
|
||||||
|
hsCode: c.hsCode,
|
||||||
|
description: c.description,
|
||||||
|
netMass: c.netMass,
|
||||||
|
percentageEstimateOrDeviation: c.percentageEstimateOrDeviation,
|
||||||
|
supplementaryUnitType: c.supplementaryUnitType,
|
||||||
|
supplementaryUnitValue: c.supplementaryUnitValue,
|
||||||
|
producers: c.producers.map(p => ({
|
||||||
|
countryCode: p.countryCode,
|
||||||
|
producerName: p.producerName,
|
||||||
|
geoLocation: {
|
||||||
|
geoJson: p.geoLocation.geoJson,
|
||||||
|
area: p.geoLocation.area,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
speciesInfo: c.speciesInfo,
|
||||||
|
referencedDdsNumbers: c.referencedDdsNumbers,
|
||||||
|
})),
|
||||||
|
referencedDds: request.referencedDds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelope = buildSoapEnvelope(this.username, this.authKey, body);
|
||||||
|
|
||||||
|
const response = await this.httpClient.post(this.ENDPOINTS.submission, envelope, {
|
||||||
|
headers: { 'SOAPAction': 'submitDDS' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseSubmitDdsResponse(response.data);
|
||||||
|
|
||||||
|
if (result.success && result.uuid) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
uuid: result.uuid,
|
||||||
|
httpStatus: response.status,
|
||||||
|
rawResponse: response.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fault = parseSoapFault(response.data);
|
||||||
|
const errors = parseEudrErrors(response.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: fault ? [fault] : (errors.length > 0 ? errors : [{ code: 'UNKNOWN', message: 'Failed to submit DDS' }]),
|
||||||
|
rawResponse: response.data,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Submit DDS failed', error.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'HTTP_ERROR', message: error.message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CF3 - Get DDS Info by UUID(s)
|
||||||
|
* Retrieves status and reference numbers for submitted DDS.
|
||||||
|
*/
|
||||||
|
async getDdsInfoByUuid(request: GetDdsInfoByUuidRequestDto): Promise<EudrApiResponseDto<GetDdsInfoResponseDto>> {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'NOT_CONFIGURED', message: 'EUDR credentials not configured' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.uuids.length > 100) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'LIMIT_EXCEEDED', message: 'Maximum 100 UUIDs per request' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = buildGetDdsInfoRequest(this.wsClientId, request.uuids);
|
||||||
|
const envelope = buildSoapEnvelope(this.username, this.authKey, body);
|
||||||
|
|
||||||
|
const response = await this.httpClient.post(this.ENDPOINTS.retrieval, envelope, {
|
||||||
|
headers: { 'SOAPAction': 'getDdsInfo' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ddsInfoList = parseGetDdsInfoResponse(response.data);
|
||||||
|
|
||||||
|
if (isSuccessResponse(response.data)) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
httpStatus: response.status,
|
||||||
|
ddsInfoList,
|
||||||
|
rawResponse: response.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fault = parseSoapFault(response.data);
|
||||||
|
const errors = parseEudrErrors(response.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: fault ? [fault] : errors,
|
||||||
|
rawResponse: response.data,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Get DDS Info failed', error.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'HTTP_ERROR', message: error.message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CF3 - Get DDS Info by Internal Reference
|
||||||
|
* Retrieves DDS info matching the internal reference (partial match, case insensitive).
|
||||||
|
*/
|
||||||
|
async getDdsInfoByReference(request: GetDdsInfoByReferenceRequestDto): Promise<EudrApiResponseDto<GetDdsInfoResponseDto>> {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'NOT_CONFIGURED', message: 'EUDR credentials not configured' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.internalReference.length < 3 || request.internalReference.length > 50) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'INVALID_REFERENCE', message: 'Internal reference must be 3-50 characters' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = buildGetDdsInfoByRefRequest(this.wsClientId, request.internalReference);
|
||||||
|
const envelope = buildSoapEnvelope(this.username, this.authKey, body);
|
||||||
|
|
||||||
|
const response = await this.httpClient.post(this.ENDPOINTS.retrieval, envelope, {
|
||||||
|
headers: { 'SOAPAction': 'GetDdsInfoByInternalReferenceNumber' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ddsInfoList = parseGetDdsInfoResponse(response.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
httpStatus: response.status,
|
||||||
|
ddsInfoList,
|
||||||
|
rawResponse: response.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Get DDS Info by Reference failed', error.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'HTTP_ERROR', message: error.message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CF5 - Amend DDS
|
||||||
|
* Amends an existing DDS in AVAILABLE status.
|
||||||
|
* Note: Activity type cannot be changed.
|
||||||
|
*/
|
||||||
|
async amendDds(request: AmendDdsRequestDto): Promise<EudrApiResponseDto<AmendDdsResponseDto>> {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'NOT_CONFIGURED', message: 'EUDR credentials not configured' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Amend uses similar structure to submit but with UUID
|
||||||
|
const body = `
|
||||||
|
<v21:AmendDDSRequest xmlns:v21="http://ec.europa.eu/tracesnt/eudr/v2.1">
|
||||||
|
<v4:WebServiceClientId xmlns:v4="http://ec.europa.eu/tracesnt/commons/v4">${this.wsClientId}</v4:WebServiceClientId>
|
||||||
|
<v21:UUID>${request.uuid}</v21:UUID>
|
||||||
|
<v21:ConfidentialityFlag>${request.confidentialityFlag}</v21:ConfidentialityFlag>
|
||||||
|
${request.companyInternalReference ? `<v21:CompanyInternalReference>${request.companyInternalReference}</v21:CompanyInternalReference>` : ''}
|
||||||
|
${request.commodities.map(c => `
|
||||||
|
<v21:Commodity>
|
||||||
|
<v21:HSCode>${c.hsCode}</v21:HSCode>
|
||||||
|
<v21:Description>${c.description}</v21:Description>
|
||||||
|
${c.netMass !== undefined ? `<v21:NetMass>${c.netMass}</v21:NetMass>` : ''}
|
||||||
|
${c.producers.map(p => `
|
||||||
|
<v21:Producer>
|
||||||
|
<v21:CountryCode>${p.countryCode}</v21:CountryCode>
|
||||||
|
${p.producerName ? `<v21:ProducerName>${p.producerName}</v21:ProducerName>` : ''}
|
||||||
|
<v21:GeoLocation>${p.geoLocation.geoJson}</v21:GeoLocation>
|
||||||
|
</v21:Producer>
|
||||||
|
`).join('')}
|
||||||
|
</v21:Commodity>
|
||||||
|
`).join('')}
|
||||||
|
</v21:AmendDDSRequest>`;
|
||||||
|
|
||||||
|
const envelope = buildSoapEnvelope(this.username, this.authKey, body);
|
||||||
|
|
||||||
|
const response = await this.httpClient.post(this.ENDPOINTS.submission, envelope, {
|
||||||
|
headers: { 'SOAPAction': 'amendDDS' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseAmendDdsResponse(response.data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
httpStatus: response.status,
|
||||||
|
success: true,
|
||||||
|
rawResponse: response.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fault = parseSoapFault(response.data);
|
||||||
|
const errors = parseEudrErrors(response.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: fault ? [fault] : errors,
|
||||||
|
rawResponse: response.data,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Amend DDS failed', error.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'HTTP_ERROR', message: error.message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CF6 - Retract DDS
|
||||||
|
* Cancels (SUBMITTED status) or withdraws (AVAILABLE status) a DDS.
|
||||||
|
*/
|
||||||
|
async retractDds(request: RetractDdsRequestDto): Promise<EudrApiResponseDto<RetractDdsResponseDto>> {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'NOT_CONFIGURED', message: 'EUDR credentials not configured' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = buildRetractDdsRequest(this.wsClientId, request.uuid);
|
||||||
|
const envelope = buildSoapEnvelope(this.username, this.authKey, body);
|
||||||
|
|
||||||
|
const response = await this.httpClient.post(this.ENDPOINTS.submission, envelope, {
|
||||||
|
headers: { 'SOAPAction': 'retractDds' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseRetractDdsResponse(response.data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
httpStatus: response.status,
|
||||||
|
success: true,
|
||||||
|
rawResponse: response.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fault = parseSoapFault(response.data);
|
||||||
|
const errors = parseEudrErrors(response.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: fault ? [fault] : errors,
|
||||||
|
rawResponse: response.data,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Retract DDS failed', error.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'HTTP_ERROR', message: error.message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CF7 - Get DDS Data
|
||||||
|
* Retrieves full DDS data by reference and verification numbers.
|
||||||
|
*/
|
||||||
|
async getDdsData(request: GetDdsDataRequestDto): Promise<EudrApiResponseDto<GetDdsDataResponseDto>> {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'NOT_CONFIGURED', message: 'EUDR credentials not configured' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = buildGetStatementByIdentifiersRequest(
|
||||||
|
this.wsClientId,
|
||||||
|
request.referenceNumber,
|
||||||
|
request.verificationNumber
|
||||||
|
);
|
||||||
|
const envelope = buildSoapEnvelope(this.username, this.authKey, body);
|
||||||
|
|
||||||
|
const response = await this.httpClient.post(this.ENDPOINTS.retrieval, envelope, {
|
||||||
|
headers: { 'SOAPAction': 'getStatementByIdentifiers' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ddsData = parseGetStatementByIdentifiersResponse(response.data);
|
||||||
|
|
||||||
|
if (ddsData) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
httpStatus: response.status,
|
||||||
|
ddsData,
|
||||||
|
rawResponse: response.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fault = parseSoapFault(response.data);
|
||||||
|
const errors = parseEudrErrors(response.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: fault ? [fault] : (errors.length > 0 ? errors : [{ code: 'NOT_FOUND', message: 'DDS not found' }]),
|
||||||
|
rawResponse: response.data,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Get DDS Data failed', error.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'HTTP_ERROR', message: error.message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CF7 - Get Referenced DDS
|
||||||
|
* Retrieves DDS data from supply chain without verification number.
|
||||||
|
*/
|
||||||
|
async getReferencedDds(request: GetReferencedDdsRequestDto): Promise<EudrApiResponseDto<GetDdsDataResponseDto>> {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'NOT_CONFIGURED', message: 'EUDR credentials not configured' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = buildGetReferencedDdsRequest(
|
||||||
|
this.wsClientId,
|
||||||
|
request.referenceNumber,
|
||||||
|
request.referenceDdsVerificationNumber
|
||||||
|
);
|
||||||
|
const envelope = buildSoapEnvelope(this.username, this.authKey, body);
|
||||||
|
|
||||||
|
const response = await this.httpClient.post(this.ENDPOINTS.retrieval, envelope, {
|
||||||
|
headers: { 'SOAPAction': 'getReferencedDds' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ddsData = parseGetStatementByIdentifiersResponse(response.data);
|
||||||
|
|
||||||
|
if (ddsData) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
httpStatus: response.status,
|
||||||
|
ddsData,
|
||||||
|
rawResponse: response.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fault = parseSoapFault(response.data);
|
||||||
|
const errors = parseEudrErrors(response.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: fault ? [fault] : errors,
|
||||||
|
rawResponse: response.data,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Get Referenced DDS failed', error.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 'HTTP_ERROR', message: error.message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
backend/src/eudr-api/index.ts
Normal file
4
backend/src/eudr-api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './eudr-api.module';
|
||||||
|
export * from './eudr-api.service';
|
||||||
|
export * from './eudr-api.controller';
|
||||||
|
export * from './dto';
|
||||||
2
backend/src/eudr-api/soap/index.ts
Normal file
2
backend/src/eudr-api/soap/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './xml-builder';
|
||||||
|
export * from './xml-parser';
|
||||||
299
backend/src/eudr-api/soap/xml-builder.ts
Normal file
299
backend/src/eudr-api/soap/xml-builder.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EUDR SOAP XML Builder
|
||||||
|
* Builds SOAP envelopes with WS-Security UsernameToken Digest authentication
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Namespaces used in EUDR API
|
||||||
|
const NAMESPACES = {
|
||||||
|
soap: 'http://schemas.xmlsoap.org/soap/envelope/',
|
||||||
|
wsse: 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd',
|
||||||
|
wsu: 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd',
|
||||||
|
v21: 'http://ec.europa.eu/tracesnt/eudr/v2.1',
|
||||||
|
v1: 'http://ec.europa.eu/tracesnt/eudr/v1',
|
||||||
|
v4: 'http://ec.europa.eu/tracesnt/commons/v4',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate WS-Security timestamp
|
||||||
|
*/
|
||||||
|
function generateTimestamp(): { created: string; expires: string } {
|
||||||
|
const now = new Date();
|
||||||
|
const expires = new Date(now.getTime() + 5 * 60 * 1000); // 5 minutes validity
|
||||||
|
|
||||||
|
return {
|
||||||
|
created: now.toISOString(),
|
||||||
|
expires: expires.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate random nonce (16 bytes, base64 encoded)
|
||||||
|
*/
|
||||||
|
function generateNonce(): string {
|
||||||
|
return crypto.randomBytes(16).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate password digest for WS-Security
|
||||||
|
* Password = Base64( SHA-1( nonce + created + authKey ) )
|
||||||
|
*/
|
||||||
|
function calculatePasswordDigest(nonce: string, created: string, authKey: string): string {
|
||||||
|
const nonceBytes = Buffer.from(nonce, 'base64');
|
||||||
|
const createdBytes = Buffer.from(created, 'utf8');
|
||||||
|
const authKeyBytes = Buffer.from(authKey, 'utf8');
|
||||||
|
|
||||||
|
const combined = Buffer.concat([nonceBytes, createdBytes, authKeyBytes]);
|
||||||
|
const hash = crypto.createHash('sha1').update(combined).digest('base64');
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build WS-Security header with UsernameToken Digest
|
||||||
|
*/
|
||||||
|
export function buildSecurityHeader(username: string, authKey: string): string {
|
||||||
|
const { created, expires } = generateTimestamp();
|
||||||
|
const nonce = generateNonce();
|
||||||
|
const passwordDigest = calculatePasswordDigest(nonce, created, authKey);
|
||||||
|
|
||||||
|
const tsId = `TS-${crypto.randomUUID()}`;
|
||||||
|
const utId = `UsernameToken-${crypto.randomUUID()}`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<wsse:Security xmlns:wsse="${NAMESPACES.wsse}"
|
||||||
|
xmlns:wsu="${NAMESPACES.wsu}"
|
||||||
|
soap:mustUnderstand="1">
|
||||||
|
<wsu:Timestamp wsu:Id="${tsId}">
|
||||||
|
<wsu:Created>${created}</wsu:Created>
|
||||||
|
<wsu:Expires>${expires}</wsu:Expires>
|
||||||
|
</wsu:Timestamp>
|
||||||
|
<wsse:UsernameToken wsu:Id="${utId}">
|
||||||
|
<wsse:Username>${escapeXml(username)}</wsse:Username>
|
||||||
|
<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">${passwordDigest}</wsse:Password>
|
||||||
|
<wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">${nonce}</wsse:Nonce>
|
||||||
|
<wsu:Created>${created}</wsu:Created>
|
||||||
|
</wsse:UsernameToken>
|
||||||
|
</wsse:Security>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape XML special characters
|
||||||
|
*/
|
||||||
|
export function escapeXml(str: string): string {
|
||||||
|
if (!str) return '';
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build complete SOAP envelope
|
||||||
|
*/
|
||||||
|
export function buildSoapEnvelope(
|
||||||
|
username: string,
|
||||||
|
authKey: string,
|
||||||
|
body: string,
|
||||||
|
version: 'v1' | 'v21' = 'v21'
|
||||||
|
): string {
|
||||||
|
const securityHeader = buildSecurityHeader(username, authKey);
|
||||||
|
const ns = version === 'v21' ? NAMESPACES.v21 : NAMESPACES.v1;
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="${NAMESPACES.soap}"
|
||||||
|
xmlns:v4="${NAMESPACES.v4}"
|
||||||
|
xmlns:${version}="${ns}">
|
||||||
|
<soap:Header>
|
||||||
|
${securityHeader}
|
||||||
|
</soap:Header>
|
||||||
|
<soap:Body>
|
||||||
|
${body}
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Echo test request body
|
||||||
|
*/
|
||||||
|
export function buildEchoRequest(message: string, wsClientId: string): string {
|
||||||
|
return `
|
||||||
|
<v1:testEchoRequest xmlns:v1="${NAMESPACES.v1}">
|
||||||
|
<v4:WebServiceClientId>${escapeXml(wsClientId)}</v4:WebServiceClientId>
|
||||||
|
<v1:message>${escapeXml(message)}</v1:message>
|
||||||
|
</v1:testEchoRequest>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Submit DDS request body (V2)
|
||||||
|
*/
|
||||||
|
export function buildSubmitDdsRequest(
|
||||||
|
wsClientId: string,
|
||||||
|
data: {
|
||||||
|
activityType: string;
|
||||||
|
confidentialityFlag: boolean;
|
||||||
|
companyInternalReference?: string;
|
||||||
|
onBehalfOfOperator?: {
|
||||||
|
name: string;
|
||||||
|
streetNumber: string;
|
||||||
|
postalCode: string;
|
||||||
|
city: string;
|
||||||
|
countryCode: string;
|
||||||
|
eori?: string;
|
||||||
|
};
|
||||||
|
commodities: Array<{
|
||||||
|
hsCode: string;
|
||||||
|
description: string;
|
||||||
|
netMass?: number;
|
||||||
|
percentageEstimateOrDeviation?: number;
|
||||||
|
supplementaryUnitType?: string;
|
||||||
|
supplementaryUnitValue?: number;
|
||||||
|
producers: Array<{
|
||||||
|
countryCode: string;
|
||||||
|
producerName?: string;
|
||||||
|
geoLocation: { geoJson: string; area?: number };
|
||||||
|
}>;
|
||||||
|
speciesInfo?: Array<{ scientificName: string; commonName: string }>;
|
||||||
|
referencedDdsNumbers?: string[];
|
||||||
|
}>;
|
||||||
|
referencedDds?: Array<{ referenceNumber: string; verificationNumber: string }>;
|
||||||
|
}
|
||||||
|
): string {
|
||||||
|
let operatorXml = '';
|
||||||
|
if (data.onBehalfOfOperator) {
|
||||||
|
const op = data.onBehalfOfOperator;
|
||||||
|
operatorXml = `
|
||||||
|
<v21:OnBehalfOfOperator>
|
||||||
|
<v21:Name>${escapeXml(op.name)}</v21:Name>
|
||||||
|
<v21:StreetNumber>${escapeXml(op.streetNumber)}</v21:StreetNumber>
|
||||||
|
<v21:PostalCode>${escapeXml(op.postalCode)}</v21:PostalCode>
|
||||||
|
<v21:City>${escapeXml(op.city)}</v21:City>
|
||||||
|
<v21:CountryCode>${escapeXml(op.countryCode)}</v21:CountryCode>
|
||||||
|
${op.eori ? `<v21:EORI>${escapeXml(op.eori)}</v21:EORI>` : ''}
|
||||||
|
</v21:OnBehalfOfOperator>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commoditiesXml = data.commodities.map(c => {
|
||||||
|
const producersXml = c.producers.map(p => `
|
||||||
|
<v21:Producer>
|
||||||
|
<v21:CountryCode>${escapeXml(p.countryCode)}</v21:CountryCode>
|
||||||
|
${p.producerName ? `<v21:ProducerName>${escapeXml(p.producerName)}</v21:ProducerName>` : ''}
|
||||||
|
<v21:GeoLocation>${escapeXml(p.geoLocation.geoJson)}</v21:GeoLocation>
|
||||||
|
${p.geoLocation.area ? `<v21:Area>${p.geoLocation.area}</v21:Area>` : ''}
|
||||||
|
</v21:Producer>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const speciesXml = c.speciesInfo?.map(s => `
|
||||||
|
<v21:SpeciesInfo>
|
||||||
|
<v21:ScientificName>${escapeXml(s.scientificName)}</v21:ScientificName>
|
||||||
|
<v21:CommonName>${escapeXml(s.commonName)}</v21:CommonName>
|
||||||
|
</v21:SpeciesInfo>
|
||||||
|
`).join('') || '';
|
||||||
|
|
||||||
|
const refsXml = c.referencedDdsNumbers?.map(ref => `
|
||||||
|
<v21:ReferencedDdsNumber>${escapeXml(ref)}</v21:ReferencedDdsNumber>
|
||||||
|
`).join('') || '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<v21:Commodity>
|
||||||
|
<v21:HSCode>${escapeXml(c.hsCode)}</v21:HSCode>
|
||||||
|
<v21:Description>${escapeXml(c.description)}</v21:Description>
|
||||||
|
${c.netMass !== undefined ? `<v21:NetMass>${c.netMass}</v21:NetMass>` : ''}
|
||||||
|
${c.percentageEstimateOrDeviation !== undefined ? `<v21:PercentageEstimateOrDeviation>${c.percentageEstimateOrDeviation}</v21:PercentageEstimateOrDeviation>` : ''}
|
||||||
|
${c.supplementaryUnitType ? `<v21:SupplementaryUnitType>${escapeXml(c.supplementaryUnitType)}</v21:SupplementaryUnitType>` : ''}
|
||||||
|
${c.supplementaryUnitValue !== undefined ? `<v21:SupplementaryUnitValue>${c.supplementaryUnitValue}</v21:SupplementaryUnitValue>` : ''}
|
||||||
|
${producersXml}
|
||||||
|
${speciesXml}
|
||||||
|
${refsXml}
|
||||||
|
</v21:Commodity>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const referencedDdsXml = data.referencedDds?.map(r => `
|
||||||
|
<v21:ReferencedDds>
|
||||||
|
<v21:ReferenceNumber>${escapeXml(r.referenceNumber)}</v21:ReferenceNumber>
|
||||||
|
<v21:VerificationNumber>${escapeXml(r.verificationNumber)}</v21:VerificationNumber>
|
||||||
|
</v21:ReferencedDds>
|
||||||
|
`).join('') || '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<v21:SubmitDDSRequest xmlns:v21="${NAMESPACES.v21}">
|
||||||
|
<v4:WebServiceClientId>${escapeXml(wsClientId)}</v4:WebServiceClientId>
|
||||||
|
<v21:ActivityType>${escapeXml(data.activityType)}</v21:ActivityType>
|
||||||
|
<v21:ConfidentialityFlag>${data.confidentialityFlag}</v21:ConfidentialityFlag>
|
||||||
|
${data.companyInternalReference ? `<v21:CompanyInternalReference>${escapeXml(data.companyInternalReference)}</v21:CompanyInternalReference>` : ''}
|
||||||
|
${operatorXml}
|
||||||
|
${commoditiesXml}
|
||||||
|
${referencedDdsXml}
|
||||||
|
</v21:SubmitDDSRequest>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Get DDS Info request body
|
||||||
|
*/
|
||||||
|
export function buildGetDdsInfoRequest(wsClientId: string, uuids: string[]): string {
|
||||||
|
const uuidElements = uuids.map(uuid => `<v21:UUID>${escapeXml(uuid)}</v21:UUID>`).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<v21:GetDdsInfoRequest xmlns:v21="${NAMESPACES.v21}">
|
||||||
|
<v4:WebServiceClientId>${escapeXml(wsClientId)}</v4:WebServiceClientId>
|
||||||
|
${uuidElements}
|
||||||
|
</v21:GetDdsInfoRequest>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Get DDS Info by Internal Reference request body
|
||||||
|
*/
|
||||||
|
export function buildGetDdsInfoByRefRequest(wsClientId: string, internalReference: string): string {
|
||||||
|
return `
|
||||||
|
<v21:GetDdsInfoByInternalReferenceNumberRequest xmlns:v21="${NAMESPACES.v21}">
|
||||||
|
<v4:WebServiceClientId>${escapeXml(wsClientId)}</v4:WebServiceClientId>
|
||||||
|
<v21:InternalReferenceNumber>${escapeXml(internalReference)}</v21:InternalReferenceNumber>
|
||||||
|
</v21:GetDdsInfoByInternalReferenceNumberRequest>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Retract DDS request body
|
||||||
|
*/
|
||||||
|
export function buildRetractDdsRequest(wsClientId: string, uuid: string): string {
|
||||||
|
return `
|
||||||
|
<v21:RetractDdsRequest xmlns:v21="${NAMESPACES.v21}">
|
||||||
|
<v4:WebServiceClientId>${escapeXml(wsClientId)}</v4:WebServiceClientId>
|
||||||
|
<v21:UUID>${escapeXml(uuid)}</v21:UUID>
|
||||||
|
</v21:RetractDdsRequest>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Get Statement By Identifiers request body
|
||||||
|
*/
|
||||||
|
export function buildGetStatementByIdentifiersRequest(
|
||||||
|
wsClientId: string,
|
||||||
|
referenceNumber: string,
|
||||||
|
verificationNumber: string
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
<v21:GetStatementByIdentifiersRequest xmlns:v21="${NAMESPACES.v21}">
|
||||||
|
<v4:WebServiceClientId>${escapeXml(wsClientId)}</v4:WebServiceClientId>
|
||||||
|
<v21:ReferenceNumber>${escapeXml(referenceNumber)}</v21:ReferenceNumber>
|
||||||
|
<v21:VerificationNumber>${escapeXml(verificationNumber)}</v21:VerificationNumber>
|
||||||
|
</v21:GetStatementByIdentifiersRequest>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Get Referenced DDS request body
|
||||||
|
*/
|
||||||
|
export function buildGetReferencedDdsRequest(
|
||||||
|
wsClientId: string,
|
||||||
|
referenceNumber: string,
|
||||||
|
referenceDdsVerificationNumber: string
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
<v21:GetReferencedDdsRequest xmlns:v21="${NAMESPACES.v21}">
|
||||||
|
<v4:WebServiceClientId>${escapeXml(wsClientId)}</v4:WebServiceClientId>
|
||||||
|
<v21:ReferenceNumber>${escapeXml(referenceNumber)}</v21:ReferenceNumber>
|
||||||
|
<v21:ReferenceDdsVerificationNumber>${escapeXml(referenceDdsVerificationNumber)}</v21:ReferenceDdsVerificationNumber>
|
||||||
|
</v21:GetReferencedDdsRequest>`;
|
||||||
|
}
|
||||||
280
backend/src/eudr-api/soap/xml-parser.ts
Normal file
280
backend/src/eudr-api/soap/xml-parser.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { XMLParser } from 'fast-xml-parser';
|
||||||
|
import { DDSStatus, EudrApiErrorDto } from '../dto/common.dto';
|
||||||
|
import { DdsInfoDto } from '../dto/get-dds-info.dto';
|
||||||
|
import { DdsDataDto, ReferencedDdsInfoDto } from '../dto/get-dds-data.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EUDR SOAP XML Response Parser
|
||||||
|
* Parses SOAP responses from the EUDR API
|
||||||
|
*/
|
||||||
|
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
attributeNamePrefix: '@_',
|
||||||
|
removeNSPrefix: true,
|
||||||
|
parseTagValue: true,
|
||||||
|
trimValues: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse SOAP Fault response
|
||||||
|
*/
|
||||||
|
export function parseSoapFault(xml: string): EudrApiErrorDto | null {
|
||||||
|
try {
|
||||||
|
const parsed = parser.parse(xml);
|
||||||
|
const envelope = parsed.Envelope || parsed['soap:Envelope'] || parsed['soapenv:Envelope'];
|
||||||
|
const body = envelope?.Body || envelope?.['soap:Body'];
|
||||||
|
const fault = body?.Fault || body?.['soap:Fault'];
|
||||||
|
|
||||||
|
if (fault) {
|
||||||
|
return {
|
||||||
|
code: fault.faultcode || fault.Code?.Value || 'SOAP_FAULT',
|
||||||
|
message: fault.faultstring || fault.Reason?.Text || 'Unknown SOAP fault',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse EUDR business errors from response
|
||||||
|
*/
|
||||||
|
export function parseEudrErrors(xml: string): EudrApiErrorDto[] {
|
||||||
|
try {
|
||||||
|
const parsed = parser.parse(xml);
|
||||||
|
const envelope = parsed.Envelope || parsed['soap:Envelope'];
|
||||||
|
const body = envelope?.Body || envelope?.['soap:Body'];
|
||||||
|
|
||||||
|
// Look for error elements in the response
|
||||||
|
const findErrors = (obj: any, errors: EudrApiErrorDto[] = []): EudrApiErrorDto[] => {
|
||||||
|
if (!obj || typeof obj !== 'object') return errors;
|
||||||
|
|
||||||
|
// Check for Error or ValidationError elements
|
||||||
|
if (obj.Error || obj.ValidationError || obj.BusinessError) {
|
||||||
|
const errList = obj.Error || obj.ValidationError || obj.BusinessError;
|
||||||
|
const errArray = Array.isArray(errList) ? errList : [errList];
|
||||||
|
|
||||||
|
for (const err of errArray) {
|
||||||
|
errors.push({
|
||||||
|
code: err.Code || err.ErrorCode || err.code || 'UNKNOWN',
|
||||||
|
message: err.Message || err.ErrorMessage || err.message || 'Unknown error',
|
||||||
|
field: err.Field || err.FieldName || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively check child elements
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
if (typeof obj[key] === 'object') {
|
||||||
|
findErrors(obj[key], errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
return findErrors(body);
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Echo test response
|
||||||
|
*/
|
||||||
|
export function parseEchoResponse(xml: string): { success: boolean; message?: string } {
|
||||||
|
try {
|
||||||
|
const parsed = parser.parse(xml);
|
||||||
|
const envelope = parsed.Envelope || parsed['soap:Envelope'];
|
||||||
|
const body = envelope?.Body || envelope?.['soap:Body'];
|
||||||
|
const response = body?.testEchoResponse || body?.EchoResponse;
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: response.message || response.Message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Submit DDS response
|
||||||
|
*/
|
||||||
|
export function parseSubmitDdsResponse(xml: string): { uuid?: string; success: boolean } {
|
||||||
|
try {
|
||||||
|
const parsed = parser.parse(xml);
|
||||||
|
const envelope = parsed.Envelope || parsed['soap:Envelope'];
|
||||||
|
const body = envelope?.Body || envelope?.['soap:Body'];
|
||||||
|
const response = body?.SubmitDDSResponse || body?.submitDDSResponse;
|
||||||
|
|
||||||
|
if (response && response.UUID) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
uuid: response.UUID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Get DDS Info response
|
||||||
|
*/
|
||||||
|
export function parseGetDdsInfoResponse(xml: string): DdsInfoDto[] {
|
||||||
|
try {
|
||||||
|
const parsed = parser.parse(xml);
|
||||||
|
const envelope = parsed.Envelope || parsed['soap:Envelope'];
|
||||||
|
const body = envelope?.Body || envelope?.['soap:Body'];
|
||||||
|
const response = body?.GetDdsInfoResponse || body?.getDdsInfoResponse;
|
||||||
|
|
||||||
|
if (!response) return [];
|
||||||
|
|
||||||
|
// Handle both single and multiple DDS info
|
||||||
|
const ddsInfoList = response.DDSInfo || response.DdsInfo;
|
||||||
|
if (!ddsInfoList) return [];
|
||||||
|
|
||||||
|
const infoArray = Array.isArray(ddsInfoList) ? ddsInfoList : [ddsInfoList];
|
||||||
|
|
||||||
|
return infoArray.map((info: any): DdsInfoDto => ({
|
||||||
|
uuid: info.UUID || info.Uuid,
|
||||||
|
status: (info.Status || 'UNKNOWN') as DDSStatus,
|
||||||
|
referenceNumber: info.ReferenceNumber || info.RefNumber,
|
||||||
|
verificationNumber: info.VerificationNumber || info.VerifNumber,
|
||||||
|
internalReference: info.InternalReferenceNumber || info.CompanyInternalReference,
|
||||||
|
caMessage: info.CAMessage || info.CaMessage || info.CompetentAuthorityMessage,
|
||||||
|
rejectionReason: info.RejectionReason,
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Retract DDS response
|
||||||
|
*/
|
||||||
|
export function parseRetractDdsResponse(xml: string): { success: boolean } {
|
||||||
|
try {
|
||||||
|
const parsed = parser.parse(xml);
|
||||||
|
const envelope = parsed.Envelope || parsed['soap:Envelope'];
|
||||||
|
const body = envelope?.Body || envelope?.['soap:Body'];
|
||||||
|
|
||||||
|
// Success if we have a RetractDdsResponse without errors
|
||||||
|
const response = body?.RetractDdsResponse || body?.retractDdsResponse;
|
||||||
|
const fault = parseSoapFault(xml);
|
||||||
|
const errors = parseEudrErrors(xml);
|
||||||
|
|
||||||
|
return { success: !!response && !fault && errors.length === 0 };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Amend DDS response
|
||||||
|
*/
|
||||||
|
export function parseAmendDdsResponse(xml: string): { success: boolean } {
|
||||||
|
try {
|
||||||
|
const parsed = parser.parse(xml);
|
||||||
|
const envelope = parsed.Envelope || parsed['soap:Envelope'];
|
||||||
|
const body = envelope?.Body || envelope?.['soap:Body'];
|
||||||
|
|
||||||
|
const response = body?.AmendDDSResponse || body?.amendDDSResponse;
|
||||||
|
const fault = parseSoapFault(xml);
|
||||||
|
const errors = parseEudrErrors(xml);
|
||||||
|
|
||||||
|
return { success: !!response && !fault && errors.length === 0 };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Get Statement By Identifiers response
|
||||||
|
*/
|
||||||
|
export function parseGetStatementByIdentifiersResponse(xml: string): DdsDataDto | null {
|
||||||
|
try {
|
||||||
|
const parsed = parser.parse(xml);
|
||||||
|
const envelope = parsed.Envelope || parsed['soap:Envelope'];
|
||||||
|
const body = envelope?.Body || envelope?.['soap:Body'];
|
||||||
|
const response = body?.GetStatementByIdentifiersResponse || body?.getStatementByIdentifiersResponse;
|
||||||
|
|
||||||
|
if (!response || !response.Statement) return null;
|
||||||
|
|
||||||
|
const stmt = response.Statement;
|
||||||
|
|
||||||
|
// Parse referenced DDS with security numbers
|
||||||
|
const referencedDds: ReferencedDdsInfoDto[] = [];
|
||||||
|
if (stmt.ReferencedDds) {
|
||||||
|
const refs = Array.isArray(stmt.ReferencedDds) ? stmt.ReferencedDds : [stmt.ReferencedDds];
|
||||||
|
for (const ref of refs) {
|
||||||
|
referencedDds.push({
|
||||||
|
referenceNumber: ref.ReferenceNumber,
|
||||||
|
referenceDdsVerificationNumber: ref.ReferenceDdsVerificationNumber || ref.SecurityNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse commodities
|
||||||
|
const commodities: any[] = [];
|
||||||
|
if (stmt.Commodity) {
|
||||||
|
const comms = Array.isArray(stmt.Commodity) ? stmt.Commodity : [stmt.Commodity];
|
||||||
|
for (const c of comms) {
|
||||||
|
commodities.push({
|
||||||
|
hsCode: c.HSCode || c.HsCode,
|
||||||
|
description: c.Description,
|
||||||
|
netMass: c.NetMass ? parseFloat(c.NetMass) : undefined,
|
||||||
|
percentageEstimateOrDeviation: c.PercentageEstimateOrDeviation ? parseFloat(c.PercentageEstimateOrDeviation) : undefined,
|
||||||
|
supplementaryUnitType: c.SupplementaryUnitType,
|
||||||
|
supplementaryUnitValue: c.SupplementaryUnitValue ? parseFloat(c.SupplementaryUnitValue) : undefined,
|
||||||
|
producers: [],
|
||||||
|
speciesInfo: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
referenceNumber: stmt.ReferenceNumber,
|
||||||
|
verificationNumber: stmt.VerificationNumber,
|
||||||
|
status: (stmt.Status || 'UNKNOWN') as DDSStatus,
|
||||||
|
activityType: stmt.ActivityType,
|
||||||
|
availabilityDate: stmt.AvailabilityDate,
|
||||||
|
operatorName: stmt.OperatorName || stmt.Operator?.Name,
|
||||||
|
operatorCountry: stmt.OperatorCountry || stmt.Operator?.CountryCode,
|
||||||
|
commodities,
|
||||||
|
showGeoLocation: stmt.ShowGeoLocation === true || stmt.ShowGeoLocation === 'true',
|
||||||
|
referencedDds,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Get Referenced DDS response (same structure as GetStatementByIdentifiers)
|
||||||
|
*/
|
||||||
|
export function parseGetReferencedDdsResponse(xml: string): DdsDataDto | null {
|
||||||
|
return parseGetStatementByIdentifiersResponse(xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if response indicates HTTP/SOAP success
|
||||||
|
*/
|
||||||
|
export function isSuccessResponse(xml: string): boolean {
|
||||||
|
const fault = parseSoapFault(xml);
|
||||||
|
if (fault) return false;
|
||||||
|
|
||||||
|
const errors = parseEudrErrors(xml);
|
||||||
|
if (errors.length > 0) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
379
backend/src/eudr-api/tests/eudr-api.service.spec.ts
Normal file
379
backend/src/eudr-api/tests/eudr-api.service.spec.ts
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { EudrApiService } from '../eudr-api.service';
|
||||||
|
import { ActivityType } from '../dto/common.dto';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// Mock axios
|
||||||
|
jest.mock('axios');
|
||||||
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
|
|
||||||
|
describe('EudrApiService', () => {
|
||||||
|
let service: EudrApiService;
|
||||||
|
let configService: ConfigService;
|
||||||
|
|
||||||
|
// Mock config values
|
||||||
|
const mockConfig = {
|
||||||
|
EUDR_API_URL: 'https://acceptance.eudr.webcloud.ec.europa.eu/tracesnt',
|
||||||
|
EUDR_WS_CLIENT_ID: 'eudr-test',
|
||||||
|
EUDR_USERNAME: 'test-user',
|
||||||
|
EUDR_AUTH_KEY: 'test-auth-key-12345',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Reset mocks
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Setup axios mock
|
||||||
|
mockedAxios.create.mockReturnValue({
|
||||||
|
post: jest.fn(),
|
||||||
|
get: jest.fn(),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
EudrApiService,
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
|
get: jest.fn((key: string) => mockConfig[key]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<EudrApiService>(EudrApiService);
|
||||||
|
configService = module.get<ConfigService>(ConfigService);
|
||||||
|
|
||||||
|
// Trigger onModuleInit
|
||||||
|
service.onModuleInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Configuration', () => {
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct environment info', () => {
|
||||||
|
const envInfo = service.getEnvironmentInfo();
|
||||||
|
expect(envInfo.isProduction).toBe(false);
|
||||||
|
expect(envInfo.wsClientId).toBe('eudr-test');
|
||||||
|
expect(envInfo.baseUrl).toContain('acceptance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report as configured when credentials exist', () => {
|
||||||
|
expect(service.isConfigured()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CF1 - Echo Test', () => {
|
||||||
|
it('should successfully perform echo test', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
status: 200,
|
||||||
|
data: `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<soap:Body>
|
||||||
|
<testEchoResponse>
|
||||||
|
<message>Test message</message>
|
||||||
|
</testEchoResponse>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the mock client and setup response
|
||||||
|
const mockClient = mockedAxios.create();
|
||||||
|
(mockClient.post as jest.Mock).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
// Re-initialize to apply mock
|
||||||
|
service.onModuleInit();
|
||||||
|
|
||||||
|
// Note: In real tests, we'd need to properly inject the axios instance
|
||||||
|
// This is a simplified example
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail echo in production environment', async () => {
|
||||||
|
// Create a production-configured service
|
||||||
|
const prodConfigService = {
|
||||||
|
get: jest.fn((key: string) => {
|
||||||
|
if (key === 'EUDR_API_URL') return 'https://webgate.ec.europa.eu/tracesnt';
|
||||||
|
return mockConfig[key];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
EudrApiService,
|
||||||
|
{ provide: ConfigService, useValue: prodConfigService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const prodService = module.get<EudrApiService>(EudrApiService);
|
||||||
|
prodService.onModuleInit();
|
||||||
|
|
||||||
|
const result = await prodService.testEcho('test');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errors[0].code).toBe('NOT_AVAILABLE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CF2 - Submit DDS', () => {
|
||||||
|
it('should validate required fields for IMPORT activity', async () => {
|
||||||
|
// IMPORT requires EORI and Net Mass
|
||||||
|
const request = {
|
||||||
|
activityType: ActivityType.IMPORT,
|
||||||
|
confidentialityFlag: false,
|
||||||
|
companyInternalReference: 'TEST-REF-001',
|
||||||
|
commodities: [
|
||||||
|
{
|
||||||
|
hsCode: '1201',
|
||||||
|
description: 'Soybeans',
|
||||||
|
netMass: 50000,
|
||||||
|
producers: [
|
||||||
|
{
|
||||||
|
countryCode: 'BR',
|
||||||
|
producerName: 'Fazenda Teste',
|
||||||
|
geoLocation: {
|
||||||
|
geoJson: JSON.stringify({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [{
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [-47.123, -23.456] },
|
||||||
|
properties: { plotId: 'PLOT-001' }
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
area: 3.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should have correct structure
|
||||||
|
expect(request.activityType).toBe(ActivityType.IMPORT);
|
||||||
|
expect(request.commodities[0].netMass).toBeDefined();
|
||||||
|
expect(request.commodities[0].producers[0].geoLocation.geoJson).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should build correct GeoJSON for producers', () => {
|
||||||
|
const geoJson = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [-47.123456, -23.456789],
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
plotId: 'PLOT-001',
|
||||||
|
area: 3.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(geoJson.type).toBe('FeatureCollection');
|
||||||
|
expect(geoJson.features[0].geometry.type).toBe('Point');
|
||||||
|
expect(geoJson.features[0].geometry.coordinates[0]).toBeGreaterThanOrEqual(-180);
|
||||||
|
expect(geoJson.features[0].geometry.coordinates[0]).toBeLessThanOrEqual(180);
|
||||||
|
expect(geoJson.features[0].geometry.coordinates[1]).toBeGreaterThanOrEqual(-90);
|
||||||
|
expect(geoJson.features[0].geometry.coordinates[1]).toBeLessThanOrEqual(90);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CF3 - Get DDS Info', () => {
|
||||||
|
it('should enforce UUID limit', async () => {
|
||||||
|
const tooManyUuids = Array(101).fill('550e8400-e29b-41d4-a716-446655440000');
|
||||||
|
|
||||||
|
const result = await service.getDdsInfoByUuid({ uuids: tooManyUuids });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errors[0].code).toBe('LIMIT_EXCEEDED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate internal reference length', async () => {
|
||||||
|
// Too short
|
||||||
|
const shortResult = await service.getDdsInfoByReference({ internalReference: 'AB' });
|
||||||
|
expect(shortResult.success).toBe(false);
|
||||||
|
expect(shortResult.errors[0].code).toBe('INVALID_REFERENCE');
|
||||||
|
|
||||||
|
// Too long
|
||||||
|
const longRef = 'A'.repeat(51);
|
||||||
|
const longResult = await service.getDdsInfoByReference({ internalReference: longRef });
|
||||||
|
expect(longResult.success).toBe(false);
|
||||||
|
expect(longResult.errors[0].code).toBe('INVALID_REFERENCE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('XML Builder', () => {
|
||||||
|
// Import the xml-builder module
|
||||||
|
const xmlBuilder = require('../soap/xml-builder');
|
||||||
|
|
||||||
|
describe('Security Header', () => {
|
||||||
|
it('should generate valid security header', () => {
|
||||||
|
const header = xmlBuilder.buildSecurityHeader('testuser', 'testauthkey');
|
||||||
|
|
||||||
|
expect(header).toContain('wsse:Security');
|
||||||
|
expect(header).toContain('wsse:UsernameToken');
|
||||||
|
expect(header).toContain('testuser');
|
||||||
|
expect(header).toContain('wsse:Password');
|
||||||
|
expect(header).toContain('wsse:Nonce');
|
||||||
|
expect(header).toContain('wsu:Created');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Echo Request', () => {
|
||||||
|
it('should build echo request correctly', () => {
|
||||||
|
const request = xmlBuilder.buildEchoRequest('Test Message', 'eudr-test');
|
||||||
|
|
||||||
|
expect(request).toContain('testEchoRequest');
|
||||||
|
expect(request).toContain('Test Message');
|
||||||
|
expect(request).toContain('eudr-test');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Submit DDS Request', () => {
|
||||||
|
it('should build submit request with geolocation', () => {
|
||||||
|
const request = xmlBuilder.buildSubmitDdsRequest('eudr-test', {
|
||||||
|
activityType: 'IMPORT',
|
||||||
|
confidentialityFlag: false,
|
||||||
|
commodities: [{
|
||||||
|
hsCode: '1201',
|
||||||
|
description: 'Soybeans',
|
||||||
|
netMass: 50000,
|
||||||
|
producers: [{
|
||||||
|
countryCode: 'BR',
|
||||||
|
geoLocation: { geoJson: '{"type":"Point"}' },
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request).toContain('SubmitDDSRequest');
|
||||||
|
expect(request).toContain('IMPORT');
|
||||||
|
expect(request).toContain('1201');
|
||||||
|
expect(request).toContain('BR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include authorized representative fields', () => {
|
||||||
|
const request = xmlBuilder.buildSubmitDdsRequest('eudr-test', {
|
||||||
|
activityType: 'IMPORT',
|
||||||
|
confidentialityFlag: false,
|
||||||
|
onBehalfOfOperator: {
|
||||||
|
name: 'Empresa Teste',
|
||||||
|
streetNumber: 'Rua 1, 100',
|
||||||
|
postalCode: '01000-000',
|
||||||
|
city: 'São Paulo',
|
||||||
|
countryCode: 'BR',
|
||||||
|
eori: 'BR12345678901234',
|
||||||
|
},
|
||||||
|
commodities: [{
|
||||||
|
hsCode: '1201',
|
||||||
|
description: 'Soybeans',
|
||||||
|
netMass: 50000,
|
||||||
|
producers: [{
|
||||||
|
countryCode: 'BR',
|
||||||
|
geoLocation: { geoJson: '{}' },
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request).toContain('OnBehalfOfOperator');
|
||||||
|
expect(request).toContain('Empresa Teste');
|
||||||
|
expect(request).toContain('BR12345678901234');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('XML Escaping', () => {
|
||||||
|
it('should escape special characters', () => {
|
||||||
|
const escaped = xmlBuilder.escapeXml('Test & <value> "quote" \'apos\'');
|
||||||
|
|
||||||
|
expect(escaped).toContain('&');
|
||||||
|
expect(escaped).toContain('<');
|
||||||
|
expect(escaped).toContain('>');
|
||||||
|
expect(escaped).toContain('"');
|
||||||
|
expect(escaped).toContain(''');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('XML Parser', () => {
|
||||||
|
const xmlParser = require('../soap/xml-parser');
|
||||||
|
|
||||||
|
describe('Parse Echo Response', () => {
|
||||||
|
it('should parse successful echo response', () => {
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<soap:Body>
|
||||||
|
<testEchoResponse>
|
||||||
|
<message>Hello World</message>
|
||||||
|
</testEchoResponse>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>`;
|
||||||
|
|
||||||
|
const result = xmlParser.parseEchoResponse(xml);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toBe('Hello World');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Parse Submit Response', () => {
|
||||||
|
it('should extract UUID from submit response', () => {
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<soap:Body>
|
||||||
|
<SubmitDDSResponse>
|
||||||
|
<UUID>550e8400-e29b-41d4-a716-446655440000</UUID>
|
||||||
|
</SubmitDDSResponse>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>`;
|
||||||
|
|
||||||
|
const result = xmlParser.parseSubmitDdsResponse(xml);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.uuid).toBe('550e8400-e29b-41d4-a716-446655440000');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Parse DDS Info Response', () => {
|
||||||
|
it('should parse DDS info list', () => {
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<soap:Body>
|
||||||
|
<GetDdsInfoResponse>
|
||||||
|
<DDSInfo>
|
||||||
|
<UUID>550e8400-e29b-41d4-a716-446655440000</UUID>
|
||||||
|
<Status>AVAILABLE</Status>
|
||||||
|
<ReferenceNumber>EUDR-2026-0001234</ReferenceNumber>
|
||||||
|
<VerificationNumber>VN123456789</VerificationNumber>
|
||||||
|
</DDSInfo>
|
||||||
|
</GetDdsInfoResponse>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>`;
|
||||||
|
|
||||||
|
const result = xmlParser.parseGetDdsInfoResponse(xml);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].uuid).toBe('550e8400-e29b-41d4-a716-446655440000');
|
||||||
|
expect(result[0].status).toBe('AVAILABLE');
|
||||||
|
expect(result[0].referenceNumber).toBe('EUDR-2026-0001234');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Parse SOAP Fault', () => {
|
||||||
|
it('should detect SOAP fault', () => {
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<soap:Body>
|
||||||
|
<soap:Fault>
|
||||||
|
<faultcode>soap:Client</faultcode>
|
||||||
|
<faultstring>Authentication failed</faultstring>
|
||||||
|
</soap:Fault>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>`;
|
||||||
|
|
||||||
|
const fault = xmlParser.parseSoapFault(xml);
|
||||||
|
expect(fault).not.toBeNull();
|
||||||
|
expect(fault.message).toContain('Authentication');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
55
backend/src/main.ts
Normal file
55
backend/src/main.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// Global API prefix
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
app.enableCors({
|
||||||
|
origin: ['http://localhost:5173', 'https://duorigin.aivertice.com'],
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
transformOptions: {
|
||||||
|
enableImplicitConversion: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Swagger
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('DuOrigin v2 API')
|
||||||
|
.setDescription('Sistema de Compliance EUDR para Agronegócio')
|
||||||
|
.setVersion('2.0.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.addTag('auth', 'Autenticação')
|
||||||
|
.addTag('users', 'Gerenciamento de Usuários')
|
||||||
|
.addTag('empresas', 'Gerenciamento de Empresas')
|
||||||
|
.addTag('propriedades', 'Gerenciamento de Propriedades')
|
||||||
|
.addTag('avaliacoes', 'Avaliações e DDS')
|
||||||
|
.addTag('documentos', 'Upload/Download de Documentos')
|
||||||
|
.addTag('dashboard', 'Dashboard e Estatísticas')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('docs', app, document);
|
||||||
|
|
||||||
|
const port = process.env.PORT || 8100;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`🚀 DuOrigin v2 API running on http://localhost:${port}`);
|
||||||
|
console.log(`📚 Swagger docs at http://localhost:${port}/api/docs`);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
9
backend/src/prisma/prisma.module.ts
Normal file
9
backend/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
13
backend/src/prisma/prisma.service.ts
Normal file
13
backend/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
79
backend/src/propriedades/dto/create-propriedade.dto.ts
Normal file
79
backend/src/propriedades/dto/create-propriedade.dto.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { IsString, IsOptional, IsBoolean, IsNumber, IsEnum, Min, Max, MinLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { NivelRisco } from '@prisma/client';
|
||||||
|
|
||||||
|
export class CreatePropriedadeDto {
|
||||||
|
@ApiProperty({ example: 'Fazenda São João' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2, { message: 'Nome deve ter no mínimo 2 caracteres' })
|
||||||
|
nome: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1 })
|
||||||
|
@IsNumber()
|
||||||
|
empresaId: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'MG-1234567-A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
codigoCar?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 500.5 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0, { message: 'Área deve ser maior ou igual a 0' })
|
||||||
|
areaHa?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: -18.9186, description: 'Latitude em graus decimais' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-90)
|
||||||
|
@Max(90)
|
||||||
|
latitude?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: -48.2772, description: 'Longitude em graus decimais' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-180)
|
||||||
|
@Max(180)
|
||||||
|
longitude?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Cerrado' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
bioma?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Rodovia BR-050, Km 45' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
endereco?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Uberlândia' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cidade?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'MG' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
estado?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: NivelRisco, default: 'BAIXO' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(NivelRisco)
|
||||||
|
nivelRisco?: NivelRisco;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'clean', description: 'Flag de desmatamento' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
flagDesmatamento?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'GeoJSON polygon' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
geojson?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
ativo?: boolean;
|
||||||
|
}
|
||||||
4
backend/src/propriedades/dto/update-propriedade.dto.ts
Normal file
4
backend/src/propriedades/dto/update-propriedade.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreatePropriedadeDto } from './create-propriedade.dto';
|
||||||
|
|
||||||
|
export class UpdatePropriedadeDto extends PartialType(CreatePropriedadeDto) {}
|
||||||
68
backend/src/propriedades/propriedades.controller.ts
Normal file
68
backend/src/propriedades/propriedades.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Controller, Get, Post, Put, Delete, Patch, Body, Param, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||||
|
import { PropriedadesService } from './propriedades.service';
|
||||||
|
import { CreatePropriedadeDto } from './dto/create-propriedade.dto';
|
||||||
|
import { UpdatePropriedadeDto } from './dto/update-propriedade.dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('propriedades')
|
||||||
|
@Controller('propriedades')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class PropriedadesController {
|
||||||
|
constructor(private propriedadesService: PropriedadesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Listar todas as propriedades' })
|
||||||
|
@ApiQuery({ name: 'empresaId', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'includeInactive', required: false, type: Boolean })
|
||||||
|
@ApiResponse({ status: 200, description: 'Lista de propriedades' })
|
||||||
|
findAll(
|
||||||
|
@Query('empresaId') empresaId?: number,
|
||||||
|
@Query('includeInactive') includeInactive?: boolean,
|
||||||
|
) {
|
||||||
|
return this.propriedadesService.findAll(empresaId, includeInactive);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Buscar propriedade por ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Propriedade encontrada' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Propriedade não encontrada' })
|
||||||
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.propriedadesService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Criar nova propriedade' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Propriedade criada com sucesso' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Empresa não encontrada' })
|
||||||
|
create(@Body() createPropriedadeDto: CreatePropriedadeDto) {
|
||||||
|
return this.propriedadesService.create(createPropriedadeDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@ApiOperation({ summary: 'Atualizar propriedade' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Propriedade atualizada' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Propriedade não encontrada' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() updatePropriedadeDto: UpdatePropriedadeDto,
|
||||||
|
) {
|
||||||
|
return this.propriedadesService.update(id, updatePropriedadeDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Remover propriedade' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Propriedade removida' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Propriedade não encontrada' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.propriedadesService.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/toggle-active')
|
||||||
|
@ApiOperation({ summary: 'Ativar/Desativar propriedade' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Status alterado' })
|
||||||
|
toggleActive(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.propriedadesService.toggleActive(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/propriedades/propriedades.module.ts
Normal file
10
backend/src/propriedades/propriedades.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PropriedadesController } from './propriedades.controller';
|
||||||
|
import { PropriedadesService } from './propriedades.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PropriedadesController],
|
||||||
|
providers: [PropriedadesService],
|
||||||
|
exports: [PropriedadesService],
|
||||||
|
})
|
||||||
|
export class PropriedadesModule {}
|
||||||
120
backend/src/propriedades/propriedades.service.ts
Normal file
120
backend/src/propriedades/propriedades.service.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreatePropriedadeDto } from './dto/create-propriedade.dto';
|
||||||
|
import { UpdatePropriedadeDto } from './dto/update-propriedade.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PropriedadesService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll(empresaId?: number, includeInactive = false) {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (empresaId) {
|
||||||
|
where.empresaId = empresaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!includeInactive) {
|
||||||
|
where.ativo = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.propriedade.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
empresa: {
|
||||||
|
select: { id: true, razaoSocial: true, cnpj: true },
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { avaliacoes: true, documentos: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: number) {
|
||||||
|
const propriedade = await this.prisma.propriedade.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
empresa: {
|
||||||
|
select: { id: true, razaoSocial: true, cnpj: true },
|
||||||
|
},
|
||||||
|
avaliacoes: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
},
|
||||||
|
documentos: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { avaliacoes: true, documentos: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!propriedade) {
|
||||||
|
throw new NotFoundException('Propriedade não encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return propriedade;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(createPropriedadeDto: CreatePropriedadeDto) {
|
||||||
|
// Verify empresa exists
|
||||||
|
const empresa = await this.prisma.empresa.findUnique({
|
||||||
|
where: { id: createPropriedadeDto.empresaId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!empresa) {
|
||||||
|
throw new BadRequestException('Empresa não encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.propriedade.create({
|
||||||
|
data: createPropriedadeDto,
|
||||||
|
include: {
|
||||||
|
empresa: {
|
||||||
|
select: { id: true, razaoSocial: true, cnpj: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, updatePropriedadeDto: UpdatePropriedadeDto) {
|
||||||
|
await this.findOne(id);
|
||||||
|
|
||||||
|
if (updatePropriedadeDto.empresaId) {
|
||||||
|
const empresa = await this.prisma.empresa.findUnique({
|
||||||
|
where: { id: updatePropriedadeDto.empresaId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!empresa) {
|
||||||
|
throw new BadRequestException('Empresa não encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.propriedade.update({
|
||||||
|
where: { id },
|
||||||
|
data: updatePropriedadeDto,
|
||||||
|
include: {
|
||||||
|
empresa: {
|
||||||
|
select: { id: true, razaoSocial: true, cnpj: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number) {
|
||||||
|
await this.findOne(id);
|
||||||
|
await this.prisma.propriedade.delete({ where: { id } });
|
||||||
|
return { message: 'Propriedade removida com sucesso' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleActive(id: number) {
|
||||||
|
const propriedade = await this.findOne(id);
|
||||||
|
return this.prisma.propriedade.update({
|
||||||
|
where: { id },
|
||||||
|
data: { ativo: !propriedade.ativo },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/src/types/express.d.ts
vendored
Normal file
15
backend/src/types/express.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Multer } from 'multer';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: {
|
||||||
|
sub: number;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
29
backend/src/users/dto/create-user.dto.ts
Normal file
29
backend/src/users/dto/create-user.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { IsEmail, IsString, MinLength, IsOptional, IsEnum, IsBoolean } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
|
||||||
|
export class CreateUserDto {
|
||||||
|
@ApiProperty({ example: 'usuario@duorigin.com' })
|
||||||
|
@IsEmail({}, { message: 'Email inválido' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Senha123!' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6, { message: 'Senha deve ter no mínimo 6 caracteres' })
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'João Silva' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2, { message: 'Nome deve ter no mínimo 2 caracteres' })
|
||||||
|
nome: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: Role, default: 'OPERADOR' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(Role)
|
||||||
|
role?: Role;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
ativo?: boolean;
|
||||||
|
}
|
||||||
4
backend/src/users/dto/update-user.dto.ts
Normal file
4
backend/src/users/dto/update-user.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateUserDto } from './create-user.dto';
|
||||||
|
|
||||||
|
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
||||||
69
backend/src/users/users.controller.ts
Normal file
69
backend/src/users/users.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Controller, Get, Post, Put, Delete, Patch, Body, Param, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { Roles } from '../auth/roles.decorator';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
|
||||||
|
@ApiTags('users')
|
||||||
|
@Controller('users')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private usersService: UsersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles(Role.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Listar todos os usuários (Admin)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Lista de usuários' })
|
||||||
|
findAll() {
|
||||||
|
return this.usersService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@Roles(Role.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Buscar usuário por ID (Admin)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Usuário encontrado' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Usuário não encontrado' })
|
||||||
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.usersService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Roles(Role.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Criar novo usuário (Admin)' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Usuário criado com sucesso' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Email já cadastrado' })
|
||||||
|
create(@Body() createUserDto: CreateUserDto) {
|
||||||
|
return this.usersService.create(createUserDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@Roles(Role.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Atualizar usuário (Admin)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Usuário atualizado' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Usuário não encontrado' })
|
||||||
|
update(@Param('id', ParseIntPipe) id: number, @Body() updateUserDto: UpdateUserDto) {
|
||||||
|
return this.usersService.update(id, updateUserDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@Roles(Role.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Remover usuário (Admin)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Usuário removido' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Usuário não encontrado' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.usersService.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/toggle-active')
|
||||||
|
@Roles(Role.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Ativar/Desativar usuário (Admin)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Status alterado' })
|
||||||
|
toggleActive(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.usersService.toggleActive(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/users/users.module.ts
Normal file
10
backend/src/users/users.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [UsersController],
|
||||||
|
providers: [UsersService],
|
||||||
|
exports: [UsersService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
129
backend/src/users/users.service.ts
Normal file
129
backend/src/users/users.service.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll() {
|
||||||
|
return this.prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nome: true,
|
||||||
|
role: true,
|
||||||
|
ativo: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: number) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nome: true,
|
||||||
|
role: true,
|
||||||
|
ativo: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('Usuário não encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByEmail(email: string) {
|
||||||
|
return this.prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(createUserDto: CreateUserDto) {
|
||||||
|
const existingUser = await this.findByEmail(createUserDto.email);
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ConflictException('Email já cadastrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
|
||||||
|
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: createUserDto.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
nome: createUserDto.nome,
|
||||||
|
role: createUserDto.role || 'OPERADOR',
|
||||||
|
ativo: createUserDto.ativo ?? true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nome: true,
|
||||||
|
role: true,
|
||||||
|
ativo: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, updateUserDto: UpdateUserDto) {
|
||||||
|
await this.findOne(id);
|
||||||
|
|
||||||
|
const data: any = { ...updateUserDto };
|
||||||
|
|
||||||
|
if (updateUserDto.password) {
|
||||||
|
data.password = await bcrypt.hash(updateUserDto.password, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nome: true,
|
||||||
|
role: true,
|
||||||
|
ativo: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number) {
|
||||||
|
await this.findOne(id);
|
||||||
|
await this.prisma.user.delete({ where: { id } });
|
||||||
|
return { message: 'Usuário removido com sucesso' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleActive(id: number) {
|
||||||
|
const user = await this.findOne(id);
|
||||||
|
return this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { ativo: !user.ativo },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nome: true,
|
||||||
|
role: true,
|
||||||
|
ativo: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
27
backend/tsconfig.json
Normal file
27
backend/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/tests/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
209
docs/API-ROUTES.md
Normal file
209
docs/API-ROUTES.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# DuOrigin v2 - API Routes
|
||||||
|
|
||||||
|
## Autenticação
|
||||||
|
|
||||||
|
Todas as rotas (exceto login/registro) requerem autenticação via JWT.
|
||||||
|
Header: `Authorization: Bearer <token>`
|
||||||
|
|
||||||
|
### POST /api/auth/login
|
||||||
|
Login de usuário.
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{ "email": "user@email.com", "password": "senha123" }
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{ "success": true, "data": { "accessToken": "jwt...", "tokenType": "bearer" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/auth/registro
|
||||||
|
Registrar novo usuário.
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{ "email": "user@email.com", "password": "senha123", "fullName": "Nome", "role": "operator" }
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{ "success": true, "data": { "id": 1, "email": "...", "fullName": "...", "role": "operator", "isActive": true, "createdAt": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/auth/me
|
||||||
|
Dados do usuário logado.
|
||||||
|
```json
|
||||||
|
// Response
|
||||||
|
{ "success": true, "data": { "id": 1, "email": "...", "fullName": "...", "role": "...", "isActive": true, "createdAt": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empresas (Companies)
|
||||||
|
|
||||||
|
### GET /api/empresas
|
||||||
|
Lista todas as empresas.
|
||||||
|
|
||||||
|
### POST /api/empresas
|
||||||
|
Criar nova empresa.
|
||||||
|
```json
|
||||||
|
{ "name": "Nome", "cnpj": "12345678000190", "country": "BR", "state": "SP", "city": "São Paulo", "euOperatorId": "EU-001" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/empresas/[id]
|
||||||
|
Detalhes de uma empresa.
|
||||||
|
|
||||||
|
### PUT /api/empresas/[id]
|
||||||
|
Atualizar empresa.
|
||||||
|
|
||||||
|
### DELETE /api/empresas/[id]
|
||||||
|
Excluir empresa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Propriedades (Producers)
|
||||||
|
|
||||||
|
### GET /api/propriedades
|
||||||
|
Lista todas as propriedades.
|
||||||
|
Query: `?companyId=1` para filtrar por empresa.
|
||||||
|
|
||||||
|
### POST /api/propriedades
|
||||||
|
Criar nova propriedade.
|
||||||
|
```json
|
||||||
|
{ "name": "Nome", "cpfCnpj": "12345678901", "companyId": 1, "state": "MT", "city": "Cuiabá", "carCode": "MT-123" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/propriedades/[id]
|
||||||
|
Detalhes de uma propriedade.
|
||||||
|
|
||||||
|
### PUT /api/propriedades/[id]
|
||||||
|
Atualizar propriedade.
|
||||||
|
|
||||||
|
### DELETE /api/propriedades/[id]
|
||||||
|
Excluir propriedade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Avaliações (Areas)
|
||||||
|
|
||||||
|
### GET /api/avaliacoes
|
||||||
|
Lista todas as avaliações/áreas.
|
||||||
|
Query: `?producerId=1` para filtrar por produtor.
|
||||||
|
|
||||||
|
### POST /api/avaliacoes
|
||||||
|
Criar nova avaliação.
|
||||||
|
```json
|
||||||
|
{ "name": "Nome", "producerId": 1, "geojson": "{...}", "areaHa": 100.5, "biome": "Cerrado", "latCenter": -15.0, "lonCenter": -56.0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/avaliacoes/[id]
|
||||||
|
Detalhes de uma avaliação.
|
||||||
|
|
||||||
|
### PUT /api/avaliacoes/[id]
|
||||||
|
Atualizar avaliação.
|
||||||
|
|
||||||
|
### DELETE /api/avaliacoes/[id]
|
||||||
|
Excluir avaliação.
|
||||||
|
|
||||||
|
### POST /api/avaliacoes/[id]/gerar-dds
|
||||||
|
Gerar Due Diligence Statement para a avaliação.
|
||||||
|
```json
|
||||||
|
// Request (opcional)
|
||||||
|
{ "notes": "Observações adicionais" }
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{ "success": true, "data": { "id": 1, "referenceNumber": "DDS-ABC123", "status": "draft", ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
### GET /api/dashboard/stats
|
||||||
|
Estatísticas do sistema.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"totalEmpresas": 10,
|
||||||
|
"totalPropriedades": 50,
|
||||||
|
"totalAvaliacoes": 100,
|
||||||
|
"totalLotes": 200,
|
||||||
|
"totalDds": 25,
|
||||||
|
"ddsByStatus": { "draft": 5, "submitted": 10, "approved": 8, "rejected": 2 },
|
||||||
|
"riskDistribution": { "low": 70, "medium": 20, "high": 8, "critical": 2 },
|
||||||
|
"recentActivity": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usuários (Admin Only)
|
||||||
|
|
||||||
|
### GET /api/usuarios
|
||||||
|
Lista todos os usuários. Requer role `admin`.
|
||||||
|
|
||||||
|
### GET /api/usuarios/[id]
|
||||||
|
Detalhes de um usuário.
|
||||||
|
|
||||||
|
### PUT /api/usuarios/[id]
|
||||||
|
Atualizar usuário.
|
||||||
|
```json
|
||||||
|
{ "email": "novo@email.com", "fullName": "Novo Nome", "role": "admin", "isActive": true, "password": "novaSenha" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### DELETE /api/usuarios/[id]
|
||||||
|
Excluir usuário (não pode excluir a si mesmo).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Formato de Resposta Padrão
|
||||||
|
|
||||||
|
### Sucesso
|
||||||
|
```json
|
||||||
|
{ "success": true, "data": { ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erro
|
||||||
|
```json
|
||||||
|
{ "success": false, "error": "Mensagem de erro" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Códigos de Status HTTP
|
||||||
|
|
||||||
|
- `200` - OK
|
||||||
|
- `201` - Created
|
||||||
|
- `400` - Bad Request
|
||||||
|
- `401` - Unauthorized
|
||||||
|
- `403` - Forbidden
|
||||||
|
- `404` - Not Found
|
||||||
|
- `500` - Internal Server Error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estrutura de Arquivos
|
||||||
|
|
||||||
|
```
|
||||||
|
src/app/api/
|
||||||
|
├── auth/
|
||||||
|
│ ├── login/route.ts # POST - Login
|
||||||
|
│ ├── registro/route.ts # POST - Registro
|
||||||
|
│ └── me/route.ts # GET - Dados do usuário
|
||||||
|
├── empresas/
|
||||||
|
│ ├── route.ts # GET list, POST create
|
||||||
|
│ └── [id]/route.ts # GET, PUT, DELETE
|
||||||
|
├── propriedades/
|
||||||
|
│ ├── route.ts # GET list, POST create
|
||||||
|
│ └── [id]/route.ts # GET, PUT, DELETE
|
||||||
|
├── avaliacoes/
|
||||||
|
│ ├── route.ts # GET list, POST create
|
||||||
|
│ ├── [id]/route.ts # GET, PUT, DELETE
|
||||||
|
│ └── [id]/gerar-dds/route.ts # POST - Gerar DDS
|
||||||
|
├── dashboard/
|
||||||
|
│ └── stats/route.ts # GET - Estatísticas
|
||||||
|
└── usuarios/
|
||||||
|
├── route.ts # GET list (admin)
|
||||||
|
└── [id]/route.ts # GET, PUT, DELETE (admin)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Libs Auxiliares
|
||||||
|
|
||||||
|
- `src/lib/prisma.ts` - Prisma Client singleton
|
||||||
|
- `src/lib/auth.ts` - JWT helpers (createToken, verifyToken, requireAuth, requireAdmin)
|
||||||
|
- `src/lib/password.ts` - bcrypt helpers (hashPassword, verifyPassword)
|
||||||
527
docs/EUDR-API-INTEGRATION.md
Normal file
527
docs/EUDR-API-INTEGRATION.md
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
# EUDR API Integration - Documentação Técnica
|
||||||
|
|
||||||
|
> **Versão**: 1.4
|
||||||
|
> **Data**: Fevereiro 2026
|
||||||
|
> **Baseado em**: EUDR API EO Specifications v1.4 (22 Julho 2025)
|
||||||
|
|
||||||
|
## 📋 Índice
|
||||||
|
|
||||||
|
1. [Visão Geral](#visão-geral)
|
||||||
|
2. [Ambientes](#ambientes)
|
||||||
|
3. [Autenticação](#autenticação)
|
||||||
|
4. [Serviços da API](#serviços-da-api)
|
||||||
|
5. [Conformance Tests](#conformance-tests)
|
||||||
|
6. [Estrutura XML/SOAP](#estrutura-xmlsoap)
|
||||||
|
7. [GeoJSON para Geolocalização](#geojson-para-geolocalização)
|
||||||
|
8. [Unidades de Medida](#unidades-de-medida)
|
||||||
|
9. [Regras de Validação](#regras-de-validação)
|
||||||
|
10. [Códigos de Erro](#códigos-de-erro)
|
||||||
|
11. [Fluxo de Integração DuOrigin](#fluxo-de-integração-duorigin)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visão Geral
|
||||||
|
|
||||||
|
A API EUDR permite a submissão e gestão de DDS (Due Diligence Statements) de forma automatizada via **SOAP/WSDL** (machine-to-machine). O DuOrigin atua como intermediário entre o operador e a API oficial da UE.
|
||||||
|
|
||||||
|
### Arquitetura
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Operador │────▶│ DuOrigin │────▶│ EUDR API │
|
||||||
|
│ (Frontend) │ │ (Backend) │ │ (EU Server) │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
│ │ │
|
||||||
|
Formulário SOAP Client SOAP/WSDL
|
||||||
|
Web UI NestJS XML Messages
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serviços Disponíveis
|
||||||
|
|
||||||
|
| Serviço | Descrição | Versões |
|
||||||
|
|---------|-----------|---------|
|
||||||
|
| `Echo` | Teste de conexão/autenticação | V1 |
|
||||||
|
| `submitDDS` | Submissão de nova DDS | V1, V2 |
|
||||||
|
| `amendDDS` | Alteração de DDS existente | V1, V2 |
|
||||||
|
| `retractDds` | Cancelar/Retirar DDS | V1, V2 |
|
||||||
|
| `getDDSInfo` | Obter status e referência de DDS | V1, V2 |
|
||||||
|
| `getStatementByIdentifiers` | Obter dados completos de DDS | V1, V2 |
|
||||||
|
| `getReferencedDds` | Obter DDS referenciadas na cadeia | V2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ambientes
|
||||||
|
|
||||||
|
### Production (Produção)
|
||||||
|
- **URL Base**: `https://webgate.ec.europa.eu/tracesnt/`
|
||||||
|
- **Uso**: DDS com valor legal
|
||||||
|
- **Requisito**: Passar todos os Conformance Tests (CF1-CF7)
|
||||||
|
|
||||||
|
### Acceptance Cloud (Testes)
|
||||||
|
- **URL Base**: `https://acceptance.eudr.webcloud.ec.europa.eu/tracesnt/`
|
||||||
|
- **Uso**: Testes funcionais e Conformance Tests
|
||||||
|
- **WebServiceClientId de Teste**: `eudr-test`
|
||||||
|
|
||||||
|
### URLs dos WSDLs
|
||||||
|
|
||||||
|
**ACCEPTANCE (Testes):**
|
||||||
|
- Submission WS: `https://acceptance.eudr.webcloud.ec.europa.eu/tracesnt/services/EudrSubmissionServiceV1.wsdl`
|
||||||
|
- Submission V2: `https://acceptance.eudr.webcloud.ec.europa.eu/tracesnt/services/EudrSubmissionServiceV2.wsdl`
|
||||||
|
- Retrieval WS: `https://acceptance.eudr.webcloud.ec.europa.eu/tracesnt/services/EudrRetrievalServiceV1.wsdl`
|
||||||
|
- Retrieval V2: `https://acceptance.eudr.webcloud.ec.europa.eu/tracesnt/services/EudrRetrievalServiceV2.wsdl`
|
||||||
|
- Echo WS: `https://acceptance.eudr.webcloud.ec.europa.eu/tracesnt/services/EudrEchoService.wsdl`
|
||||||
|
|
||||||
|
**PRODUCTION (Produção):**
|
||||||
|
- Submission WS: `https://webgate.ec.europa.eu/tracesnt/services/EudrSubmissionServiceV1.wsdl`
|
||||||
|
- Retrieval WS: `https://webgate.ec.europa.eu/tracesnt/services/EudrRetrievalServiceV1.wsdl`
|
||||||
|
- (Echo não disponível em produção)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Autenticação
|
||||||
|
|
||||||
|
A API utiliza **WS-Security UsernameToken com Digest** (HTTPS obrigatório).
|
||||||
|
|
||||||
|
### Estrutura do Header de Segurança
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"
|
||||||
|
xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
|
||||||
|
<wsu:Timestamp wsu:Id="TS-xxx">
|
||||||
|
<wsu:Created>2026-02-09T12:00:00.000Z</wsu:Created>
|
||||||
|
<wsu:Expires>2026-02-09T12:05:00.000Z</wsu:Expires>
|
||||||
|
</wsu:Timestamp>
|
||||||
|
<wsse:UsernameToken wsu:Id="UsernameToken-xxx">
|
||||||
|
<wsse:Username>EU_LOGIN_USERNAME</wsse:Username>
|
||||||
|
<wsse:Password Type="...#PasswordDigest">BASE64_DIGEST</wsse:Password>
|
||||||
|
<wsse:Nonce EncodingType="...#Base64Binary">BASE64_NONCE</wsse:Nonce>
|
||||||
|
<wsu:Created>2026-02-09T12:00:00.000Z</wsu:Created>
|
||||||
|
</wsse:UsernameToken>
|
||||||
|
</wsse:Security>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cálculo do Password Digest
|
||||||
|
|
||||||
|
```
|
||||||
|
Password = Base64( SHA-1( Nonce + Created + AuthenticationKey ) )
|
||||||
|
```
|
||||||
|
|
||||||
|
Onde:
|
||||||
|
- **Nonce**: 16 bytes aleatórios em Base64
|
||||||
|
- **Created**: Timestamp ISO 8601
|
||||||
|
- **AuthenticationKey**: Chave obtida no TRACES NT
|
||||||
|
|
||||||
|
### Campos Obrigatórios no SOAP Envelope
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<v4:WebServiceClientId>eudr-test</v4:WebServiceClientId>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Serviços da API
|
||||||
|
|
||||||
|
### 1. Echo Test (CF1)
|
||||||
|
Teste de conexão - disponível apenas em ambientes de teste.
|
||||||
|
|
||||||
|
**Endpoint**: `{EUDR_URL}/services/EudrEchoService#testEcho`
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```xml
|
||||||
|
<eudr:testEchoRequest>
|
||||||
|
<eudr:message>Test message</eudr:message>
|
||||||
|
</eudr:testEchoRequest>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```xml
|
||||||
|
<eudr:testEchoResponse>
|
||||||
|
<eudr:message>Test message</eudr:message>
|
||||||
|
</eudr:testEchoResponse>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Submit DDS (CF2)
|
||||||
|
Submissão de nova Declaração de Due Diligence.
|
||||||
|
|
||||||
|
**Endpoint**: `{EUDR_URL}/services/EudrSubmissionServiceV2#submitDDS`
|
||||||
|
|
||||||
|
**Request (V2):**
|
||||||
|
```xml
|
||||||
|
<v21:SubmitDDSRequest>
|
||||||
|
<v4:WebServiceClientId>eudr-test</v4:WebServiceClientId>
|
||||||
|
<v21:ActivityType>IMPORT</v21:ActivityType>
|
||||||
|
<v21:ConfidentialityFlag>false</v21:ConfidentialityFlag>
|
||||||
|
<v21:CompanyInternalReference>REF-2026-001</v21:CompanyInternalReference>
|
||||||
|
|
||||||
|
<!-- Para Authorized Representative -->
|
||||||
|
<v21:OnBehalfOfOperator>
|
||||||
|
<v21:Name>Empresa Exemplo Ltda</v21:Name>
|
||||||
|
<v21:StreetNumber>Rua Principal, 100</v21:StreetNumber>
|
||||||
|
<v21:PostalCode>01000-000</v21:PostalCode>
|
||||||
|
<v21:City>São Paulo</v21:City>
|
||||||
|
<v21:CountryCode>BR</v21:CountryCode>
|
||||||
|
<v21:EORI>BR12345678901234</v21:EORI>
|
||||||
|
</v21:OnBehalfOfOperator>
|
||||||
|
|
||||||
|
<v21:Commodity>
|
||||||
|
<v21:HSCode>1201</v21:HSCode>
|
||||||
|
<v21:Description>Soybeans for export</v21:Description>
|
||||||
|
<v21:NetMass>50000</v21:NetMass>
|
||||||
|
<v21:PercentageEstimateOrDeviation>5</v21:PercentageEstimateOrDeviation>
|
||||||
|
|
||||||
|
<v21:Producer>
|
||||||
|
<v21:CountryCode>BR</v21:CountryCode>
|
||||||
|
<v21:ProducerName>Fazenda Exemplo</v21:ProducerName>
|
||||||
|
<v21:GeoLocation>{"type":"FeatureCollection","features":[...]}</v21:GeoLocation>
|
||||||
|
</v21:Producer>
|
||||||
|
</v21:Commodity>
|
||||||
|
</v21:SubmitDDSRequest>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```xml
|
||||||
|
<v21:SubmitDDSResponse>
|
||||||
|
<v21:UUID>550e8400-e29b-41d4-a716-446655440000</v21:UUID>
|
||||||
|
</v21:SubmitDDSResponse>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Get DDS Info (CF3)
|
||||||
|
Obter status e número de referência.
|
||||||
|
|
||||||
|
**Endpoint**: `{EUDR_URL}/services/EudrRetrievalServiceV2#getDdsInfo`
|
||||||
|
|
||||||
|
**Request por UUID:**
|
||||||
|
```xml
|
||||||
|
<v21:GetDdsInfoRequest>
|
||||||
|
<v4:WebServiceClientId>eudr-test</v4:WebServiceClientId>
|
||||||
|
<v21:UUID>550e8400-e29b-41d4-a716-446655440000</v21:UUID>
|
||||||
|
</v21:GetDdsInfoRequest>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```xml
|
||||||
|
<v21:GetDdsInfoResponse>
|
||||||
|
<v21:DDSInfo>
|
||||||
|
<v21:UUID>550e8400-e29b-41d4-a716-446655440000</v21:UUID>
|
||||||
|
<v21:Status>AVAILABLE</v21:Status>
|
||||||
|
<v21:ReferenceNumber>EUDR-2026-0001234</v21:ReferenceNumber>
|
||||||
|
<v21:VerificationNumber>VN123456789</v21:VerificationNumber>
|
||||||
|
<v21:InternalReference>REF-2026-001</v21:InternalReference>
|
||||||
|
<v21:CAMessage>...</v21:CAMessage>
|
||||||
|
<v21:RejectionReason>...</v21:RejectionReason>
|
||||||
|
</v21:DDSInfo>
|
||||||
|
</v21:GetDdsInfoResponse>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Amend DDS (CF5)
|
||||||
|
Alterar DDS em status AVAILABLE.
|
||||||
|
|
||||||
|
**Endpoint**: `{EUDR_URL}/services/EudrSubmissionServiceV2#amendDDS`
|
||||||
|
|
||||||
|
**Nota**: A mensagem é semelhante a submitDDS, mas inclui o UUID da DDS a ser alterada.
|
||||||
|
|
||||||
|
### 5. Retract DDS (CF6)
|
||||||
|
Cancelar/Retirar DDS.
|
||||||
|
|
||||||
|
**Endpoint**: `{EUDR_URL}/services/EudrSubmissionServiceV2#retractDds`
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```xml
|
||||||
|
<v21:RetractDdsRequest>
|
||||||
|
<v4:WebServiceClientId>eudr-test</v4:WebServiceClientId>
|
||||||
|
<v21:UUID>550e8400-e29b-41d4-a716-446655440000</v21:UUID>
|
||||||
|
</v21:RetractDdsRequest>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Get Statement By Identifiers (CF7)
|
||||||
|
Obter dados completos de uma DDS usando Reference + Verification Number.
|
||||||
|
|
||||||
|
**Endpoint**: `{EUDR_URL}/services/EudrRetrievalServiceV2#getStatementByIdentifiers`
|
||||||
|
|
||||||
|
### 7. Get Referenced DDS (CF7)
|
||||||
|
Obter DDS referenciadas na cadeia de suprimentos (V2 only).
|
||||||
|
|
||||||
|
**Endpoint**: `{EUDR_URL}/services/EudrRetrievalServiceV2#getReferencedDds`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conformance Tests
|
||||||
|
|
||||||
|
Sequência obrigatória para acesso à produção:
|
||||||
|
|
||||||
|
| CF | Nome | Descrição | Obrigatório |
|
||||||
|
|----|------|-----------|-------------|
|
||||||
|
| CF1 | Echo Test | Conexão e autenticação | ✅ |
|
||||||
|
| CF2 | Submit DDS | Submissão de DDS | ✅ |
|
||||||
|
| CF3 | Get DDS Info | Obter referência/status | ✅ |
|
||||||
|
| CF4 | Error Handling | Gestão de erros | ✅ |
|
||||||
|
| CF5 | Amend DDS | Alteração de DDS | Opcional |
|
||||||
|
| CF6 | Retract DDS | Retirada de DDS | Opcional |
|
||||||
|
| CF7 | Get DDS Data | Obter dados completos | Opcional |
|
||||||
|
|
||||||
|
### Fluxo de Certificação
|
||||||
|
|
||||||
|
1. Registrar operador no TRACES NT (Acceptance)
|
||||||
|
2. Obter credenciais de Web Service
|
||||||
|
3. Executar CF1 (Echo) - validar autenticação
|
||||||
|
4. Executar CF2 (Submit) - submeter DDS de teste
|
||||||
|
5. Executar CF3 (Get Info) - obter referência
|
||||||
|
6. Executar CF4 - testar cenários de erro
|
||||||
|
7. Solicitar acesso à produção via email para SANTE-TRACES@ec.europa.eu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estrutura XML/SOAP
|
||||||
|
|
||||||
|
### Envelope SOAP Completo
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
|
||||||
|
xmlns:v21="http://ec.europa.eu/tracesnt/eudr/v2.1"
|
||||||
|
xmlns:v4="http://ec.europa.eu/tracesnt/commons/v4">
|
||||||
|
<soap:Header>
|
||||||
|
<wsse:Security>
|
||||||
|
<!-- UsernameToken conforme seção Autenticação -->
|
||||||
|
</wsse:Security>
|
||||||
|
</soap:Header>
|
||||||
|
<soap:Body>
|
||||||
|
<!-- Request específico do serviço -->
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Namespaces
|
||||||
|
|
||||||
|
| Prefixo | URI |
|
||||||
|
|---------|-----|
|
||||||
|
| soap | http://schemas.xmlsoap.org/soap/envelope/ |
|
||||||
|
| wsse | http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd |
|
||||||
|
| wsu | http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd |
|
||||||
|
| v21 | http://ec.europa.eu/tracesnt/eudr/v2.1 |
|
||||||
|
| v4 | http://ec.europa.eu/tracesnt/commons/v4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GeoJSON para Geolocalização
|
||||||
|
|
||||||
|
A geolocalização é enviada como string JSON dentro do campo `GeoLocation`.
|
||||||
|
|
||||||
|
### Estrutura GeoJSON para EUDR
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": [-47.123456, -23.456789]
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"plotId": "PLOT-001",
|
||||||
|
"area": 3.5,
|
||||||
|
"harvestDate": "2025-06-15"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[
|
||||||
|
[-47.100, -23.400],
|
||||||
|
[-47.100, -23.500],
|
||||||
|
[-47.200, -23.500],
|
||||||
|
[-47.200, -23.400],
|
||||||
|
[-47.100, -23.400]
|
||||||
|
]]
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"plotId": "PLOT-002"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regras de Geolocalização
|
||||||
|
|
||||||
|
- **Latitude**: -90 a +90
|
||||||
|
- **Longitude**: -180 a +180
|
||||||
|
- **Precisão máxima**: 6 casas decimais (sistema arredonda automaticamente)
|
||||||
|
- **Área máxima para Point (não-gado)**: 4 hectares
|
||||||
|
- **Área mínima**: 0.1 hectare (0.0001 km²)
|
||||||
|
- **Polígonos**: Mínimo 4 pontos, não podem ter auto-interseção
|
||||||
|
- **Tamanho máximo por DDS**: 25 MB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unidades de Medida
|
||||||
|
|
||||||
|
### Para Import/Export
|
||||||
|
|
||||||
|
| HS Code | Tipo | Descrição |
|
||||||
|
|---------|------|-----------|
|
||||||
|
| 010221, 010229 | NAR (p/st) | Número de itens |
|
||||||
|
| 4011, 4013 | NAR (p/st) | Número de itens |
|
||||||
|
| 4403, 4406, 4408, 4410-4413 | MTQ (m³) | Metro cúbico |
|
||||||
|
| 4701, 4702, 4704, 4705 | KSD (kg 90% sdt) | Kg substância 90% seca |
|
||||||
|
|
||||||
|
**Campos obrigatórios Import/Export:**
|
||||||
|
- Net Mass (Kg): Sempre obrigatório
|
||||||
|
- Supplementary Unit: Obrigatório se HS Code estiver na lista acima
|
||||||
|
|
||||||
|
### Para Domestic/Trade
|
||||||
|
|
||||||
|
Combinações válidas:
|
||||||
|
1. Net Mass + Percentage estimate (0-25%)
|
||||||
|
2. Supplementary unit type + quantity
|
||||||
|
3. Ambos
|
||||||
|
|
||||||
|
**Tipos de Supplementary Unit:**
|
||||||
|
| Código | Display | Descrição |
|
||||||
|
|--------|---------|-----------|
|
||||||
|
| KSD | KSD (kg 90% sdt) | Kg substância 90% seca |
|
||||||
|
| MTK | MTK (m²) | Metro quadrado |
|
||||||
|
| MTQ | MTQ (m³) | Metro cúbico |
|
||||||
|
| MTR | MTR (m) | Metro |
|
||||||
|
| NAR | NAR (p/st) | Número de itens |
|
||||||
|
| NPR | NPR (pa) | Número de pares |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regras de Validação
|
||||||
|
|
||||||
|
### Operador
|
||||||
|
|
||||||
|
| Regra | Descrição |
|
||||||
|
|-------|-----------|
|
||||||
|
| Activity Type | Obrigatório; não pode mudar em amend |
|
||||||
|
| EORI | Obrigatório para IMPORT/EXPORT |
|
||||||
|
| Non-EU Operator | Apenas IMPORT permitido |
|
||||||
|
| Authorized Rep | Deve informar dados do operador representado |
|
||||||
|
|
||||||
|
### Commodities
|
||||||
|
|
||||||
|
| Regra | Descrição |
|
||||||
|
|-------|-----------|
|
||||||
|
| Mínimo | Pelo menos 1 commodity por DDS |
|
||||||
|
| Máximo | 100 commodities por DDS |
|
||||||
|
| Description | Obrigatório para cada commodity |
|
||||||
|
| Net Mass | Obrigatório para IMPORT/EXPORT |
|
||||||
|
| HS Code | Deve ser válido (Annex I EUDR) |
|
||||||
|
| Timber | Requer scientific name + common name |
|
||||||
|
|
||||||
|
### Geolocalização
|
||||||
|
|
||||||
|
| Regra | Descrição |
|
||||||
|
|-------|-----------|
|
||||||
|
| Obrigatória | Se não houver Referenced DDS |
|
||||||
|
| Tamanho máximo | 25 MB por DDS |
|
||||||
|
| Producers por commodity | Máximo 1000 |
|
||||||
|
| Producers por DDS | Máximo 10.000 |
|
||||||
|
| Área para Point | Obrigatória; default 4ha; máx 4ha (não-gado) |
|
||||||
|
|
||||||
|
### DDS Referenciadas
|
||||||
|
|
||||||
|
| Regra | Descrição |
|
||||||
|
|-------|-----------|
|
||||||
|
| Máximo | 2000 por DDS |
|
||||||
|
| Status | Deve estar AVAILABLE ou ARCHIVED |
|
||||||
|
| Auto-referência | DDS não pode referenciar a si mesma |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Códigos de Erro
|
||||||
|
|
||||||
|
### Erros de Autenticação/Schema
|
||||||
|
|
||||||
|
| Código HTTP | Descrição |
|
||||||
|
|-------------|-----------|
|
||||||
|
| 401 | Credenciais inválidas |
|
||||||
|
| 403 | Sem permissão |
|
||||||
|
| 500 | Erro de schema XML |
|
||||||
|
|
||||||
|
### Erros de Negócio
|
||||||
|
|
||||||
|
| Código | Descrição |
|
||||||
|
|--------|-----------|
|
||||||
|
| EUDR_WEBSERVICE_USER_NOT_EUDR_OPERATOR | Usuário não registrado como operador EUDR |
|
||||||
|
| EUDR_WEBSERVICE_USER_FROM_MANY_OPERATOR | Usuário pertence a mais de um operador |
|
||||||
|
| EUDR_WEBSERVICE_USER_ACTIVITY_NOT_ALLOWED | Atividade não permitida para o perfil |
|
||||||
|
| EUDR_OPERATOR_EORI_FOR_ACTIVITY_MISSING | EORI obrigatório para IMPORT/EXPORT |
|
||||||
|
| EUDR_BEHALF_OPERATOR_NOT_PROVIDED | Operador representado não informado |
|
||||||
|
| EUDR_BEHALF_OPERATOR_CITY_POSTALCODE_EMPTY_OR_INVALID | Cidade/CEP inválidos |
|
||||||
|
| EUDR_ACTIVITY_TYPE_NOT_COMPATIBLE | Atividade incompatível com perfil |
|
||||||
|
| EUDR_COMMODITIES_HS_CODE_INVALID | HS Code inválido |
|
||||||
|
| EUDR_COMMODITIES_DESCRIPTOR_NET_MASS_EMPTY | Net Mass obrigatório |
|
||||||
|
| EUDR_COMMODITIES_DESCRIPTOR_QUANTITY_MISSING | Quantidade obrigatória |
|
||||||
|
| EUDR_COMMODITITY_PRODUCER_COUNTRY_CODE_INVALID | Código de país inválido |
|
||||||
|
| EUDR_COMMODITIES_PRODUCER_GEO_EMPTY | Geolocalização obrigatória |
|
||||||
|
| EUDR_COMMODITIES_PRODUCER_GEO_INVALID | GeoJSON inválido |
|
||||||
|
| EUDR_COMMODITIES_PRODUCER_GEO_LATITUDE_INVALID | Latitude fora do range |
|
||||||
|
| EUDR_COMMODITIES_PRODUCER_GEO_LONGITUDE_INVALID | Longitude fora do range |
|
||||||
|
| EUDR_COMMODITIES_PRODUCER_GEO_INVALID_GEOMETRY | Geometria inválida |
|
||||||
|
| EUDR_COMMODITIES_PRODUCER_GEO_AREA_INVALID | Área inválida (0.1-4 ha) |
|
||||||
|
| EUDR_MAXIMUM_GEO_SIZE_REACHED | Tamanho máximo excedido (25MB) |
|
||||||
|
| EUDR_REFERENCED_STATEMENT_NOT_FOUND | DDS referenciada não encontrada |
|
||||||
|
| EUDR_MAXIMUM_REFERENCED_DDS_REACHED | Máximo de DDS referenciadas excedido |
|
||||||
|
| EUDR_API_AMEND_ACTIVITY_TYPE_CHANGE_NOT_ALLOWED | Não pode mudar activity type |
|
||||||
|
| EUDR_API_AMEND_OR_WITHDRAW_DDS_NOT_POSSIBLE | DDS referenciada ou prazo expirado |
|
||||||
|
| EUDR_API_AMEND_NOT_ALLOWED_FOR_STATUS | Status não permite alteração |
|
||||||
|
| EUDR_API_NO_DDS | DDS não encontrada |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fluxo de Integração DuOrigin
|
||||||
|
|
||||||
|
### 1. Cadastro de Operador
|
||||||
|
```
|
||||||
|
Operador → DuOrigin UI → Salvar empresa com EORI, endereço, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Submissão de DDS
|
||||||
|
```
|
||||||
|
1. Operador preenche formulário DDS no DuOrigin
|
||||||
|
2. Sistema valida dados localmente
|
||||||
|
3. DuOrigin chama eudr-api.service.submitDDS()
|
||||||
|
4. Recebe UUID e armazena no banco
|
||||||
|
5. Polling getDDSInfo() para obter Reference Number
|
||||||
|
6. Atualiza status no DuOrigin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Acompanhamento
|
||||||
|
```
|
||||||
|
1. DuOrigin faz polling periódico de getDDSInfo()
|
||||||
|
2. Atualiza status (SUBMITTED → AVAILABLE/REJECTED)
|
||||||
|
3. Notifica operador se houver CA message
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Alterações
|
||||||
|
```
|
||||||
|
1. Operador solicita alteração
|
||||||
|
2. DuOrigin valida prazo e referências
|
||||||
|
3. Chama amendDDS() com dados completos
|
||||||
|
4. Atualiza registro local
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Retirada
|
||||||
|
```
|
||||||
|
1. Operador solicita cancelamento/retirada
|
||||||
|
2. DuOrigin valida status e referências
|
||||||
|
3. Chama retractDds() com UUID
|
||||||
|
4. Atualiza status para WITHDRAWN/CANCELLED
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contatos
|
||||||
|
|
||||||
|
- **Suporte Técnico**: SANTE-TRACES@ec.europa.eu (título deve começar com "EUDR API")
|
||||||
|
- **Política**: ENV-DEFORESTATION@ec.europa.eu
|
||||||
|
- **Website**: https://environment.ec.europa.eu/topics/forests/deforestation/regulation-implementation_en
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Documentação gerada para DuOrigin v2 - Fevereiro 2026*
|
||||||
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?
|
||||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + 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 updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
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/jpeg" href="/logo-duorigin.jpg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="DuOrigin - Compliance EUDR Inteligente para o Agronegócio" />
|
||||||
|
<title>DuOrigin - Compliance EUDR</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>
|
||||||
5510
frontend/package-lock.json
generated
Normal file
5510
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.71.1",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"autoprefixer": "^10.4.24",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
BIN
frontend/public/logo-duorigin.jpg
Normal file
BIN
frontend/public/logo-duorigin.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
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 |
77
frontend/src/App.tsx
Normal file
77
frontend/src/App.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { AuthProvider } from '@/contexts/AuthContext';
|
||||||
|
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||||
|
import DashboardLayout from '@/components/DashboardLayout';
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
import Landing from '@/pages/Landing';
|
||||||
|
import Login from '@/pages/Login';
|
||||||
|
import Registro from '@/pages/Registro';
|
||||||
|
import Dashboard from '@/pages/Dashboard';
|
||||||
|
import Empresas from '@/pages/Empresas';
|
||||||
|
import EmpresaForm from '@/pages/EmpresaForm';
|
||||||
|
import Propriedades from '@/pages/Propriedades';
|
||||||
|
import PropriedadeForm from '@/pages/PropriedadeForm';
|
||||||
|
import Avaliacoes from '@/pages/Avaliacoes';
|
||||||
|
import AvaliacaoDetail from '@/pages/AvaliacaoDetail';
|
||||||
|
import Documentos from '@/pages/Documentos';
|
||||||
|
import Usuarios from '@/pages/Usuarios';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
{/* Public Routes */}
|
||||||
|
<Route path="/" element={<Landing />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/registro" element={<Registro />} />
|
||||||
|
|
||||||
|
{/* Protected Routes */}
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
|
||||||
|
{/* Empresas */}
|
||||||
|
<Route path="/empresas" element={<Empresas />} />
|
||||||
|
<Route path="/empresas/nova" element={<EmpresaForm />} />
|
||||||
|
<Route path="/empresas/:id" element={<EmpresaForm />} />
|
||||||
|
|
||||||
|
{/* Propriedades */}
|
||||||
|
<Route path="/propriedades" element={<Propriedades />} />
|
||||||
|
<Route path="/propriedades/nova" element={<PropriedadeForm />} />
|
||||||
|
<Route path="/propriedades/:id" element={<PropriedadeForm />} />
|
||||||
|
|
||||||
|
{/* Avaliações */}
|
||||||
|
<Route path="/avaliacoes" element={<Avaliacoes />} />
|
||||||
|
<Route path="/avaliacoes/nova" element={<AvaliacaoDetail />} />
|
||||||
|
<Route path="/avaliacoes/:id" element={<AvaliacaoDetail />} />
|
||||||
|
|
||||||
|
{/* Documentos */}
|
||||||
|
<Route path="/documentos" element={<Documentos />} />
|
||||||
|
|
||||||
|
{/* Usuários (Admin only) */}
|
||||||
|
<Route
|
||||||
|
path="/usuarios"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute adminOnly>
|
||||||
|
<Usuarios />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Catch all */}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
37
frontend/src/api/client.ts
Normal file
37
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor - adiciona JWT token
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('duorigin_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor - trata erros de auth
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('duorigin_token');
|
||||||
|
localStorage.removeItem('duorigin_user');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
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 |
162
frontend/src/components/DDSModal.tsx
Normal file
162
frontend/src/components/DDSModal.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { FileText, Loader2, CheckCircle, AlertCircle, Download } from 'lucide-react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import { api } from '@/hooks/useApi';
|
||||||
|
import { Avaliacao } from '@/types';
|
||||||
|
|
||||||
|
interface DDSModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
avaliacao: Avaliacao | null;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DDSModal({ isOpen, onClose, avaliacao, onSuccess }: DDSModalProps) {
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [result, setResult] = useState<{ success: boolean; message: string; codigo?: string } | null>(null);
|
||||||
|
|
||||||
|
const handleGenerar = async () => {
|
||||||
|
if (!avaliacao) return;
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.gerarDDS(avaliacao.id);
|
||||||
|
setResult({
|
||||||
|
success: true,
|
||||||
|
message: 'DDS gerada com sucesso!',
|
||||||
|
codigo: response.data.dds_codigo,
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as { response?: { data?: { detail?: string } } };
|
||||||
|
setResult({
|
||||||
|
success: false,
|
||||||
|
message: err.response?.data?.detail || 'Erro ao gerar DDS',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setResult(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!avaliacao) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={handleClose} title="Gerar Declaração de Due Diligence (DDS)" maxWidth="lg">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Info da avaliação */}
|
||||||
|
<div className="bg-gray-50 rounded-xl p-4">
|
||||||
|
<h3 className="font-semibold text-navy mb-3">Dados da Avaliação</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-muted">Propriedade:</span>
|
||||||
|
<span className="ml-2 text-navy">{avaliacao.propriedade?.nome || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-muted">Data:</span>
|
||||||
|
<span className="ml-2 text-navy">
|
||||||
|
{new Date(avaliacao.data_avaliacao).toLocaleDateString('pt-BR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-muted">Status:</span>
|
||||||
|
<span className={`ml-2 font-medium ${
|
||||||
|
avaliacao.status === 'aprovada' ? 'text-green-600' :
|
||||||
|
avaliacao.status === 'reprovada' ? 'text-red-500' : 'text-amber-500'
|
||||||
|
}`}>
|
||||||
|
{avaliacao.status.charAt(0).toUpperCase() + avaliacao.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-muted">Score de Risco:</span>
|
||||||
|
<span className="ml-2 text-navy font-medium">{avaliacao.score_risco}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resultado */}
|
||||||
|
{result && (
|
||||||
|
<div className={`rounded-xl p-4 flex items-start gap-3 ${
|
||||||
|
result.success ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'
|
||||||
|
}`}>
|
||||||
|
{result.success ? (
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-600 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-6 h-6 text-red-500 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className={result.success ? 'text-green-800' : 'text-red-800'}>
|
||||||
|
{result.message}
|
||||||
|
</p>
|
||||||
|
{result.codigo && (
|
||||||
|
<p className="mt-2 font-mono text-sm bg-white/50 px-2 py-1 rounded inline-block">
|
||||||
|
Código: {result.codigo}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Aviso */}
|
||||||
|
{!result && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm text-amber-800">
|
||||||
|
<p className="font-medium mb-1">⚠️ Atenção</p>
|
||||||
|
<p>
|
||||||
|
A DDS será gerada no formato compatível com TRACES NT.
|
||||||
|
Certifique-se de que todos os dados da avaliação estão corretos antes de prosseguir.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ações */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
{result?.success ? 'Fechar' : 'Cancelar'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!result?.success && (
|
||||||
|
<button
|
||||||
|
onClick={handleGenerar}
|
||||||
|
disabled={isGenerating || avaliacao.status !== 'aprovada'}
|
||||||
|
className="btn-primary flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
Gerando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FileText className="w-5 h-5" />
|
||||||
|
Gerar DDS
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result?.success && (
|
||||||
|
<button className="btn-primary flex items-center gap-2">
|
||||||
|
<Download className="w-5 h-5" />
|
||||||
|
Baixar DDS
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{avaliacao.status !== 'aprovada' && !result && (
|
||||||
|
<p className="text-center text-sm text-gray-muted">
|
||||||
|
⚠️ A avaliação precisa estar <strong>aprovada</strong> para gerar a DDS.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
frontend/src/components/DashboardLayout.tsx
Normal file
13
frontend/src/components/DashboardLayout.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import Sidebar from './Sidebar';
|
||||||
|
|
||||||
|
export default function DashboardLayout() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-gray-bg">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="ml-64 flex-1 p-8">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
frontend/src/components/DataTable.tsx
Normal file
109
frontend/src/components/DataTable.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Column<T> {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
render?: (item: T) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTableProps<T> {
|
||||||
|
columns: Column<T>[];
|
||||||
|
data: T[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
emptyMessage?: string;
|
||||||
|
keyExtractor: (item: T) => string | number;
|
||||||
|
onRowClick?: (item: T) => void;
|
||||||
|
pagination?: {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DataTable<T>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
emptyMessage = 'Nenhum registro encontrado',
|
||||||
|
keyExtractor,
|
||||||
|
onRowClick,
|
||||||
|
pagination,
|
||||||
|
}: DataTableProps<T>) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="glass-card flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="glass-card text-center py-12">
|
||||||
|
<p className="text-gray-muted">{emptyMessage}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card !p-0 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr className="text-gray-text text-xs uppercase tracking-wider">
|
||||||
|
{columns.map(col => (
|
||||||
|
<th key={col.key} className="text-left p-4 font-semibold">
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map(item => (
|
||||||
|
<tr
|
||||||
|
key={keyExtractor(item)}
|
||||||
|
onClick={() => onRowClick?.(item)}
|
||||||
|
className={`border-t border-gray-100 hover:bg-gray-50 transition ${
|
||||||
|
onRowClick ? 'cursor-pointer' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{columns.map(col => (
|
||||||
|
<td key={col.key} className="p-4 text-navy">
|
||||||
|
{col.render
|
||||||
|
? col.render(item)
|
||||||
|
: (item as Record<string, unknown>)[col.key] as ReactNode}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between p-4 border-t border-gray-100">
|
||||||
|
<span className="text-sm text-gray-muted">
|
||||||
|
Página {pagination.page} de {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => pagination.onPageChange(pagination.page - 1)}
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => pagination.onPageChange(pagination.page + 1)}
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
frontend/src/components/Footer.tsx
Normal file
7
frontend/src/components/Footer.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-gray-200 py-8 px-6 text-center text-gray-muted text-sm bg-white">
|
||||||
|
© {new Date().getFullYear()} DUORIGIN — Compliance EUDR Inteligente. Todos os direitos reservados.
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
frontend/src/components/Modal.tsx
Normal file
66
frontend/src/components/Modal.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { ReactNode, useEffect } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
maxWidth?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWidthClasses = {
|
||||||
|
sm: 'max-w-sm',
|
||||||
|
md: 'max-w-md',
|
||||||
|
lg: 'max-w-lg',
|
||||||
|
xl: 'max-w-xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Modal({ isOpen, onClose, title, children, maxWidth = 'md' }: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className={`relative bg-white rounded-2xl shadow-xl w-full ${maxWidthClasses[maxWidth]} max-h-[90vh] overflow-hidden`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||||
|
<h2 className="text-xl font-semibold text-navy">{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-muted" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/components/Navbar.tsx
Normal file
19
frontend/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
return (
|
||||||
|
<nav className="fixed top-0 w-full z-50 bg-white/90 backdrop-blur-md border-b border-gray-200 px-6 py-4">
|
||||||
|
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||||
|
<Link to="/" className="flex items-center gap-3">
|
||||||
|
<img src="/logo-duorigin.jpg" alt="DuoOrigin" className="w-14 h-14 rounded-lg" />
|
||||||
|
<span className="text-2xl font-bold text-navy">Duo<span className="text-primary">Origin</span></span>
|
||||||
|
</Link>
|
||||||
|
<div className="hidden md:flex items-center gap-8 text-sm text-navy">
|
||||||
|
<a href="#features" className="hover:text-primary transition">Recursos</a>
|
||||||
|
<a href="#about" className="hover:text-primary transition">Sobre</a>
|
||||||
|
<Link to="/login" className="btn-primary text-sm !py-2 !px-6">Acessar Plataforma</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
frontend/src/components/ProtectedRoute.tsx
Normal file
31
frontend/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
adminOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProtectedRoute({ children, adminOnly = false }: ProtectedRouteProps) {
|
||||||
|
const { isAuthenticated, isLoading, user } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-bg">
|
||||||
|
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminOnly && user?.role !== 'admin') {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
100
frontend/src/components/Sidebar.tsx
Normal file
100
frontend/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Building2,
|
||||||
|
MapPin,
|
||||||
|
ClipboardCheck,
|
||||||
|
FileText,
|
||||||
|
Users,
|
||||||
|
LogOut,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ path: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||||
|
{ path: '/empresas', icon: Building2, label: 'Empresas' },
|
||||||
|
{ path: '/propriedades', icon: MapPin, label: 'Propriedades' },
|
||||||
|
{ path: '/avaliacoes', icon: ClipboardCheck, label: 'Avaliações' },
|
||||||
|
{ path: '/documentos', icon: FileText, label: 'Documentos' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminItems = [
|
||||||
|
{ path: '/usuarios', icon: Users, label: 'Usuários' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
const isActive = (path: string) => location.pathname === path;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="fixed left-0 top-0 h-screen w-64 bg-white border-r border-gray-200 flex flex-col">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="p-6 border-b border-gray-200">
|
||||||
|
<Link to="/dashboard" className="flex items-center gap-3">
|
||||||
|
<img src="/logo-duorigin.jpg" alt="DuoOrigin" className="w-12 h-12 rounded-lg" />
|
||||||
|
<span className="text-xl font-bold text-navy">Duo<span className="text-primary">Origin</span></span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menu */}
|
||||||
|
<nav className="flex-1 p-4 overflow-y-auto">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{menuItems.map(item => (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={`sidebar-link ${isActive(item.path) ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<p className="px-4 text-xs font-semibold text-gray-custom uppercase tracking-wider mb-2">
|
||||||
|
Administração
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{adminItems.map(item => (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={`sidebar-link ${isActive(item.path) ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User & Logout */}
|
||||||
|
<div className="p-4 border-t border-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4 px-2">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<span className="text-primary font-semibold">
|
||||||
|
{user?.nome?.charAt(0)?.toUpperCase() || 'U'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-navy truncate">{user?.nome}</p>
|
||||||
|
<p className="text-xs text-gray-muted truncate">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="sidebar-link w-full text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
<span>Sair</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/components/StatsCard.tsx
Normal file
30
frontend/src/components/StatsCard.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
|
|
||||||
|
interface StatsCardProps {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
trend?: string;
|
||||||
|
trendUp?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatsCard({ icon, label, value, trend, trendUp }: StatsCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="glass-card">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="text-3xl">{icon}</div>
|
||||||
|
{trend && (
|
||||||
|
<div className={`flex items-center gap-1 text-xs font-medium ${trendUp ? 'text-green-600' : 'text-red-500'}`}>
|
||||||
|
{trendUp ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
||||||
|
{trend}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-3xl font-bold text-navy">{value}</div>
|
||||||
|
<div className="text-sm text-gray-muted mt-1">{label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
frontend/src/contexts/AuthContext.tsx
Normal file
83
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { createContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import apiClient from '@/api/client';
|
||||||
|
import { User, AuthResponse, LoginCredentials, RegistroData } from '@/types';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (credentials: LoginCredentials) => Promise<void>;
|
||||||
|
registro: (data: RegistroData) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextType>({} as AuthContextType);
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('duorigin_token');
|
||||||
|
const savedUser = localStorage.getItem('duorigin_user');
|
||||||
|
|
||||||
|
if (token && savedUser) {
|
||||||
|
setUser(JSON.parse(savedUser));
|
||||||
|
// Validar token com backend
|
||||||
|
apiClient.get('/auth/me')
|
||||||
|
.then(response => {
|
||||||
|
setUser(response.data);
|
||||||
|
localStorage.setItem('duorigin_user', JSON.stringify(response.data));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem('duorigin_token');
|
||||||
|
localStorage.removeItem('duorigin_user');
|
||||||
|
setUser(null);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (credentials: LoginCredentials) => {
|
||||||
|
const response = await apiClient.post<AuthResponse>('/auth/login', credentials);
|
||||||
|
const { access_token, user } = response.data;
|
||||||
|
|
||||||
|
localStorage.setItem('duorigin_token', access_token);
|
||||||
|
localStorage.setItem('duorigin_user', JSON.stringify(user));
|
||||||
|
setUser(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const registro = async (data: RegistroData) => {
|
||||||
|
const response = await apiClient.post<AuthResponse>('/auth/registro', data);
|
||||||
|
const { access_token, user } = response.data;
|
||||||
|
|
||||||
|
localStorage.setItem('duorigin_token', access_token);
|
||||||
|
localStorage.setItem('duorigin_user', JSON.stringify(user));
|
||||||
|
setUser(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('duorigin_token');
|
||||||
|
localStorage.removeItem('duorigin_user');
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{
|
||||||
|
user,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
registro,
|
||||||
|
logout,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
frontend/src/hooks/useApi.ts
Normal file
90
frontend/src/hooks/useApi.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import apiClient from '@/api/client';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
|
||||||
|
interface UseApiState<T> {
|
||||||
|
data: T | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseApiReturn<T> extends UseApiState<T> {
|
||||||
|
execute: (...args: unknown[]) => Promise<T | null>;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApi<T>(
|
||||||
|
apiCall: (...args: unknown[]) => Promise<{ data: T }>
|
||||||
|
): UseApiReturn<T> {
|
||||||
|
const [state, setState] = useState<UseApiState<T>>({
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const execute = useCallback(async (...args: unknown[]): Promise<T | null> => {
|
||||||
|
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiCall(...args);
|
||||||
|
setState({ data: response.data, isLoading: false, error: null });
|
||||||
|
return response.data;
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as AxiosError<{ detail?: string }>;
|
||||||
|
const message = error.response?.data?.detail || error.message || 'Erro desconhecido';
|
||||||
|
setState(prev => ({ ...prev, isLoading: false, error: message }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [apiCall]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setState({ data: null, isLoading: false, error: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ...state, execute, reset };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Funções de API helper
|
||||||
|
export const api = {
|
||||||
|
// Dashboard
|
||||||
|
getDashboardStats: () => apiClient.get('/dashboard/stats'),
|
||||||
|
|
||||||
|
// Empresas
|
||||||
|
getEmpresas: () => apiClient.get('/empresas'),
|
||||||
|
getEmpresa: (id: number) => apiClient.get(`/empresas/${id}`),
|
||||||
|
createEmpresa: (data: unknown) => apiClient.post('/empresas', data),
|
||||||
|
updateEmpresa: (id: number, data: unknown) => apiClient.put(`/empresas/${id}`, data),
|
||||||
|
deleteEmpresa: (id: number) => apiClient.delete(`/empresas/${id}`),
|
||||||
|
|
||||||
|
// Propriedades
|
||||||
|
getPropriedades: (empresaId?: number) =>
|
||||||
|
apiClient.get('/propriedades', { params: empresaId ? { empresa_id: empresaId } : {} }),
|
||||||
|
getPropriedade: (id: number) => apiClient.get(`/propriedades/${id}`),
|
||||||
|
createPropriedade: (data: unknown) => apiClient.post('/propriedades', data),
|
||||||
|
updatePropriedade: (id: number, data: unknown) => apiClient.put(`/propriedades/${id}`, data),
|
||||||
|
deletePropriedade: (id: number) => apiClient.delete(`/propriedades/${id}`),
|
||||||
|
|
||||||
|
// Avaliações
|
||||||
|
getAvaliacoes: (propriedadeId?: number) =>
|
||||||
|
apiClient.get('/avaliacoes', { params: propriedadeId ? { propriedade_id: propriedadeId } : {} }),
|
||||||
|
getAvaliacao: (id: number) => apiClient.get(`/avaliacoes/${id}`),
|
||||||
|
createAvaliacao: (data: unknown) => apiClient.post('/avaliacoes', data),
|
||||||
|
updateAvaliacao: (id: number, data: unknown) => apiClient.put(`/avaliacoes/${id}`, data),
|
||||||
|
gerarDDS: (id: number) => apiClient.post(`/avaliacoes/${id}/gerar-dds`),
|
||||||
|
|
||||||
|
// Documentos
|
||||||
|
getDocumentos: (params?: { propriedade_id?: number; avaliacao_id?: number }) =>
|
||||||
|
apiClient.get('/documentos', { params }),
|
||||||
|
uploadDocumento: (formData: FormData) =>
|
||||||
|
apiClient.post('/documentos/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
}),
|
||||||
|
deleteDocumento: (id: number) => apiClient.delete(`/documentos/${id}`),
|
||||||
|
|
||||||
|
// Usuários
|
||||||
|
getUsuarios: () => apiClient.get('/usuarios'),
|
||||||
|
getUsuario: (id: number) => apiClient.get(`/usuarios/${id}`),
|
||||||
|
createUsuario: (data: unknown) => apiClient.post('/usuarios', data),
|
||||||
|
updateUsuario: (id: number, data: unknown) => apiClient.put(`/usuarios/${id}`, data),
|
||||||
|
deleteUsuario: (id: number) => apiClient.delete(`/usuarios/${id}`),
|
||||||
|
};
|
||||||
12
frontend/src/hooks/useAuth.ts
Normal file
12
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { AuthContext } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
60
frontend/src/index.css
Normal file
60
frontend/src/index.css
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--green: #1A7A4C;
|
||||||
|
--green-hover: #15634D;
|
||||||
|
--navy: #2D3142;
|
||||||
|
--gray: #C8C9CB;
|
||||||
|
--gray-text: #8E9196;
|
||||||
|
--text: #5A5D6B;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
background: #FFFFFF;
|
||||||
|
color: #2D3142;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.glass-card {
|
||||||
|
@apply bg-white border border-gray-200 rounded-2xl p-6 transition-all duration-300;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card:hover {
|
||||||
|
@apply border-primary;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 24px rgba(26, 122, 76, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-primary hover:bg-primary-hover text-white font-semibold py-3 px-8 rounded-xl transition-all duration-300 inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 32px rgba(26, 122, 76, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply border border-gray-custom text-navy hover:border-primary py-3 px-8 rounded-xl transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
@apply w-full bg-gray-bg border border-gray-200 rounded-lg px-4 py-3 text-navy focus:border-primary focus:outline-none transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link {
|
||||||
|
@apply flex items-center gap-3 px-4 py-3 rounded-lg text-gray-text hover:text-primary hover:bg-primary/5 transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link.active {
|
||||||
|
@apply bg-primary/10 text-primary font-medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: #F5F6F8; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #1A7A4C; border-radius: 3px; }
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
245
frontend/src/pages/AvaliacaoDetail.tsx
Normal file
245
frontend/src/pages/AvaliacaoDetail.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { ArrowLeft, Loader2, FileText, MapPin, Calendar, AlertTriangle, CheckCircle, Clock, XCircle } from 'lucide-react';
|
||||||
|
import { api } from '@/hooks/useApi';
|
||||||
|
import { Avaliacao } from '@/types';
|
||||||
|
import DDSModal from '@/components/DDSModal';
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
pendente: { icon: Clock, color: 'bg-amber-100 text-amber-700', label: 'Pendente' },
|
||||||
|
em_analise: { icon: AlertTriangle, color: 'bg-blue-100 text-blue-700', label: 'Em Análise' },
|
||||||
|
aprovada: { icon: CheckCircle, color: 'bg-green-100 text-green-700', label: 'Aprovada' },
|
||||||
|
reprovada: { icon: XCircle, color: 'bg-red-100 text-red-700', label: 'Reprovada' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const riscoConfig = {
|
||||||
|
baixo: { color: 'text-green-600', bg: 'bg-green-100', label: 'Baixo' },
|
||||||
|
medio: { color: 'text-amber-600', bg: 'bg-amber-100', label: 'Médio' },
|
||||||
|
alto: { color: 'text-orange-600', bg: 'bg-orange-100', label: 'Alto' },
|
||||||
|
critico: { color: 'text-red-600', bg: 'bg-red-100', label: 'Crítico' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AvaliacaoDetail() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams();
|
||||||
|
const [avaliacao, setAvaliacao] = useState<Avaliacao | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [showDDSModal, setShowDDSModal] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAvaliacao();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadAvaliacao = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.getAvaliacao(Number(id));
|
||||||
|
setAvaliacao(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar avaliação:', error);
|
||||||
|
// Mock
|
||||||
|
setAvaliacao({
|
||||||
|
id: Number(id),
|
||||||
|
propriedade_id: 1,
|
||||||
|
data_avaliacao: '2024-02-01',
|
||||||
|
status: 'aprovada',
|
||||||
|
risco_desmatamento: 'baixo',
|
||||||
|
score_risco: 15,
|
||||||
|
observacoes: 'Propriedade em conformidade com EUDR. Documentação verificada e validada.',
|
||||||
|
dds_gerada: true,
|
||||||
|
dds_codigo: 'DDS-2024-00001',
|
||||||
|
created_at: '2024-02-01',
|
||||||
|
updated_at: '2024-02-01',
|
||||||
|
propriedade: {
|
||||||
|
id: 1,
|
||||||
|
nome: 'Fazenda Santa Maria',
|
||||||
|
codigo_car: 'MT-5107909-F4B8E35DB1',
|
||||||
|
area_total_ha: 1250.5,
|
||||||
|
cidade: 'Sinop',
|
||||||
|
estado: 'MT',
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!avaliacao) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-muted">Avaliação não encontrada</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = statusConfig[avaliacao.status];
|
||||||
|
const StatusIcon = status.icon;
|
||||||
|
const risco = riscoConfig[avaliacao.risco_desmatamento];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/avaliacoes')}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5 text-navy" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-navy">Avaliação #{avaliacao.id}</h1>
|
||||||
|
<p className="text-gray-text text-sm mt-1">
|
||||||
|
{avaliacao.propriedade?.nome}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDDSModal(true)}
|
||||||
|
disabled={avaliacao.status !== 'aprovada'}
|
||||||
|
className="btn-primary flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<FileText className="w-5 h-5" />
|
||||||
|
{avaliacao.dds_gerada ? 'Ver DDS' : 'Gerar DDS'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
|
{/* Main Info */}
|
||||||
|
<div className="md:col-span-2 space-y-6">
|
||||||
|
{/* Status Card */}
|
||||||
|
<div className="glass-card">
|
||||||
|
<h2 className="text-lg font-semibold text-navy mb-4">Status da Avaliação</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="p-4 bg-gray-50 rounded-xl text-center">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-full ${status.color}`}>
|
||||||
|
<StatusIcon className="w-4 h-4" />
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-gray-muted mt-2">Status</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-50 rounded-xl text-center">
|
||||||
|
<span className={`inline-flex text-sm font-medium px-3 py-1.5 rounded-full ${risco.bg} ${risco.color}`}>
|
||||||
|
{risco.label}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-gray-muted mt-2">Risco</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-50 rounded-xl text-center">
|
||||||
|
<span className="text-2xl font-bold text-navy">{avaliacao.score_risco}%</span>
|
||||||
|
<p className="text-xs text-gray-muted mt-1">Score de Risco</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-50 rounded-xl text-center">
|
||||||
|
<span className={`text-sm font-medium ${avaliacao.dds_gerada ? 'text-green-600' : 'text-gray-muted'}`}>
|
||||||
|
{avaliacao.dds_gerada ? '✓ Gerada' : 'Pendente'}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-gray-muted mt-2">DDS</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Observações */}
|
||||||
|
<div className="glass-card">
|
||||||
|
<h2 className="text-lg font-semibold text-navy mb-4">Observações</h2>
|
||||||
|
<p className="text-gray-text">
|
||||||
|
{avaliacao.observacoes || 'Sem observações registradas.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DDS Info */}
|
||||||
|
{avaliacao.dds_gerada && avaliacao.dds_codigo && (
|
||||||
|
<div className="glass-card bg-green-50 border-green-200">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-green-100 flex items-center justify-center">
|
||||||
|
<FileText className="w-6 h-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-green-800">DDS Gerada com Sucesso</h3>
|
||||||
|
<p className="text-green-700 text-sm mt-1">
|
||||||
|
Código: <span className="font-mono font-semibold">{avaliacao.dds_codigo}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-green-600 text-xs mt-2">
|
||||||
|
Declaração compatível com TRACES NT
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Propriedade Info */}
|
||||||
|
<div className="glass-card">
|
||||||
|
<h2 className="text-lg font-semibold text-navy mb-4">Propriedade</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MapPin className="w-5 h-5 text-primary" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-navy">{avaliacao.propriedade?.nome}</p>
|
||||||
|
<p className="text-xs text-gray-muted">
|
||||||
|
{avaliacao.propriedade?.cidade}/{avaliacao.propriedade?.estado}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-3 border-t border-gray-100">
|
||||||
|
<p className="text-xs text-gray-muted mb-1">Código CAR</p>
|
||||||
|
<p className="font-mono text-sm text-navy">{avaliacao.propriedade?.codigo_car || '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="pt-3 border-t border-gray-100">
|
||||||
|
<p className="text-xs text-gray-muted mb-1">Área Total</p>
|
||||||
|
<p className="font-medium text-navy">
|
||||||
|
{avaliacao.propriedade?.area_total_ha?.toLocaleString('pt-BR')} ha
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Datas */}
|
||||||
|
<div className="glass-card">
|
||||||
|
<h2 className="text-lg font-semibold text-navy mb-4">Datas</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-muted text-sm flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
Avaliação
|
||||||
|
</span>
|
||||||
|
<span className="text-navy font-medium">
|
||||||
|
{new Date(avaliacao.data_avaliacao).toLocaleDateString('pt-BR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-muted text-sm">Criado em</span>
|
||||||
|
<span className="text-navy">
|
||||||
|
{new Date(avaliacao.created_at).toLocaleDateString('pt-BR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-muted text-sm">Atualizado em</span>
|
||||||
|
<span className="text-navy">
|
||||||
|
{new Date(avaliacao.updated_at).toLocaleDateString('pt-BR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DDS Modal */}
|
||||||
|
<DDSModal
|
||||||
|
isOpen={showDDSModal}
|
||||||
|
onClose={() => setShowDDSModal(false)}
|
||||||
|
avaliacao={avaliacao}
|
||||||
|
onSuccess={loadAvaliacao}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
224
frontend/src/pages/Avaliacoes.tsx
Normal file
224
frontend/src/pages/Avaliacoes.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Plus, Search, ClipboardCheck, Calendar, AlertTriangle, CheckCircle, Clock, XCircle } from 'lucide-react';
|
||||||
|
import DataTable from '@/components/DataTable';
|
||||||
|
import { api } from '@/hooks/useApi';
|
||||||
|
import { Avaliacao } from '@/types';
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
pendente: { icon: Clock, color: 'bg-amber-100 text-amber-700', label: 'Pendente' },
|
||||||
|
em_analise: { icon: AlertTriangle, color: 'bg-blue-100 text-blue-700', label: 'Em Análise' },
|
||||||
|
aprovada: { icon: CheckCircle, color: 'bg-green-100 text-green-700', label: 'Aprovada' },
|
||||||
|
reprovada: { icon: XCircle, color: 'bg-red-100 text-red-700', label: 'Reprovada' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const riscoConfig = {
|
||||||
|
baixo: { color: 'bg-green-100 text-green-700', label: 'Baixo' },
|
||||||
|
medio: { color: 'bg-amber-100 text-amber-700', label: 'Médio' },
|
||||||
|
alto: { color: 'bg-orange-100 text-orange-700', label: 'Alto' },
|
||||||
|
critico: { color: 'bg-red-100 text-red-700', label: 'Crítico' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Avaliacoes() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [avaliacoes, setAvaliacoes] = useState<Avaliacao[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAvaliacoes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadAvaliacoes = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.getAvaliacoes();
|
||||||
|
setAvaliacoes(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar avaliações:', error);
|
||||||
|
// Mock data
|
||||||
|
setAvaliacoes([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
propriedade_id: 1,
|
||||||
|
data_avaliacao: '2024-02-01',
|
||||||
|
status: 'aprovada',
|
||||||
|
risco_desmatamento: 'baixo',
|
||||||
|
score_risco: 15,
|
||||||
|
observacoes: 'Propriedade em conformidade com EUDR',
|
||||||
|
dds_gerada: true,
|
||||||
|
dds_codigo: 'DDS-2024-00001',
|
||||||
|
created_at: '2024-02-01',
|
||||||
|
updated_at: '2024-02-01',
|
||||||
|
propriedade: { id: 1, nome: 'Fazenda Santa Maria' } as any,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
propriedade_id: 2,
|
||||||
|
data_avaliacao: '2024-02-05',
|
||||||
|
status: 'em_analise',
|
||||||
|
risco_desmatamento: 'medio',
|
||||||
|
score_risco: 45,
|
||||||
|
observacoes: 'Aguardando documentação adicional',
|
||||||
|
dds_gerada: false,
|
||||||
|
created_at: '2024-02-05',
|
||||||
|
updated_at: '2024-02-05',
|
||||||
|
propriedade: { id: 2, nome: 'Sítio Esperança' } as any,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
propriedade_id: 3,
|
||||||
|
data_avaliacao: '2024-02-08',
|
||||||
|
status: 'pendente',
|
||||||
|
risco_desmatamento: 'baixo',
|
||||||
|
score_risco: 22,
|
||||||
|
dds_gerada: false,
|
||||||
|
created_at: '2024-02-08',
|
||||||
|
updated_at: '2024-02-08',
|
||||||
|
propriedade: { id: 3, nome: 'Estância Boa Vista' } as any,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredAvaliacoes = avaliacoes.filter(
|
||||||
|
a =>
|
||||||
|
a.propriedade?.nome?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
a.dds_codigo?.includes(searchTerm)
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'propriedade',
|
||||||
|
label: 'Propriedade',
|
||||||
|
render: (av: Avaliacao) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<ClipboardCheck className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-navy">{av.propriedade?.nome || '-'}</div>
|
||||||
|
{av.dds_codigo && (
|
||||||
|
<div className="text-xs text-primary font-mono">{av.dds_codigo}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'data_avaliacao',
|
||||||
|
label: 'Data',
|
||||||
|
render: (av: Avaliacao) => (
|
||||||
|
<div className="flex items-center gap-2 text-gray-text">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
{new Date(av.data_avaliacao).toLocaleDateString('pt-BR')}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
render: (av: Avaliacao) => {
|
||||||
|
const config = statusConfig[av.status];
|
||||||
|
const Icon = config.icon;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full ${config.color}`}>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'risco_desmatamento',
|
||||||
|
label: 'Risco',
|
||||||
|
render: (av: Avaliacao) => {
|
||||||
|
const config = riscoConfig[av.risco_desmatamento];
|
||||||
|
return (
|
||||||
|
<span className={`text-xs font-medium px-2.5 py-1 rounded-full ${config.color}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'score_risco',
|
||||||
|
label: 'Score',
|
||||||
|
render: (av: Avaliacao) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-16 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${
|
||||||
|
av.score_risco <= 30 ? 'bg-green-500' :
|
||||||
|
av.score_risco <= 60 ? 'bg-amber-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${av.score_risco}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-navy">{av.score_risco}%</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dds_gerada',
|
||||||
|
label: 'DDS',
|
||||||
|
render: (av: Avaliacao) => (
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium px-2 py-1 rounded-full ${
|
||||||
|
av.dds_gerada
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-gray-100 text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{av.dds_gerada ? 'Gerada' : 'Pendente'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-navy">Avaliações</h1>
|
||||||
|
<p className="text-gray-text text-sm mt-1">
|
||||||
|
Avaliações de due diligence EUDR
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/avaliacoes/nova')}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
Nova Avaliação
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-muted" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por propriedade ou código DDS..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
|
className="input-field pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={filteredAvaliacoes}
|
||||||
|
isLoading={isLoading}
|
||||||
|
keyExtractor={av => av.id}
|
||||||
|
onRowClick={av => navigate(`/avaliacoes/${av.id}`)}
|
||||||
|
emptyMessage="Nenhuma avaliação encontrada"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
frontend/src/pages/Dashboard.tsx
Normal file
179
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
import { Building2, MapPin, ClipboardCheck, FileText, Loader2 } from 'lucide-react';
|
||||||
|
import StatsCard from '@/components/StatsCard';
|
||||||
|
import { api } from '@/hooks/useApi';
|
||||||
|
import { DashboardStats } from '@/types';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.getDashboardStats();
|
||||||
|
setStats(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar stats:', error);
|
||||||
|
// Mock data para desenvolvimento
|
||||||
|
setStats({
|
||||||
|
total_empresas: 12,
|
||||||
|
total_propriedades: 48,
|
||||||
|
total_avaliacoes: 156,
|
||||||
|
avaliacoes_aprovadas: 142,
|
||||||
|
avaliacoes_pendentes: 8,
|
||||||
|
dds_geradas: 134,
|
||||||
|
avaliacoes_por_mes: [
|
||||||
|
{ mes: 'Set', total: 18 },
|
||||||
|
{ mes: 'Out', total: 24 },
|
||||||
|
{ mes: 'Nov', total: 32 },
|
||||||
|
{ mes: 'Dez', total: 28 },
|
||||||
|
{ mes: 'Jan', total: 35 },
|
||||||
|
{ mes: 'Fev', total: 19 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taxaAprovacao = stats
|
||||||
|
? Math.round((stats.avaliacoes_aprovadas / stats.total_avaliacoes) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-navy">Dashboard</h1>
|
||||||
|
<p className="text-gray-text text-sm mt-1">Visão geral do compliance EUDR</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-muted">
|
||||||
|
Última atualização: {new Date().toLocaleString('pt-BR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<StatsCard
|
||||||
|
icon={<Building2 className="w-6 h-6 text-primary" />}
|
||||||
|
label="Empresas"
|
||||||
|
value={stats?.total_empresas || 0}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
icon={<MapPin className="w-6 h-6 text-primary" />}
|
||||||
|
label="Propriedades"
|
||||||
|
value={stats?.total_propriedades || 0}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
icon={<ClipboardCheck className="w-6 h-6 text-primary" />}
|
||||||
|
label="Avaliações"
|
||||||
|
value={stats?.total_avaliacoes || 0}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
icon={<FileText className="w-6 h-6 text-primary" />}
|
||||||
|
label="DDS Geradas"
|
||||||
|
value={stats?.dds_geradas || 0}
|
||||||
|
trend={`${taxaAprovacao}%`}
|
||||||
|
trendUp={taxaAprovacao >= 90}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-6 mb-8">
|
||||||
|
{/* Gráfico de Avaliações por Mês */}
|
||||||
|
<div className="glass-card">
|
||||||
|
<h2 className="text-lg font-semibold text-navy mb-4">📊 Avaliações por Mês</h2>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={stats?.avaliacoes_por_mes || []}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E4E8" />
|
||||||
|
<XAxis dataKey="mes" stroke="#8E9196" fontSize={12} />
|
||||||
|
<YAxis stroke="#8E9196" fontSize={12} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid #E2E4E8',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="total" fill="#1A7A4C" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resumo */}
|
||||||
|
<div className="glass-card">
|
||||||
|
<h2 className="text-lg font-semibold text-navy mb-4">📋 Resumo</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
||||||
|
<span className="text-sm text-gray-text">Avaliações Aprovadas</span>
|
||||||
|
<span className="font-semibold text-green-600">{stats?.avaliacoes_aprovadas || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-amber-50 rounded-lg">
|
||||||
|
<span className="text-sm text-gray-text">Avaliações Pendentes</span>
|
||||||
|
<span className="font-semibold text-amber-600">{stats?.avaliacoes_pendentes || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-primary/5 rounded-lg">
|
||||||
|
<span className="text-sm text-gray-text">Taxa de Aprovação</span>
|
||||||
|
<span className="font-semibold text-primary">{taxaAprovacao}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<span className="text-sm text-gray-text">DDS Geradas</span>
|
||||||
|
<span className="font-semibold text-navy">{stats?.dds_geradas || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="glass-card">
|
||||||
|
<h2 className="text-lg font-semibold text-navy mb-4">⚡ Ações Rápidas</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<a
|
||||||
|
href="/empresas"
|
||||||
|
className="p-4 bg-gray-50 rounded-xl hover:bg-primary/5 hover:border-primary border border-transparent transition text-center"
|
||||||
|
>
|
||||||
|
<Building2 className="w-8 h-8 text-primary mx-auto mb-2" />
|
||||||
|
<span className="text-sm text-navy font-medium">Nova Empresa</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/propriedades"
|
||||||
|
className="p-4 bg-gray-50 rounded-xl hover:bg-primary/5 hover:border-primary border border-transparent transition text-center"
|
||||||
|
>
|
||||||
|
<MapPin className="w-8 h-8 text-primary mx-auto mb-2" />
|
||||||
|
<span className="text-sm text-navy font-medium">Nova Propriedade</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/avaliacoes"
|
||||||
|
className="p-4 bg-gray-50 rounded-xl hover:bg-primary/5 hover:border-primary border border-transparent transition text-center"
|
||||||
|
>
|
||||||
|
<ClipboardCheck className="w-8 h-8 text-primary mx-auto mb-2" />
|
||||||
|
<span className="text-sm text-navy font-medium">Nova Avaliação</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/documentos"
|
||||||
|
className="p-4 bg-gray-50 rounded-xl hover:bg-primary/5 hover:border-primary border border-transparent transition text-center"
|
||||||
|
>
|
||||||
|
<FileText className="w-8 h-8 text-primary mx-auto mb-2" />
|
||||||
|
<span className="text-sm text-navy font-medium">Upload Documento</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
296
frontend/src/pages/Documentos.tsx
Normal file
296
frontend/src/pages/Documentos.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { FileText, Upload, Search, Trash2, Download, File, Image, FileSpreadsheet, Loader2 } from 'lucide-react';
|
||||||
|
import DataTable from '@/components/DataTable';
|
||||||
|
import Modal from '@/components/Modal';
|
||||||
|
import { api } from '@/hooks/useApi';
|
||||||
|
import { Documento } from '@/types';
|
||||||
|
|
||||||
|
const iconByType: Record<string, typeof File> = {
|
||||||
|
'application/pdf': FileText,
|
||||||
|
'image/jpeg': Image,
|
||||||
|
'image/png': Image,
|
||||||
|
'application/vnd.ms-excel': FileSpreadsheet,
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': FileSpreadsheet,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Documentos() {
|
||||||
|
const [documentos, setDocumentos] = useState<Documento[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [selectedDoc, setSelectedDoc] = useState<Documento | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDocumentos();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadDocumentos = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.getDocumentos();
|
||||||
|
setDocumentos(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar documentos:', error);
|
||||||
|
// Mock data
|
||||||
|
setDocumentos([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
nome: 'CAR_Fazenda_Santa_Maria.pdf',
|
||||||
|
tipo: 'application/pdf',
|
||||||
|
tamanho: 2456789,
|
||||||
|
url: '/uploads/car_fazenda.pdf',
|
||||||
|
propriedade_id: 1,
|
||||||
|
uploaded_by: 1,
|
||||||
|
created_at: '2024-02-01T10:30:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
nome: 'Licença_Ambiental_2024.pdf',
|
||||||
|
tipo: 'application/pdf',
|
||||||
|
tamanho: 1234567,
|
||||||
|
url: '/uploads/licenca.pdf',
|
||||||
|
empresa_id: 1,
|
||||||
|
uploaded_by: 1,
|
||||||
|
created_at: '2024-02-03T14:15:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
nome: 'Mapa_Propriedade.png',
|
||||||
|
tipo: 'image/png',
|
||||||
|
tamanho: 3456789,
|
||||||
|
url: '/uploads/mapa.png',
|
||||||
|
propriedade_id: 1,
|
||||||
|
uploaded_by: 1,
|
||||||
|
created_at: '2024-02-05T09:00:00',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
await api.uploadDocumento(formData);
|
||||||
|
}
|
||||||
|
loadDocumentos();
|
||||||
|
setShowUploadModal(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao fazer upload:', error);
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!selectedDoc) return;
|
||||||
|
try {
|
||||||
|
await api.deleteDocumento(selectedDoc.id);
|
||||||
|
loadDocumentos();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao excluir documento:', error);
|
||||||
|
}
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
setSelectedDoc(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredDocumentos = documentos.filter(d =>
|
||||||
|
d.nome.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'nome',
|
||||||
|
label: 'Documento',
|
||||||
|
render: (doc: Documento) => {
|
||||||
|
const Icon = iconByType[doc.tipo] || File;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<Icon className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-navy">{doc.nome}</div>
|
||||||
|
<div className="text-xs text-gray-muted">{formatFileSize(doc.tamanho)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tipo',
|
||||||
|
label: 'Tipo',
|
||||||
|
render: (doc: Documento) => (
|
||||||
|
<span className="text-sm text-gray-text">
|
||||||
|
{doc.tipo.split('/').pop()?.toUpperCase() || 'Arquivo'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'created_at',
|
||||||
|
label: 'Data Upload',
|
||||||
|
render: (doc: Documento) => (
|
||||||
|
<span className="text-gray-text">
|
||||||
|
{new Date(doc.created_at).toLocaleDateString('pt-BR')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'acoes',
|
||||||
|
label: 'Ações',
|
||||||
|
render: (doc: Documento) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={doc.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 text-gray-muted" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedDoc(doc);
|
||||||
|
setShowDeleteModal(true);
|
||||||
|
}}
|
||||||
|
className="p-2 hover:bg-red-50 rounded-lg transition"
|
||||||
|
title="Excluir"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-navy">Documentos</h1>
|
||||||
|
<p className="text-gray-text text-sm mt-1">
|
||||||
|
Gestão de documentos e arquivos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUploadModal(true)}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Upload className="w-5 h-5" />
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-muted" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nome do arquivo..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
|
className="input-field pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={filteredDocumentos}
|
||||||
|
isLoading={isLoading}
|
||||||
|
keyExtractor={doc => doc.id}
|
||||||
|
emptyMessage="Nenhum documento encontrado"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Upload Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showUploadModal}
|
||||||
|
onClose={() => setShowUploadModal(false)}
|
||||||
|
title="Upload de Documentos"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div
|
||||||
|
className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center hover:border-primary transition cursor-pointer"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="w-12 h-12 text-primary mx-auto animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="w-12 h-12 text-gray-muted mx-auto mb-4" />
|
||||||
|
)}
|
||||||
|
<p className="text-gray-text mb-2">
|
||||||
|
{isUploading ? 'Enviando...' : 'Clique para selecionar arquivos'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-muted">
|
||||||
|
PDF, imagens ou planilhas (máx. 10MB)
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
multiple
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.xls,.xlsx"
|
||||||
|
onChange={handleUpload}
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showDeleteModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
setSelectedDoc(null);
|
||||||
|
}}
|
||||||
|
title="Confirmar Exclusão"
|
||||||
|
>
|
||||||
|
<p className="text-gray-text mb-6">
|
||||||
|
Tem certeza que deseja excluir o documento <strong>{selectedDoc?.nome}</strong>?
|
||||||
|
Esta ação não pode ser desfeita.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
setSelectedDoc(null);
|
||||||
|
}}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-red-500 hover:bg-red-600 text-white font-semibold py-3 px-8 rounded-xl transition"
|
||||||
|
>
|
||||||
|
Excluir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
298
frontend/src/pages/EmpresaForm.tsx
Normal file
298
frontend/src/pages/EmpresaForm.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ArrowLeft, Loader2, Save, Trash2 } from 'lucide-react';
|
||||||
|
import { api } from '@/hooks/useApi';
|
||||||
|
import Modal from '@/components/Modal';
|
||||||
|
|
||||||
|
const empresaSchema = z.object({
|
||||||
|
nome: z.string().min(3, 'Nome deve ter no mínimo 3 caracteres'),
|
||||||
|
cnpj: z.string().min(14, 'CNPJ inválido'),
|
||||||
|
email: z.string().email('Email inválido').optional().or(z.literal('')),
|
||||||
|
telefone: z.string().optional(),
|
||||||
|
endereco: z.string().optional(),
|
||||||
|
cidade: z.string().optional(),
|
||||||
|
estado: z.string().max(2, 'Use a sigla do estado').optional(),
|
||||||
|
cep: z.string().optional(),
|
||||||
|
eu_operator_id: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type EmpresaFormData = z.infer<typeof empresaSchema>;
|
||||||
|
|
||||||
|
export default function EmpresaForm() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams();
|
||||||
|
const isEditing = Boolean(id);
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<EmpresaFormData>({
|
||||||
|
resolver: zodResolver(empresaSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing) {
|
||||||
|
loadEmpresa();
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadEmpresa = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.getEmpresa(Number(id));
|
||||||
|
reset(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar empresa:', error);
|
||||||
|
setError('Empresa não encontrada');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: EmpresaFormData) => {
|
||||||
|
setIsSaving(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditing) {
|
||||||
|
await api.updateEmpresa(Number(id), data);
|
||||||
|
} else {
|
||||||
|
await api.createEmpresa(data);
|
||||||
|
}
|
||||||
|
navigate('/empresas');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } };
|
||||||
|
setError(error.response?.data?.detail || 'Erro ao salvar empresa');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await api.deleteEmpresa(Number(id));
|
||||||
|
navigate('/empresas');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } };
|
||||||
|
setError(error.response?.data?.detail || 'Erro ao excluir empresa');
|
||||||
|
}
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/empresas')}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5 text-navy" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-navy">
|
||||||
|
{isEditing ? 'Editar Empresa' : 'Nova Empresa'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-text text-sm mt-1">
|
||||||
|
{isEditing ? 'Atualize os dados da empresa' : 'Cadastre uma nova empresa operadora'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="glass-card max-w-2xl">
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-red-600 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="text-sm text-gray-text mb-1 block">Nome da Empresa *</label>
|
||||||
|
<input
|
||||||
|
{...register('nome')}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="Nome da empresa"
|
||||||
|
/>
|
||||||
|
{errors.nome && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.nome.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-text mb-1 block">CNPJ *</label>
|
||||||
|
<input
|
||||||
|
{...register('cnpj')}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="00.000.000/0000-00"
|
||||||
|
/>
|
||||||
|
{errors.cnpj && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.cnpj.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-text mb-1 block">EU Operator ID</label>
|
||||||
|
<input
|
||||||
|
{...register('eu_operator_id')}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="BR-OP-XXXX-XXX"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-text mb-1 block">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
{...register('email')}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="contato@empresa.com"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-text mb-1 block">Telefone</label>
|
||||||
|
<input
|
||||||
|
{...register('telefone')}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="(00) 00000-0000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="text-sm text-gray-text mb-1 block">Endereço</label>
|
||||||
|
<input
|
||||||
|
{...register('endereco')}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="Rua, número, bairro"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-text mb-1 block">Cidade</label>
|
||||||
|
<input
|
||||||
|
{...register('cidade')}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="Cidade"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-text mb-1 block">Estado</label>
|
||||||
|
<input
|
||||||
|
{...register('estado')}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="UF"
|
||||||
|
maxLength={2}
|
||||||
|
/>
|
||||||
|
{errors.estado && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.estado.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-text mb-1 block">CEP</label>
|
||||||
|
<input
|
||||||
|
{...register('cep')}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="00000-000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||||
|
{isEditing ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeleteModal(true)}
|
||||||
|
className="flex items-center gap-2 text-red-500 hover:text-red-600 transition"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
Excluir
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/empresas')}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
Salvando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-5 h-5" />
|
||||||
|
Salvar
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showDeleteModal}
|
||||||
|
onClose={() => setShowDeleteModal(false)}
|
||||||
|
title="Confirmar Exclusão"
|
||||||
|
>
|
||||||
|
<p className="text-gray-text mb-6">
|
||||||
|
Tem certeza que deseja excluir esta empresa? Esta ação não pode ser desfeita.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteModal(false)}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-red-500 hover:bg-red-600 text-white font-semibold py-3 px-8 rounded-xl transition"
|
||||||
|
>
|
||||||
|
Excluir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
frontend/src/pages/Empresas.tsx
Normal file
188
frontend/src/pages/Empresas.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Plus, Search, Building2, MapPin, Phone } from 'lucide-react';
|
||||||
|
import DataTable from '@/components/DataTable';
|
||||||
|
import { api } from '@/hooks/useApi';
|
||||||
|
import { Empresa } from '@/types';
|
||||||
|
|
||||||
|
export default function Empresas() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [empresas, setEmpresas] = useState<Empresa[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadEmpresas();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadEmpresas = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.getEmpresas();
|
||||||
|
setEmpresas(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar empresas:', error);
|
||||||
|
// Mock data
|
||||||
|
setEmpresas([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
nome: 'AgroBrasil Exportações',
|
||||||
|
cnpj: '12.345.678/0001-99',
|
||||||
|
email: 'contato@agrobrasil.com',
|
||||||
|
telefone: '(11) 99999-9999',
|
||||||
|
cidade: 'São Paulo',
|
||||||
|
estado: 'SP',
|
||||||
|
eu_operator_id: 'BR-OP-2024-001',
|
||||||
|
ativo: true,
|
||||||
|
created_at: '2024-01-15',
|
||||||
|
updated_at: '2024-01-15',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
nome: 'Fazendas União LTDA',
|
||||||
|
cnpj: '98.765.432/0001-11',
|
||||||
|
email: 'contato@fazendasuniao.com.br',
|
||||||
|
telefone: '(62) 98888-8888',
|
||||||
|
cidade: 'Goiânia',
|
||||||
|
estado: 'GO',
|
||||||
|
eu_operator_id: 'BR-OP-2024-002',
|
||||||
|
ativo: true,
|
||||||
|
created_at: '2024-02-20',
|
||||||
|
updated_at: '2024-02-20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
nome: 'Cooperativa Grãos do Sul',
|
||||||
|
cnpj: '55.444.333/0001-22',
|
||||||
|
email: 'admin@graosul.coop.br',
|
||||||
|
telefone: '(51) 97777-7777',
|
||||||
|
cidade: 'Porto Alegre',
|
||||||
|
estado: 'RS',
|
||||||
|
eu_operator_id: 'BR-OP-2024-003',
|
||||||
|
ativo: true,
|
||||||
|
created_at: '2024-03-10',
|
||||||
|
updated_at: '2024-03-10',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredEmpresas = empresas.filter(
|
||||||
|
e =>
|
||||||
|
e.nome.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
e.cnpj.includes(searchTerm)
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'nome',
|
||||||
|
label: 'Empresa',
|
||||||
|
render: (empresa: Empresa) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<Building2 className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-navy">{empresa.nome}</div>
|
||||||
|
<div className="text-xs text-gray-muted">{empresa.cnpj}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'localizacao',
|
||||||
|
label: 'Localização',
|
||||||
|
render: (empresa: Empresa) => (
|
||||||
|
<div className="flex items-center gap-2 text-gray-text">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
{empresa.cidade}/{empresa.estado}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contato',
|
||||||
|
label: 'Contato',
|
||||||
|
render: (empresa: Empresa) => (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm text-navy">{empresa.email}</div>
|
||||||
|
{empresa.telefone && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-muted">
|
||||||
|
<Phone className="w-3 h-3" />
|
||||||
|
{empresa.telefone}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'eu_operator_id',
|
||||||
|
label: 'EU Operator ID',
|
||||||
|
render: (empresa: Empresa) => (
|
||||||
|
<span className="font-mono text-xs text-primary bg-primary/10 px-2 py-1 rounded">
|
||||||
|
{empresa.eu_operator_id || '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
render: (empresa: Empresa) => (
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium px-2 py-1 rounded-full ${
|
||||||
|
empresa.ativo
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-gray-100 text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{empresa.ativo ? 'Ativa' : 'Inativa'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-navy">Empresas</h1>
|
||||||
|
<p className="text-gray-text text-sm mt-1">
|
||||||
|
Gestão de empresas operadoras EUDR
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/empresas/nova')}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
Nova Empresa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-muted" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nome ou CNPJ..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
|
className="input-field pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={filteredEmpresas}
|
||||||
|
isLoading={isLoading}
|
||||||
|
keyExtractor={empresa => empresa.id}
|
||||||
|
onRowClick={empresa => navigate(`/empresas/${empresa.id}`)}
|
||||||
|
emptyMessage="Nenhuma empresa encontrada"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
258
frontend/src/pages/Landing.tsx
Normal file
258
frontend/src/pages/Landing.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Navbar from '@/components/Navbar';
|
||||||
|
import Footer from '@/components/Footer';
|
||||||
|
import { Shield, Map, FileText, Link2, BarChart3, Globe, Zap, CheckCircle2, Server } from 'lucide-react';
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: <Shield className="w-8 h-8" />,
|
||||||
|
title: 'Due Diligence Automatizada',
|
||||||
|
desc: 'Motor de avaliação EUDR com checklist completo, scoring de risco e flags automáticas',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Map className="w-8 h-8" />,
|
||||||
|
title: 'Geolocalização Avançada',
|
||||||
|
desc: 'Mapeamento de áreas com GeoJSON/KML, detecção de sobreposição e análise de desmatamento',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FileText className="w-8 h-8" />,
|
||||||
|
title: 'DDS Automatizada',
|
||||||
|
desc: 'Geração, revisão e envio de Declarações de Due Diligence diretamente para a UE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Link2 className="w-8 h-8" />,
|
||||||
|
title: 'Rastreabilidade Completa',
|
||||||
|
desc: 'Cadeia de custódia do produtor ao porto, com histórico imutável de auditoria',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BarChart3 className="w-8 h-8" />,
|
||||||
|
title: 'Dashboard Inteligente',
|
||||||
|
desc: 'Visão consolidada de risco, status de DDS, alertas e KPIs em tempo real',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Server className="w-8 h-8" />,
|
||||||
|
title: 'Integração Direta via API Oficial EU',
|
||||||
|
desc: 'Conexão machine-to-machine com a API EUDR oficial — submissão automática de DDS',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ value: '5.2K+', label: 'Produtores' },
|
||||||
|
{ value: '180K+', label: 'Lotes Rastreados' },
|
||||||
|
{ value: '2.4K+', label: 'DDS Enviadas' },
|
||||||
|
{ value: '99.2%', label: 'Aprovação' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const apiFeatures = [
|
||||||
|
{
|
||||||
|
icon: <Zap className="w-6 h-6" />,
|
||||||
|
title: 'Submissão Automática',
|
||||||
|
desc: 'Envie DDS diretamente do DuOrigin para o sistema EUDR sem exportar arquivos',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <CheckCircle2 className="w-6 h-6" />,
|
||||||
|
title: 'Status em Tempo Real',
|
||||||
|
desc: 'Acompanhe o processamento: SUBMITTED → AVAILABLE com número de referência',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Link2 className="w-6 h-6" />,
|
||||||
|
title: 'Cadeia de Suprimentos',
|
||||||
|
desc: 'Referencie DDS de fornecedores e navegue pela supply chain integrada',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Shield className="w-6 h-6" />,
|
||||||
|
title: 'Conformance Certified',
|
||||||
|
desc: 'Aprovado em todos os 7 Conformance Tests exigidos pela Comissão Europeia',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Landing() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="pt-32 pb-20 px-6 bg-gradient-to-b from-white to-gray-bg">
|
||||||
|
<div className="max-w-5xl mx-auto text-center">
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20 mb-4">
|
||||||
|
<span className="inline-block w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||||
|
API EUDR v1.4 Integrada
|
||||||
|
</div>
|
||||||
|
<div className="inline-block px-4 py-1.5 rounded-full text-xs font-medium bg-navy/10 text-navy border border-navy/20 mb-8 ml-2">
|
||||||
|
🇪🇺 Conformidade EUDR 2025 — Regulamento (UE) 2023/1115
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl md:text-7xl font-bold mb-6 leading-tight">
|
||||||
|
<span className="text-navy">DUO</span>
|
||||||
|
<span className="text-primary">ORIGIN</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-navy/80">Compliance EUDR</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-primary text-4xl md:text-5xl">Inteligente</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-text max-w-2xl mx-auto mb-10">
|
||||||
|
Plataforma completa para gestão de compliance EUDR no agronegócio brasileiro.
|
||||||
|
Due diligence automatizada, rastreabilidade e <strong>integração direta com a API oficial da UE</strong>.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<Link to="/login" className="btn-primary text-lg">
|
||||||
|
Começar Agora
|
||||||
|
</Link>
|
||||||
|
<button className="btn-secondary">
|
||||||
|
Agendar Demo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="mt-16 grid grid-cols-2 md:grid-cols-4 gap-8 max-w-2xl mx-auto">
|
||||||
|
{stats.map(stat => (
|
||||||
|
<div key={stat.label} className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-primary">{stat.value}</div>
|
||||||
|
<div className="text-xs text-gray-muted mt-1">{stat.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* API Integration Section - NEW */}
|
||||||
|
<section className="py-20 px-6 bg-gradient-to-r from-navy to-navy/90">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-semibold bg-green-500/20 text-green-400 border border-green-500/30 mb-4">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
API EUDR v1.4 Certified
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||||
|
Integração Oficial <span className="text-primary">EUDR</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-300 max-w-2xl mx-auto">
|
||||||
|
O DuOrigin conecta diretamente com a API oficial da Comissão Europeia via SOAP/WSDL.
|
||||||
|
Submeta, acompanhe e gerencie suas DDS sem sair da plataforma.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{apiFeatures.map(f => (
|
||||||
|
<div key={f.title} className="bg-white/5 backdrop-blur border border-white/10 rounded-xl p-6 hover:bg-white/10 transition-colors">
|
||||||
|
<div className="text-primary mb-4">{f.icon}</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">{f.title}</h3>
|
||||||
|
<p className="text-sm text-gray-400">{f.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 text-center">
|
||||||
|
<div className="inline-flex items-center gap-4 bg-white/5 border border-white/10 rounded-lg px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe className="w-5 h-5 text-primary" />
|
||||||
|
<span className="text-white font-medium">Ambientes Suportados:</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-300">ACCEPTANCE (Testes)</span>
|
||||||
|
<span className="text-gray-500">|</span>
|
||||||
|
<span className="text-gray-300">PRODUCTION (Produção)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<section id="features" className="py-20 px-6 bg-white">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-center mb-4 text-navy">
|
||||||
|
Recursos <span className="text-primary">Completos</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-muted text-center mb-12">
|
||||||
|
Tudo que você precisa para compliance EUDR
|
||||||
|
</p>
|
||||||
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
|
{features.map(f => (
|
||||||
|
<div key={f.title} className="glass-card">
|
||||||
|
<div className="text-primary mb-4">{f.icon}</div>
|
||||||
|
<h3 className="text-lg font-semibold text-navy mb-2">{f.title}</h3>
|
||||||
|
<p className="text-sm text-gray-text">{f.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Technical Specs - NEW */}
|
||||||
|
<section className="py-16 px-6 bg-gray-bg">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-2xl font-bold text-center mb-8 text-navy">
|
||||||
|
Especificações <span className="text-primary">Técnicas</span>
|
||||||
|
</h2>
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div className="grid md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-gray-200">
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="font-semibold text-navy mb-4">API EUDR</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-text">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||||
|
Protocolo SOAP/WSDL
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||||
|
WS-Security UsernameToken Digest
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||||
|
GeoJSON para geolocalização
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||||
|
Suporte V1 e V2 dos serviços
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="font-semibold text-navy mb-4">Serviços Disponíveis</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-text">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs bg-gray-100 px-1.5 py-0.5 rounded">submitDDS</span>
|
||||||
|
Submissão de DDS
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs bg-gray-100 px-1.5 py-0.5 rounded">amendDDS</span>
|
||||||
|
Alteração de DDS
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs bg-gray-100 px-1.5 py-0.5 rounded">retractDds</span>
|
||||||
|
Cancelar/Retirar
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs bg-gray-100 px-1.5 py-0.5 rounded">getDDSInfo</span>
|
||||||
|
Status e Referência
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section id="about" className="py-20 px-6 bg-white">
|
||||||
|
<div className="max-w-3xl mx-auto text-center">
|
||||||
|
<h2 className="text-3xl font-bold mb-6 text-navy">
|
||||||
|
Pronto para garantir sua <span className="text-primary">conformidade EUDR</span>?
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-text mb-8">
|
||||||
|
Entre em contato para uma demonstração personalizada do DuoOrigin
|
||||||
|
e veja a integração com a API EUDR em ação.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<Link to="/login" className="btn-primary text-lg">
|
||||||
|
Acessar Demo
|
||||||
|
</Link>
|
||||||
|
<a href="mailto:contato@duorigin.com" className="btn-secondary">
|
||||||
|
Falar com Especialista
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
frontend/src/pages/Login.tsx
Normal file
157
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { Loader2, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('Email inválido'),
|
||||||
|
password: z.string().min(4, 'Senha deve ter no mínimo 4 caracteres'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginFormData = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
const demoUsers = [
|
||||||
|
{ email: 'demo@duorigin.com', password: 'DuoDemo2026', role: 'Admin', desc: 'Acesso completo' },
|
||||||
|
{ email: 'operador@duorigin.com', password: 'DuoDemo2026', role: 'Operador', desc: 'Acesso operacional' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/dashboard';
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginFormData>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: LoginFormData) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(data);
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } };
|
||||||
|
setError(error.response?.data?.detail || (error.response?.data as any)?.message || 'Email ou senha inválidos');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fillDemo = (idx: number) => {
|
||||||
|
setValue('email', demoUsers[idx].email);
|
||||||
|
setValue('password', demoUsers[idx].password);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-bg flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Link to="/">
|
||||||
|
<img
|
||||||
|
src="/logo-duorigin.jpg"
|
||||||
|
alt="DuoOrigin"
|
||||||
|
className="w-20 h-20 rounded-xl mx-auto mb-4"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-navy">
|
||||||
|
Duo<span className="text-primary">Origin</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-muted mt-1">Field to Data. Compliance Verified.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Card */}
|
||||||
|
<div className="bg-white rounded-2xl p-8 border border-gray-200 shadow-lg">
|
||||||
|
<h2 className="text-xl font-semibold text-navy mb-6">Acesso ao Sistema</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-text mb-1 block">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
{...register('email')}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="seu@email.com"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-text mb-1 block">Senha</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
{...register('password')}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 flex items-center gap-2 text-red-600 text-sm">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="btn-primary w-full flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
Entrando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Entrar'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<Link to="/registro" className="text-sm text-primary hover:underline">
|
||||||
|
Não tem conta? Criar conta
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Demo Access */}
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<p className="text-sm text-gray-muted mb-3 text-center">🔑 Acesso Demo</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{demoUsers.map((user, idx) => (
|
||||||
|
<button
|
||||||
|
key={user.email}
|
||||||
|
onClick={() => fillDemo(idx)}
|
||||||
|
className="w-full bg-gray-bg hover:bg-gray-100 border border-gray-200 hover:border-primary/20 rounded-lg p-3 text-left transition"
|
||||||
|
>
|
||||||
|
<div className="text-primary font-semibold text-sm">👤 {user.role}</div>
|
||||||
|
<div className="text-gray-muted text-xs">{user.email} — {user.desc}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user