DuOrigin v2 - React + NestJS + Prisma + EUDR API Integration

This commit is contained in:
2026-02-09 09:10:15 -03:00
parent cb90bad239
commit 4122dc6f3b
170 changed files with 31333 additions and 200 deletions

17
.env.example Normal file
View 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
View File

@@ -1,36 +1,6 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.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
node_modules/
dist/
.next/
*.log
.env
.env.local

33
backend/.env.example Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateAvaliacaoDto } from './create-avaliacao.dto';
export class UpdateAvaliacaoDto extends PartialType(CreateAvaliacaoDto) {}

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

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

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

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

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

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

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

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateEmpresaDto } from './create-empresa.dto';
export class UpdateEmpresaDto extends PartialType(CreateEmpresaDto) {}

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

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

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

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

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

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

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

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

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

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

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

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

View 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 }],
};
}
}
}

View File

@@ -0,0 +1,4 @@
export * from './eudr-api.module';
export * from './eudr-api.service';
export * from './eudr-api.controller';
export * from './dto';

View File

@@ -0,0 +1,2 @@
export * from './xml-builder';
export * from './xml-parser';

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* 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>`;
}

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

View 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('&amp;');
expect(escaped).toContain('&lt;');
expect(escaped).toContain('&gt;');
expect(escaped).toContain('&quot;');
expect(escaped).toContain('&apos;');
});
});
});
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
View 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();

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

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

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

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreatePropriedadeDto } from './create-propriedade.dto';
export class UpdatePropriedadeDto extends PartialType(CreatePropriedadeDto) {}

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

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

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

@@ -0,0 +1,15 @@
import { Multer } from 'multer';
declare global {
namespace Express {
interface Request {
user?: {
sub: number;
email: string;
role: string;
};
}
}
}
export {};

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

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}

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

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

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

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

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

77
frontend/src/App.tsx Normal file
View 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;

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

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,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>
);
}

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

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

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

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

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

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

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

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

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

View 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}`),
};

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

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

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

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

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

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

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

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

View 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