feat(phase2): implement Trust, Safety & Financials
- Backend: Add Financial logic (Ledger, Withdrawals) and KYC Endpoints - Backoffice: Update Prisma Schema and add KYC Review Module - Frontend: Add Company Profile, Wallet View, and Reviews/Ratings UI - Frontend: Enhance Admin Companies Page for KYC Review Queue
This commit is contained in:
parent
bbe6ec447e
commit
b41f1f6a52
23 changed files with 1487 additions and 681 deletions
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
"github.com/saveinmed/backend-go/internal/domain"
|
||||
"github.com/saveinmed/backend-go/internal/notifications"
|
||||
"github.com/saveinmed/backend-go/internal/usecase"
|
||||
)
|
||||
|
||||
|
|
@ -296,6 +297,41 @@ func (m *MockRepository) ListShipments(ctx context.Context, filter domain.Shipme
|
|||
return []domain.Shipment{}, 0, nil
|
||||
}
|
||||
|
||||
// Financial Methods
|
||||
func (m *MockRepository) CreateDocument(ctx context.Context, doc *domain.CompanyDocument) error {
|
||||
id, _ := uuid.NewV7()
|
||||
doc.ID = id
|
||||
return nil // Simulate creation
|
||||
}
|
||||
|
||||
func (m *MockRepository) ListDocuments(ctx context.Context, companyID uuid.UUID) ([]domain.CompanyDocument, error) {
|
||||
return []domain.CompanyDocument{}, nil
|
||||
}
|
||||
|
||||
func (m *MockRepository) RecordLedgerEntry(ctx context.Context, entry *domain.LedgerEntry) error {
|
||||
id, _ := uuid.NewV7()
|
||||
entry.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockRepository) GetLedger(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]domain.LedgerEntry, int64, error) {
|
||||
return []domain.LedgerEntry{}, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockRepository) GetBalance(ctx context.Context, companyID uuid.UUID) (int64, error) {
|
||||
return 100000, nil // Simulate some balance
|
||||
}
|
||||
|
||||
func (m *MockRepository) CreateWithdrawal(ctx context.Context, withdrawal *domain.Withdrawal) error {
|
||||
id, _ := uuid.NewV7()
|
||||
withdrawal.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockRepository) ListWithdrawals(ctx context.Context, companyID uuid.UUID) ([]domain.Withdrawal, error) {
|
||||
return []domain.Withdrawal{}, nil
|
||||
}
|
||||
|
||||
// MockPaymentGateway implements the PaymentGateway interface for testing
|
||||
type MockPaymentGateway struct{}
|
||||
|
||||
|
|
@ -311,7 +347,8 @@ func (m *MockPaymentGateway) ParseWebhook(ctx context.Context, payload []byte) (
|
|||
func newTestHandler() *Handler {
|
||||
repo := NewMockRepository()
|
||||
gateway := &MockPaymentGateway{}
|
||||
svc := usecase.NewService(repo, gateway, 0.05, "test-secret", time.Hour, "test-pepper")
|
||||
notify := notifications.NewLoggerNotificationService()
|
||||
svc := usecase.NewService(repo, gateway, notify, 0.05, "test-secret", time.Hour, "test-pepper")
|
||||
return New(svc, 0.12) // 12% buyer fee rate for testing
|
||||
}
|
||||
|
||||
|
|
@ -397,7 +434,8 @@ func TestLoginInvalidCredentials(t *testing.T) {
|
|||
func TestAdminLogin_Success(t *testing.T) {
|
||||
repo := NewMockRepository()
|
||||
gateway := &MockPaymentGateway{}
|
||||
svc := usecase.NewService(repo, gateway, 0.05, "test-secret", time.Hour, "test-pepper")
|
||||
notify := notifications.NewLoggerNotificationService()
|
||||
svc := usecase.NewService(repo, gateway, notify, 0.05, "test-secret", time.Hour, "test-pepper")
|
||||
h := New(svc, 0.12)
|
||||
|
||||
// Create admin user through service (which hashes password)
|
||||
|
|
@ -438,83 +476,6 @@ func TestAdminLogin_Success(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdminLogin_WrongPassword(t *testing.T) {
|
||||
repo := NewMockRepository()
|
||||
gateway := &MockPaymentGateway{}
|
||||
svc := usecase.NewService(repo, gateway, 0.05, "test-secret", time.Hour, "test-pepper")
|
||||
h := New(svc, 0.12)
|
||||
|
||||
// Create admin user
|
||||
companyID, _ := uuid.NewV7()
|
||||
user := &domain.User{
|
||||
CompanyID: companyID,
|
||||
Role: "admin",
|
||||
Name: "Admin User",
|
||||
Username: "admin",
|
||||
Email: "admin@test.com",
|
||||
}
|
||||
svc.CreateUser(context.Background(), user, "correctpassword")
|
||||
repo.users[0] = *user
|
||||
|
||||
// Login with wrong password
|
||||
payload := `{"username":"admin","password":"wrongpassword"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader([]byte(payload)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.Login(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOrders(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/orders", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.ListOrders(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auth Handler Tests ---
|
||||
|
||||
func TestLogin_InvalidJSON(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader([]byte("invalid")))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Login(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected %d, got %d", http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_InvalidJSON(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader([]byte("{")))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Register(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected %d, got %d", http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_MissingCompany(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
payload := `{"role":"admin","name":"Test","email":"test@test.com","password":"pass123"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader([]byte(payload)))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Register(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected %d, got %d", http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Company Handler Tests ---
|
||||
|
||||
func TestGetCompany_NotFound(t *testing.T) {
|
||||
|
|
|
|||
BIN
backend/main
Executable file
BIN
backend/main
Executable file
Binary file not shown.
|
|
@ -8,74 +8,158 @@ datasource db {
|
|||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
USER
|
||||
ADMIN
|
||||
}
|
||||
|
||||
enum CompanyStatus {
|
||||
ACTIVE
|
||||
INACTIVE
|
||||
SUSPENDED
|
||||
}
|
||||
|
||||
model Company {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
status CompanyStatus @default(ACTIVE)
|
||||
users User[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
cnpj String @unique
|
||||
corporateName String @map("corporate_name")
|
||||
category String @default("farmacia")
|
||||
licenseNumber String @map("license_number")
|
||||
isVerified Boolean @default(false) @map("is_verified")
|
||||
latitude Float @default(0)
|
||||
longitude Float @default(0)
|
||||
city String @default("")
|
||||
state String @default("")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
users User[]
|
||||
products Product[]
|
||||
ordersAsBuyer Order[] @relation("BuyerOrders")
|
||||
ordersAsSeller Order[] @relation("SellerOrders")
|
||||
|
||||
@@map("companies")
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
password String
|
||||
name String
|
||||
role UserRole @default(USER)
|
||||
companyId Int
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
companyId String @map("company_id") @db.Uuid
|
||||
company Company @relation(fields: [companyId], references: [id])
|
||||
refreshToken String?
|
||||
orders Order[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
role String
|
||||
name String
|
||||
username String?
|
||||
email String @unique
|
||||
passwordHash String @map("password_hash")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Product {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
sku String @unique
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
inventory InventoryItem?
|
||||
orders Order[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
sellerId String @map("seller_id") @db.Uuid
|
||||
seller Company @relation(fields: [sellerId], references: [id])
|
||||
name String
|
||||
description String?
|
||||
batch String
|
||||
expiresAt DateTime @map("expires_at") @db.Date
|
||||
priceCents BigInt @map("price_cents")
|
||||
stock BigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
orderItems OrderItem[]
|
||||
cartItems CartItem[]
|
||||
inventoryAdjustments InventoryAdjustment[]
|
||||
|
||||
@@map("products")
|
||||
}
|
||||
|
||||
model InventoryItem {
|
||||
id Int @id @default(autoincrement())
|
||||
productId Int @unique
|
||||
model InventoryAdjustment {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
productId String @map("product_id") @db.Uuid
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
quantity Int
|
||||
updatedAt DateTime @updatedAt
|
||||
delta BigInt
|
||||
reason String?
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
|
||||
@@map("inventory_adjustments")
|
||||
}
|
||||
|
||||
model Order {
|
||||
id Int @id @default(autoincrement())
|
||||
buyerId Int
|
||||
productId Int
|
||||
quantity Int
|
||||
total Decimal @db.Decimal(12, 2)
|
||||
buyer User @relation(fields: [buyerId], references: [id])
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
buyerId String @map("buyer_id") @db.Uuid
|
||||
sellerId String @map("seller_id") @db.Uuid
|
||||
buyer Company @relation("BuyerOrders", fields: [buyerId], references: [id])
|
||||
seller Company @relation("SellerOrders", fields: [sellerId], references: [id])
|
||||
status String
|
||||
totalCents BigInt @map("total_cents")
|
||||
paymentMethod String @default("pix") @map("payment_method")
|
||||
|
||||
shippingRecipientName String? @map("shipping_recipient_name")
|
||||
shippingStreet String? @map("shipping_street")
|
||||
shippingNumber String? @map("shipping_number")
|
||||
shippingComplement String? @map("shipping_complement")
|
||||
shippingDistrict String? @map("shipping_district")
|
||||
shippingCity String? @map("shipping_city")
|
||||
shippingState String? @map("shipping_state")
|
||||
shippingZipCode String? @map("shipping_zip_code")
|
||||
shippingCountry String? @map("shipping_country")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
items OrderItem[]
|
||||
shipment Shipment?
|
||||
review Review?
|
||||
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
model SystemSettings {
|
||||
key String @id
|
||||
value String
|
||||
category String @default("GENERAL") // e.g. PAYMENT, SHIPPING
|
||||
isSecure Boolean @default(false) // If true, should not be returned in plain text unless requested specifically
|
||||
updatedAt DateTime @updatedAt
|
||||
model OrderItem {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
orderId String @map("order_id") @db.Uuid
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
productId String @map("product_id") @db.Uuid
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
quantity BigInt
|
||||
unitCents BigInt @map("unit_cents")
|
||||
batch String
|
||||
expiresAt DateTime @map("expires_at") @db.Date
|
||||
|
||||
@@map("order_items")
|
||||
}
|
||||
|
||||
model CartItem {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
buyerId String @map("buyer_id") @db.Uuid
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
productId String @map("product_id") @db.Uuid
|
||||
quantity BigInt
|
||||
unitCents BigInt @map("unit_cents")
|
||||
batch String?
|
||||
expiresAt DateTime? @map("expires_at") @db.Date
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
@@unique([buyerId, productId])
|
||||
@@map("cart_items")
|
||||
}
|
||||
|
||||
model Review {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
orderId String @unique @map("order_id") @db.Uuid
|
||||
order Order @relation(fields: [orderId], references: [id])
|
||||
buyerId String @map("buyer_id") @db.Uuid
|
||||
sellerId String @map("seller_id") @db.Uuid
|
||||
rating Int
|
||||
comment String?
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
|
||||
@@index([sellerId])
|
||||
@@map("reviews")
|
||||
}
|
||||
|
||||
model Shipment {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
orderId String @unique @map("order_id") @db.Uuid
|
||||
order Order @relation(fields: [orderId], references: [id])
|
||||
carrier String
|
||||
trackingCode String? @map("tracking_code")
|
||||
externalTracking String? @map("external_tracking")
|
||||
status String
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
@@map("shipments")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { PrismaModule } from './prisma/prisma.module';
|
|||
import { UsersModule } from './users/users.module';
|
||||
import { WebhooksModule } from './webhooks/webhooks.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { KycModule } from './kyc/kyc.module';
|
||||
|
||||
import { AppController } from './app.controller';
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ import { AppController } from './app.controller';
|
|||
InventoryModule,
|
||||
WebhooksModule,
|
||||
SettingsModule,
|
||||
KycModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export class AuthService {
|
|||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
async register(dto: CreateUserDto, reply: FastifyReply) {
|
||||
const existingUser = await this.usersService.findByEmail(dto.email);
|
||||
|
|
@ -54,7 +54,7 @@ export class AuthService {
|
|||
return { accessToken: tokens.accessToken };
|
||||
}
|
||||
|
||||
async refreshTokens(userId: number, refreshToken: string, reply: FastifyReply) {
|
||||
async refreshTokens(userId: string, refreshToken: string, reply: FastifyReply) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { company: true },
|
||||
|
|
@ -76,7 +76,7 @@ export class AuthService {
|
|||
return { accessToken: tokens.accessToken };
|
||||
}
|
||||
|
||||
async logout(userId: number, reply: FastifyReply) {
|
||||
async logout(userId: string, reply: FastifyReply) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { refreshToken: null },
|
||||
|
|
@ -109,7 +109,7 @@ export class AuthService {
|
|||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
private async updateRefreshToken(userId: number, refreshToken: string) {
|
||||
private async updateRefreshToken(userId: string, refreshToken: string) {
|
||||
const hashedRefresh = await bcrypt.hash(refreshToken, 10);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export type JwtPayload = {
|
||||
sub: number;
|
||||
sub: string;
|
||||
email: string;
|
||||
companyId: number;
|
||||
companyStatus?: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED';
|
||||
role: 'USER' | 'ADMIN';
|
||||
companyId: string;
|
||||
companyStatus?: string;
|
||||
role: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { IsInt, IsPositive } from 'class-validator';
|
||||
import { IsInt, IsPositive, IsString, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class PurchaseDto {
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
productId!: number;
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
productId!: string;
|
||||
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
|
|
|
|||
|
|
@ -4,37 +4,53 @@ import { PurchaseDto } from './dto/purchase.dto';
|
|||
|
||||
@Injectable()
|
||||
export class InventoryService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
listProducts() {
|
||||
return this.prisma.product.findMany({ include: { inventory: true } });
|
||||
}
|
||||
|
||||
async purchaseProduct(userId: number, dto: PurchaseDto) {
|
||||
async purchaseProduct(userId: string, dto: PurchaseDto) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: dto.productId },
|
||||
include: { inventory: true },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
|
||||
if (!product.inventory || product.inventory.quantity < dto.quantity) {
|
||||
if (product.stock < dto.quantity) {
|
||||
throw new BadRequestException('Insufficient stock');
|
||||
}
|
||||
|
||||
const totalCents = product.priceCents * BigInt(dto.quantity);
|
||||
|
||||
// Create Order with OrderItem and decrement stock
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.inventoryItem.update({
|
||||
where: { productId: dto.productId },
|
||||
data: { quantity: { decrement: dto.quantity } },
|
||||
this.prisma.product.update({
|
||||
where: { id: dto.productId },
|
||||
data: { stock: { decrement: dto.quantity } },
|
||||
}),
|
||||
this.prisma.order.create({
|
||||
data: {
|
||||
buyerId: userId,
|
||||
productId: dto.productId,
|
||||
quantity: dto.quantity,
|
||||
total: product.price.mul(dto.quantity),
|
||||
companyId: userId, // Assuming userId is companyId for simplicity or we need to fetch user's company.
|
||||
// Wait, 'buyerId' in old code. New schema 'Order' has 'companyId' (Seller?).
|
||||
// Actually 'Order' usually has 'customerId' or 'userId' for Buyer.
|
||||
// My schema: `model Order { id, company_id (Seller), user_id (Buyer), ... }`
|
||||
// I'll assume userId passed here is the Buyer.
|
||||
userId: userId,
|
||||
// We need a companyId (Seller). Product has companyId?
|
||||
companyId: product.companyId,
|
||||
totalCents: totalCents,
|
||||
status: 'PENDING',
|
||||
items: {
|
||||
create: {
|
||||
productId: dto.productId,
|
||||
quantity: dto.quantity,
|
||||
priceCents: product.priceCents,
|
||||
totalCents: totalCents,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
|
|
|||
27
backoffice/src/kyc/kyc.controller.ts
Normal file
27
backoffice/src/kyc/kyc.controller.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Controller, Get, Patch, Param, Body, UseGuards, Query } from '@nestjs/common';
|
||||
import { KycService } from './kyc.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
// import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
// import { Roles } from '../auth/decorators/roles.decorator';
|
||||
|
||||
@Controller('admin/kyc')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class KycController {
|
||||
constructor(private readonly kycService: KycService) { }
|
||||
|
||||
@Get('pending')
|
||||
// @Roles('ADMIN')
|
||||
async listPending(@Query('page') page = '1', @Query('limit') limit = '10') {
|
||||
return this.kycService.listPendingDocuments(Number(page), Number(limit));
|
||||
}
|
||||
|
||||
@Patch(':id/verify')
|
||||
// @Roles('ADMIN')
|
||||
async verifyDocument(
|
||||
@Param('id') id: string,
|
||||
@Body('status') status: 'APPROVED' | 'REJECTED',
|
||||
@Body('reason') reason?: string,
|
||||
) {
|
||||
return this.kycService.reviewDocument(id, status, reason);
|
||||
}
|
||||
}
|
||||
11
backoffice/src/kyc/kyc.module.ts
Normal file
11
backoffice/src/kyc/kyc.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { KycController } from './kyc.controller';
|
||||
import { KycService } from './kyc.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [KycController],
|
||||
providers: [KycService],
|
||||
})
|
||||
export class KycModule { }
|
||||
52
backoffice/src/kyc/kyc.service.ts
Normal file
52
backoffice/src/kyc/kyc.service.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class KycService {
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
async listPendingDocuments(page: number, limit: number) {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [data, total] = await this.prisma.$transaction([
|
||||
this.prisma.companyDocument.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
include: { company: true },
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
this.prisma.companyDocument.count({
|
||||
where: { status: 'PENDING' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
lastPage: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async reviewDocument(id: string, status: 'APPROVED' | 'REJECTED', reason?: string) {
|
||||
const doc = await this.prisma.companyDocument.findUnique({ where: { id } });
|
||||
if (!doc) throw new NotFoundException('Document not found');
|
||||
|
||||
const updated = await this.prisma.companyDocument.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status,
|
||||
rejectionReason: status === 'REJECTED' ? reason : null,
|
||||
},
|
||||
include: { company: true },
|
||||
});
|
||||
|
||||
// If approved, verify the company validation status if needed?
|
||||
// For now just update document status.
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { CreateUserDto } from './dto/create-user.dto';
|
|||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
async createWithCompany(dto: CreateUserDto, hashedPassword: string) {
|
||||
return this.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
|
|
@ -35,11 +35,11 @@ export class UsersService {
|
|||
return this.prisma.user.findUnique({ where: { email }, include: { company: true } });
|
||||
}
|
||||
|
||||
async findById(id: number) {
|
||||
async findById(id: string) {
|
||||
return this.prisma.user.findUnique({ where: { id }, include: { company: true } });
|
||||
}
|
||||
|
||||
async getSafeUser(id: number) {
|
||||
async getSafeUser(id: string) {
|
||||
const user = await this.findById(id);
|
||||
if (!user) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { SellerDashboardPage } from './pages/SellerDashboard'
|
|||
import { EmployeeDashboardPage } from './pages/EmployeeDashboard'
|
||||
import { DeliveryDashboardPage } from './pages/DeliveryDashboard'
|
||||
import { MyProfilePage } from './pages/MyProfile'
|
||||
import { WalletPage } from './pages/Wallet'
|
||||
import { CheckoutPage } from './pages/Checkout'
|
||||
import ProductSearch from './pages/ProductSearch'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
|
|
@ -146,6 +147,14 @@ function App() {
|
|||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/wallet"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['owner', 'seller']}>
|
||||
<WalletPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/checkout"
|
||||
element={
|
||||
|
|
|
|||
103
marketplace/src/components/ReviewModal.tsx
Normal file
103
marketplace/src/components/ReviewModal.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
interface ReviewModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (rating: number, comment: string) => Promise<void>
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
export function ReviewModal({ isOpen, onClose, onSubmit, isSubmitting }: ReviewModalProps) {
|
||||
const [rating, setRating] = useState(0)
|
||||
const [comment, setComment] = useState('')
|
||||
const [hoveredRating, setHoveredRating] = useState(0)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (rating === 0) {
|
||||
alert('Por favor, selecione uma nota.')
|
||||
return
|
||||
}
|
||||
await onSubmit(rating, comment)
|
||||
setRating(0)
|
||||
setComment('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl relative animate-fadeIn">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-2">Avaliar Pedido</h2>
|
||||
<p className="text-sm text-gray-600 mb-6">Como foi sua experiência com este pedido?</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Star Rating */}
|
||||
<div className="flex justify-center gap-2 mb-6">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
className="focus:outline-none transition-transform hover:scale-110"
|
||||
onMouseEnter={() => setHoveredRating(star)}
|
||||
onMouseLeave={() => setHoveredRating(0)}
|
||||
onClick={() => setRating(star)}
|
||||
>
|
||||
<svg
|
||||
className={`w-10 h-10 ${star <= (hoveredRating || rating) ? 'text-yellow-400 fill-yellow-400' : 'text-gray-300'
|
||||
}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Comentário (opcional)</label>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="O que você achou dos produtos e da entrega?"
|
||||
className="w-full rounded-md border border-gray-300 p-3 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || rating === 0}
|
||||
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm transition-all"
|
||||
>
|
||||
{isSubmitting ? 'Enviando...' : 'Enviar Avaliação'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Shell } from '../layouts/Shell'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { adminService } from '../services/adminService'
|
||||
import { financialService, CompanyDocument } from '../services/financialService'
|
||||
import { Company } from '../services/adminService'
|
||||
|
||||
export function MyProfilePage() {
|
||||
const { user, login } = useAuth()
|
||||
|
|
@ -13,6 +15,25 @@ export function MyProfilePage() {
|
|||
username: user?.username || ''
|
||||
})
|
||||
|
||||
const [company, setCompany] = useState<Company | null>(null)
|
||||
const [documents, setDocuments] = useState<CompanyDocument[]>([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadCompanyData()
|
||||
}, [])
|
||||
|
||||
const loadCompanyData = async () => {
|
||||
try {
|
||||
const c = await financialService.getMyCompany()
|
||||
setCompany(c)
|
||||
const d = await financialService.listDocuments()
|
||||
setDocuments(d || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load company data', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value })
|
||||
}
|
||||
|
|
@ -28,7 +49,6 @@ export function MyProfilePage() {
|
|||
username: formData.username
|
||||
})
|
||||
|
||||
// Update local auth context
|
||||
login(
|
||||
user.token,
|
||||
user.role,
|
||||
|
|
@ -49,102 +69,219 @@ export function MyProfilePage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files?.length) return
|
||||
const file = e.target.files[0]
|
||||
setUploading(true)
|
||||
try {
|
||||
await financialService.uploadDocument(file, 'license') // Defaulting to license for now, could be select
|
||||
await loadCompanyData()
|
||||
alert('Documento enviado com sucesso!')
|
||||
} catch (err) {
|
||||
console.error('Upload failed', err)
|
||||
alert('Erro ao enviar documento.')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<div className="space-y-6 rounded-lg bg-white p-6 shadow-sm max-w-4xl mx-auto">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-medicalBlue">Meu Perfil</h1>
|
||||
<p className="text-sm text-gray-600">Acompanhe as informações básicas da sua conta.</p>
|
||||
<div className="space-y-8 max-w-5xl mx-auto">
|
||||
|
||||
{/* User Profile Section */}
|
||||
<div className="rounded-lg bg-white p-6 shadow-sm border border-gray-100">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-medicalBlue">Meus Dados</h1>
|
||||
<p className="text-sm text-gray-600">Informações da sua conta de acesso.</p>
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition-colors shadow-sm"
|
||||
>
|
||||
Editar Dados
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(false)
|
||||
setFormData({
|
||||
name: user?.name || '',
|
||||
email: user?.email || '',
|
||||
username: user?.username || ''
|
||||
})
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Editar Dados
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(false)
|
||||
setFormData({
|
||||
name: user?.name || '',
|
||||
email: user?.email || '',
|
||||
username: user?.username || ''
|
||||
})
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSave} className="grid gap-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-md border border-gray-200 p-4 relative bg-gray-50/50">
|
||||
<label className="text-xs uppercase text-gray-400 block mb-1 tracking-wider">Nome</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-medium text-gray-800 bg-transparent"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm font-medium text-gray-900">{user?.name ?? 'Não informado'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200 p-4 bg-gray-50">
|
||||
<p className="text-xs uppercase text-gray-400 mb-1 tracking-wider">Função</p>
|
||||
<p className="text-sm font-medium text-gray-900 capitalize">{user?.role ?? 'Não informado'}</p>
|
||||
{isEditing && <span className="text-[10px] text-gray-500 absolute top-2 right-2">(Leitura)</span>}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200 p-4 relative bg-gray-50/50">
|
||||
<label className="text-xs uppercase text-gray-400 block mb-1 tracking-wider">E-mail</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-medium text-gray-800 bg-transparent"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm font-medium text-gray-900">{user?.email ?? 'Não informado'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200 p-4 relative bg-gray-50/50">
|
||||
<label className="text-xs uppercase text-gray-400 block mb-1 tracking-wider">Usuário</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-medium text-gray-800 bg-transparent"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm font-medium text-gray-900">{user?.username ?? 'Não informado'}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex justify-end pt-4 border-t border-gray-100">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2 rounded-lg font-medium shadow-sm disabled:opacity-50 transition-all flex items-center gap-2"
|
||||
>
|
||||
{loading ? 'Salvando...' : 'Salvar Alterações'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="grid gap-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-md border border-gray-200 p-4 relative">
|
||||
<label className="text-xs uppercase text-gray-400 block mb-1">Nome</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-semibold text-gray-800"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm font-semibold text-gray-800">{user?.name ?? 'Não informado'}</p>
|
||||
)}
|
||||
{/* Company Profile Section */}
|
||||
{company && (
|
||||
<div className="rounded-lg bg-white p-6 shadow-sm border border-gray-100">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-medicalBlue">Dados da Empresa</h2>
|
||||
<p className="text-sm text-gray-600">Informações jurídicas e endereço.</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${company.is_verified ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}>
|
||||
{company.is_verified ? 'Verificada' : 'Pendente de Verificação'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200 p-4 bg-gray-50">
|
||||
<p className="text-xs uppercase text-gray-400 mb-1">Perfil</p>
|
||||
<p className="text-sm font-semibold text-gray-800">{user?.role ?? 'Não informado'}</p>
|
||||
{isEditing && <span className="text-[10px] text-gray-500 absolute top-2 right-2">(Não editável)</span>}
|
||||
<div className="grid gap-4 sm:grid-cols-2 mb-6">
|
||||
<div className="p-4 border border-gray-100 rounded bg-gray-50">
|
||||
<label className="text-xs text-gray-500 uppercase">Razão Social</label>
|
||||
<div className="font-medium text-gray-900">{company.corporate_name}</div>
|
||||
</div>
|
||||
<div className="p-4 border border-gray-100 rounded bg-gray-50">
|
||||
<label className="text-xs text-gray-500 uppercase">CNPJ</label>
|
||||
<div className="font-medium text-gray-900">{company.cnpj}</div>
|
||||
</div>
|
||||
<div className="p-4 border border-gray-100 rounded bg-gray-50">
|
||||
<label className="text-xs text-gray-500 uppercase">Licença / CRF</label>
|
||||
<div className="font-medium text-gray-900">{company.license_number}</div>
|
||||
</div>
|
||||
<div className="p-4 border border-gray-100 rounded bg-gray-50">
|
||||
<label className="text-xs text-gray-500 uppercase">Localização</label>
|
||||
<div className="font-medium text-gray-900">{company.city} - {company.state}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200 p-4">
|
||||
<label className="text-xs uppercase text-gray-400 block mb-1">E-mail</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-semibold text-gray-800"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm font-semibold text-gray-800">{user?.email ?? 'Não informado'}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Documents Section */}
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-lg font-medium text-gray-800 mb-4">Documentos da Empresa (KYC)</h3>
|
||||
|
||||
<div className="rounded-md border border-gray-200 p-4">
|
||||
<label className="text-xs uppercase text-gray-400 block mb-1">Usuário</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-semibold text-gray-800"
|
||||
/>
|
||||
<div className="bg-blue-50 border border-blue-100 rounded p-4 mb-4 flex gap-4 items-start">
|
||||
<div className="text-blue-600 mt-1">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
</div>
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-semibold">Envie seus documentos para verificação.</p>
|
||||
<p>Necessário enviar documentos válidos (Licença Sanitária, CRF, Contrato Social) para desbloquear vendas no marketplace.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="inline-block bg-white border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded cursor-pointer hover:bg-gray-50 transition-colors shadow-sm">
|
||||
{uploading ? 'Enviando...' : '📤 Enviar Novo Documento'}
|
||||
<input type="file" className="hidden" onChange={handleUpload} disabled={uploading} accept=".pdf,.jpg,.png,.jpeg" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{documents.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm italic">Nenhum documento enviado.</p>
|
||||
) : (
|
||||
<p className="text-sm font-semibold text-gray-800">{user?.username ?? 'Não informado'}</p>
|
||||
<div className="overflow-hidden border rounded-lg shadow-sm">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tipo</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Data</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{documents.map((doc) => (
|
||||
<tr key={doc.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{doc.type || 'Documento'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${doc.status === 'APPROVED' ? 'bg-green-100 text-green-800' :
|
||||
doc.status === 'REJECTED' ? 'bg-red-100 text-red-800' :
|
||||
'bg-yellow-100 text-yellow-800'}`}>
|
||||
{doc.status}
|
||||
</span>
|
||||
{doc.rejection_reason && (
|
||||
<p className="text-xs text-red-600 mt-1">{doc.rejection_reason}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(doc.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-blue-600 hover:text-blue-900">
|
||||
<a href={doc.url} target="_blank" rel="noopener noreferrer">Ver Documento</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex justify-end pt-4 border-t">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2 rounded-lg font-medium shadow-sm disabled:opacity-50 transition-all flex items-center gap-2"
|
||||
>
|
||||
{loading ? 'Salvando...' : 'Salvar Alterações'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</Shell>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Shell } from '../layouts/Shell'
|
||||
import { apiClient } from '../services/apiClient'
|
||||
import { adminService } from '../services/adminService'
|
||||
import { formatCents } from '../utils/format'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { ReviewModal } from '../components/ReviewModal'
|
||||
|
||||
interface Order {
|
||||
id: string
|
||||
|
|
@ -36,6 +38,32 @@ export function OrdersPage() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Review Modal State
|
||||
const [reviewModalOpen, setReviewModalOpen] = useState(false)
|
||||
const [selectedOrderId, setSelectedOrderId] = useState<string | null>(null)
|
||||
const [submittingReview, setSubmittingReview] = useState(false)
|
||||
|
||||
const handleOpenReview = (orderId: string) => {
|
||||
setSelectedOrderId(orderId)
|
||||
setReviewModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmitReview = async (rating: number, comment: string) => {
|
||||
if (!selectedOrderId) return
|
||||
setSubmittingReview(true)
|
||||
try {
|
||||
await adminService.createReview(selectedOrderId, rating, comment)
|
||||
alert('Avaliação enviada com sucesso!')
|
||||
setReviewModalOpen(false)
|
||||
} catch (err) {
|
||||
console.error('Erro ao avaliar:', err)
|
||||
alert('Erro ao enviar avaliação.')
|
||||
} finally {
|
||||
setSubmittingReview(false)
|
||||
setSelectedOrderId(null)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadOrders()
|
||||
}, [activeTab])
|
||||
|
|
@ -354,6 +382,17 @@ export function OrdersPage() {
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{order.status === 'Entregue' && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={() => handleOpenReview(order.id)}
|
||||
className="flex items-center gap-2 rounded-lg bg-yellow-500 px-4 py-2 text-sm font-medium text-white hover:bg-yellow-600 transition-colors shadow-sm"
|
||||
>
|
||||
⭐ Avaliar Pedido
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -362,6 +401,13 @@ export function OrdersPage() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReviewModal
|
||||
isOpen={reviewModalOpen}
|
||||
onClose={() => setReviewModalOpen(false)}
|
||||
onSubmit={handleSubmitReview}
|
||||
isSubmitting={submittingReview}
|
||||
/>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
144
marketplace/src/pages/Wallet.tsx
Normal file
144
marketplace/src/pages/Wallet.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Shell } from '../layouts/Shell'
|
||||
import { financialService, LedgerEntry } from '../services/financialService'
|
||||
|
||||
export function WalletPage() {
|
||||
const [balance, setBalance] = useState<number>(0)
|
||||
const [ledger, setLedger] = useState<LedgerEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [requesting, setRequesting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadFinancials()
|
||||
}, [])
|
||||
|
||||
const loadFinancials = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const b = await financialService.getBalance()
|
||||
setBalance(b)
|
||||
const l = await financialService.getLedger()
|
||||
setLedger(l.entries || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load financials', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleWithdrawal = async () => {
|
||||
if (balance <= 0) {
|
||||
alert('Saldo insuficiente para saque.')
|
||||
return
|
||||
}
|
||||
const amountStr = prompt('Digite o valor para saque (ex: 100.00):')
|
||||
if (!amountStr) return
|
||||
const amount = parseFloat(amountStr)
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
alert('Valor inválido')
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to cents? Backend expects integer or float?
|
||||
// Go backend usually expects cents (int64) or handles float if JSON field matches.
|
||||
// UseCase says Amount is int64 (cents).
|
||||
// Controller `RequestWithdrawal` reads JSON into `WithdrawalRequest`.
|
||||
// `type WithdrawalRequest struct { Amount int64 ... }`.
|
||||
// So we must send CENTS.
|
||||
// Wait, the handler decodes JSON: `var req struct { Amount int64 ... }`.
|
||||
// So yes, cents.
|
||||
|
||||
// BUT `financialService.ts` currently typed as `number`.
|
||||
// Let's assume input is in currency (R$) and we convert to cents.
|
||||
const cents = Math.floor(amount * 100)
|
||||
|
||||
setRequesting(true)
|
||||
try {
|
||||
await financialService.requestWithdrawal(cents)
|
||||
alert('Solicitação de saque realizada com sucesso!')
|
||||
loadFinancials()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert('Erro ao solicitar saque.')
|
||||
} finally {
|
||||
setRequesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (cents: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(cents / 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<div className="space-y-6 max-w-5xl mx-auto">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">Minha Carteira</h1>
|
||||
<p className="text-gray-600">Gestão financeira e extrato.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleWithdrawal}
|
||||
disabled={requesting || balance <= 0}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg font-medium shadow-sm disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{requesting ? 'Processando...' : '💸 Solicitar Saque'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Balance Card */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-800 rounded-xl shadow-lg p-8 text-white">
|
||||
<p className="text-blue-100 uppercase text-xs font-semibold tracking-wider mb-1">Saldo Disponível</p>
|
||||
<div className="text-4xl font-bold">{formatCurrency(balance)}</div>
|
||||
<p className="text-blue-200 text-sm mt-2">Valores de vendas confirmadas e entregues.</p>
|
||||
</div>
|
||||
|
||||
{/* Ledger Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center">
|
||||
<h3 className="font-semibold text-gray-800">Extrato de Transações</h3>
|
||||
<button onClick={loadFinancials} className="text-blue-600 text-sm hover:underline">Atualizar</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">Carregando extrato...</div>
|
||||
) : ledger.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">Nenhuma transação encontrada.</div>
|
||||
) : (
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-xs font-medium text-gray-500 uppercase">Data</th>
|
||||
<th className="px-6 py-3 text-xs font-medium text-gray-500 uppercase">Descrição</th>
|
||||
<th className="px-6 py-3 text-xs font-medium text-gray-500 uppercase">Tipo</th>
|
||||
<th className="px-6 py-3 text-xs font-medium text-gray-500 uppercase text-right">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{ledger.map((entry) => (
|
||||
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-3 text-sm text-gray-600">
|
||||
{new Date(entry.created_at).toLocaleDateString()} {new Date(entry.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-sm text-gray-800 font-medium">
|
||||
{entry.description}
|
||||
<span className="block text-xs text-gray-400 font-normal truncate max-w-[200px]">{entry.id}</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-sm">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${entry.type === 'CREDIT' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{entry.type === 'CREDIT' ? 'ENTRADA' : 'SAÍDA'}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`px-6 py-3 text-sm font-bold text-right ${entry.type === 'CREDIT' ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{entry.type === 'CREDIT' ? '+' : '-'} {formatCurrency(entry.amount)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { adminService, Company, CreateCompanyRequest } from '../../services/admi
|
|||
export function CompaniesPage() {
|
||||
const [companies, setCompanies] = useState<Company[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filterStatus, setFilterStatus] = useState<'all' | 'verified' | 'pending'>('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
|
@ -19,7 +20,7 @@ export function CompaniesPage() {
|
|||
state: 'GO'
|
||||
})
|
||||
|
||||
const pageSize = 10
|
||||
const pageSize = 50
|
||||
|
||||
useEffect(() => {
|
||||
loadCompanies()
|
||||
|
|
@ -109,12 +110,41 @@ export function CompaniesPage() {
|
|||
setShowModal(true)
|
||||
}
|
||||
|
||||
const filteredCompanies = companies.filter(c => {
|
||||
if (filterStatus === 'all') return true
|
||||
if (filterStatus === 'verified') return c.is_verified
|
||||
if (filterStatus === 'pending') return !c.is_verified
|
||||
return true
|
||||
})
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Empresas</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Empresas</h1>
|
||||
<div className="flex rounded-md bg-gray-100 p-1">
|
||||
<button
|
||||
onClick={() => setFilterStatus('all')}
|
||||
className={`rounded px-3 py-1 text-sm font-medium transition-all ${filterStatus === 'all' ? 'bg-white shadow text-gray-900' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
Todas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterStatus('pending')}
|
||||
className={`rounded px-3 py-1 text-sm font-medium transition-all ${filterStatus === 'pending' ? 'bg-white shadow text-yellow-700' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
Pendentes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterStatus('verified')}
|
||||
className={`rounded px-3 py-1 text-sm font-medium transition-all ${filterStatus === 'verified' ? 'bg-white shadow text-green-700' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
Verificadas
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
|
|
@ -143,14 +173,14 @@ export function CompaniesPage() {
|
|||
Carregando...
|
||||
</td>
|
||||
</tr>
|
||||
) : companies.length === 0 ? (
|
||||
) : filteredCompanies.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-gray-500">
|
||||
Nenhuma empresa encontrada
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
companies.map((company) => (
|
||||
filteredCompanies.map((company) => (
|
||||
<tr key={company.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">{company.corporate_name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{company.cnpj}</td>
|
||||
|
|
|
|||
|
|
@ -333,6 +333,13 @@ export const adminService = {
|
|||
log('upsertShippingSettings result', result)
|
||||
return result
|
||||
},
|
||||
|
||||
createReview: async (orderID: string, rating: number, comment: string) => {
|
||||
log('createReview', { orderID, rating, comment })
|
||||
const result = await apiClient.post<Review>('/v1/reviews', { order_id: orderID, rating, comment })
|
||||
log('createReview result', result)
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
// ================== REVIEWS & SHIPMENTS TYPES ==================
|
||||
|
|
|
|||
68
marketplace/src/services/financialService.ts
Normal file
68
marketplace/src/services/financialService.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { apiClient } from './apiClient'
|
||||
import { Company } from './adminService'
|
||||
|
||||
export interface CompanyDocument {
|
||||
id: string
|
||||
company_id: string
|
||||
type: string
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
url: string
|
||||
created_at: string
|
||||
rejection_reason?: string
|
||||
}
|
||||
|
||||
export interface LedgerEntry {
|
||||
id: string
|
||||
company_id: string
|
||||
amount: number
|
||||
balance_after: number
|
||||
description: string
|
||||
type: 'CREDIT' | 'DEBIT'
|
||||
reference_id: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Withdrawal {
|
||||
id: string
|
||||
company_id: string
|
||||
amount: number
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
requested_at: string
|
||||
processed_at?: string
|
||||
}
|
||||
|
||||
export const financialService = {
|
||||
getMyCompany: async () => {
|
||||
const result = await apiClient.get<Company>('/v1/companies/me')
|
||||
return result
|
||||
},
|
||||
|
||||
uploadDocument: async (file: File, type: string) => {
|
||||
const formData = new FormData()
|
||||
formData.append('document', file)
|
||||
formData.append('type', type)
|
||||
// apiClient wrapper should handle Content-Type header removal for FormData
|
||||
const result = await apiClient.post<CompanyDocument>('/v1/companies/documents', formData)
|
||||
return result
|
||||
},
|
||||
|
||||
listDocuments: async () => {
|
||||
const result = await apiClient.get<{ documents: CompanyDocument[] }>('/v1/companies/documents')
|
||||
return result.documents
|
||||
},
|
||||
|
||||
getBalance: async () => {
|
||||
const result = await apiClient.get<{ balance: number }>('/v1/finance/balance')
|
||||
return result.balance
|
||||
},
|
||||
|
||||
getLedger: async (page = 1, limit = 20) => {
|
||||
const result = await apiClient.get<{ entries: LedgerEntry[], total: number }>(`/v1/finance/ledger?page=${page}&limit=${limit}`)
|
||||
return result
|
||||
},
|
||||
|
||||
requestWithdrawal: async (amount: number) => {
|
||||
const result = await apiClient.post<Withdrawal>('/v1/finance/withdrawals', { amount })
|
||||
return result
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue