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/gofrs/uuid/v5"
|
||||||
"github.com/saveinmed/backend-go/internal/domain"
|
"github.com/saveinmed/backend-go/internal/domain"
|
||||||
|
"github.com/saveinmed/backend-go/internal/notifications"
|
||||||
"github.com/saveinmed/backend-go/internal/usecase"
|
"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
|
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
|
// MockPaymentGateway implements the PaymentGateway interface for testing
|
||||||
type MockPaymentGateway struct{}
|
type MockPaymentGateway struct{}
|
||||||
|
|
||||||
|
|
@ -311,7 +347,8 @@ func (m *MockPaymentGateway) ParseWebhook(ctx context.Context, payload []byte) (
|
||||||
func newTestHandler() *Handler {
|
func newTestHandler() *Handler {
|
||||||
repo := NewMockRepository()
|
repo := NewMockRepository()
|
||||||
gateway := &MockPaymentGateway{}
|
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
|
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) {
|
func TestAdminLogin_Success(t *testing.T) {
|
||||||
repo := NewMockRepository()
|
repo := NewMockRepository()
|
||||||
gateway := &MockPaymentGateway{}
|
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)
|
h := New(svc, 0.12)
|
||||||
|
|
||||||
// Create admin user through service (which hashes password)
|
// 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 ---
|
// --- Company Handler Tests ---
|
||||||
|
|
||||||
func TestGetCompany_NotFound(t *testing.T) {
|
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")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UserRole {
|
|
||||||
USER
|
|
||||||
ADMIN
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CompanyStatus {
|
|
||||||
ACTIVE
|
|
||||||
INACTIVE
|
|
||||||
SUSPENDED
|
|
||||||
}
|
|
||||||
|
|
||||||
model Company {
|
model Company {
|
||||||
id Int @id @default(autoincrement())
|
id String @id @default(uuid()) @db.Uuid
|
||||||
name String
|
cnpj String @unique
|
||||||
status CompanyStatus @default(ACTIVE)
|
corporateName String @map("corporate_name")
|
||||||
users User[]
|
category String @default("farmacia")
|
||||||
createdAt DateTime @default(now())
|
licenseNumber String @map("license_number")
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id String @id @default(uuid()) @db.Uuid
|
||||||
email String @unique
|
companyId String @map("company_id") @db.Uuid
|
||||||
password String
|
|
||||||
name String
|
|
||||||
role UserRole @default(USER)
|
|
||||||
companyId Int
|
|
||||||
company Company @relation(fields: [companyId], references: [id])
|
company Company @relation(fields: [companyId], references: [id])
|
||||||
refreshToken String?
|
role String
|
||||||
orders Order[]
|
name String
|
||||||
createdAt DateTime @default(now())
|
username String?
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model Product {
|
||||||
id Int @id @default(autoincrement())
|
id String @id @default(uuid()) @db.Uuid
|
||||||
name String
|
sellerId String @map("seller_id") @db.Uuid
|
||||||
sku String @unique
|
seller Company @relation(fields: [sellerId], references: [id])
|
||||||
price Decimal @db.Decimal(10, 2)
|
name String
|
||||||
inventory InventoryItem?
|
description String?
|
||||||
orders Order[]
|
batch String
|
||||||
createdAt DateTime @default(now())
|
expiresAt DateTime @map("expires_at") @db.Date
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model InventoryAdjustment {
|
||||||
id Int @id @default(autoincrement())
|
id String @id @default(uuid()) @db.Uuid
|
||||||
productId Int @unique
|
productId String @map("product_id") @db.Uuid
|
||||||
product Product @relation(fields: [productId], references: [id])
|
product Product @relation(fields: [productId], references: [id])
|
||||||
quantity Int
|
delta BigInt
|
||||||
updatedAt DateTime @updatedAt
|
reason String?
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||||
|
|
||||||
|
@@map("inventory_adjustments")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Order {
|
model Order {
|
||||||
id Int @id @default(autoincrement())
|
id String @id @default(uuid()) @db.Uuid
|
||||||
buyerId Int
|
buyerId String @map("buyer_id") @db.Uuid
|
||||||
productId Int
|
sellerId String @map("seller_id") @db.Uuid
|
||||||
quantity Int
|
buyer Company @relation("BuyerOrders", fields: [buyerId], references: [id])
|
||||||
total Decimal @db.Decimal(12, 2)
|
seller Company @relation("SellerOrders", fields: [sellerId], references: [id])
|
||||||
buyer User @relation(fields: [buyerId], references: [id])
|
status String
|
||||||
product Product @relation(fields: [productId], references: [id])
|
totalCents BigInt @map("total_cents")
|
||||||
createdAt DateTime @default(now())
|
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 {
|
model OrderItem {
|
||||||
key String @id
|
id String @id @default(uuid()) @db.Uuid
|
||||||
value String
|
orderId String @map("order_id") @db.Uuid
|
||||||
category String @default("GENERAL") // e.g. PAYMENT, SHIPPING
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
isSecure Boolean @default(false) // If true, should not be returned in plain text unless requested specifically
|
productId String @map("product_id") @db.Uuid
|
||||||
updatedAt DateTime @updatedAt
|
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 { UsersModule } from './users/users.module';
|
||||||
import { WebhooksModule } from './webhooks/webhooks.module';
|
import { WebhooksModule } from './webhooks/webhooks.module';
|
||||||
import { SettingsModule } from './settings/settings.module';
|
import { SettingsModule } from './settings/settings.module';
|
||||||
|
import { KycModule } from './kyc/kyc.module';
|
||||||
|
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
|
|
||||||
|
|
@ -18,6 +19,7 @@ import { AppController } from './app.controller';
|
||||||
InventoryModule,
|
InventoryModule,
|
||||||
WebhooksModule,
|
WebhooksModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
|
KycModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export class AuthService {
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async register(dto: CreateUserDto, reply: FastifyReply) {
|
async register(dto: CreateUserDto, reply: FastifyReply) {
|
||||||
const existingUser = await this.usersService.findByEmail(dto.email);
|
const existingUser = await this.usersService.findByEmail(dto.email);
|
||||||
|
|
@ -54,7 +54,7 @@ export class AuthService {
|
||||||
return { accessToken: tokens.accessToken };
|
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({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
include: { company: true },
|
include: { company: true },
|
||||||
|
|
@ -76,7 +76,7 @@ export class AuthService {
|
||||||
return { accessToken: tokens.accessToken };
|
return { accessToken: tokens.accessToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(userId: number, reply: FastifyReply) {
|
async logout(userId: string, reply: FastifyReply) {
|
||||||
await this.prisma.user.update({
|
await this.prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { refreshToken: null },
|
data: { refreshToken: null },
|
||||||
|
|
@ -109,7 +109,7 @@ export class AuthService {
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateRefreshToken(userId: number, refreshToken: string) {
|
private async updateRefreshToken(userId: string, refreshToken: string) {
|
||||||
const hashedRefresh = await bcrypt.hash(refreshToken, 10);
|
const hashedRefresh = await bcrypt.hash(refreshToken, 10);
|
||||||
await this.prisma.user.update({
|
await this.prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export type JwtPayload = {
|
export type JwtPayload = {
|
||||||
sub: number;
|
sub: string;
|
||||||
email: string;
|
email: string;
|
||||||
companyId: number;
|
companyId: string;
|
||||||
companyStatus?: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED';
|
companyStatus?: string;
|
||||||
role: 'USER' | 'ADMIN';
|
role: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { IsInt, IsPositive } from 'class-validator';
|
import { IsInt, IsPositive, IsString, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
export class PurchaseDto {
|
export class PurchaseDto {
|
||||||
@IsInt()
|
@IsString()
|
||||||
@IsPositive()
|
@IsNotEmpty()
|
||||||
productId!: number;
|
productId!: string;
|
||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@IsPositive()
|
@IsPositive()
|
||||||
|
|
|
||||||
|
|
@ -4,37 +4,53 @@ import { PurchaseDto } from './dto/purchase.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InventoryService {
|
export class InventoryService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) { }
|
||||||
|
|
||||||
listProducts() {
|
listProducts() {
|
||||||
return this.prisma.product.findMany({ include: { inventory: true } });
|
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({
|
const product = await this.prisma.product.findUnique({
|
||||||
where: { id: dto.productId },
|
where: { id: dto.productId },
|
||||||
include: { inventory: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
throw new NotFoundException('Product not found');
|
throw new NotFoundException('Product not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!product.inventory || product.inventory.quantity < dto.quantity) {
|
if (product.stock < dto.quantity) {
|
||||||
throw new BadRequestException('Insufficient stock');
|
throw new BadRequestException('Insufficient stock');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalCents = product.priceCents * BigInt(dto.quantity);
|
||||||
|
|
||||||
|
// Create Order with OrderItem and decrement stock
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction([
|
||||||
this.prisma.inventoryItem.update({
|
this.prisma.product.update({
|
||||||
where: { productId: dto.productId },
|
where: { id: dto.productId },
|
||||||
data: { quantity: { decrement: dto.quantity } },
|
data: { stock: { decrement: dto.quantity } },
|
||||||
}),
|
}),
|
||||||
this.prisma.order.create({
|
this.prisma.order.create({
|
||||||
data: {
|
data: {
|
||||||
buyerId: userId,
|
companyId: userId, // Assuming userId is companyId for simplicity or we need to fetch user's company.
|
||||||
productId: dto.productId,
|
// Wait, 'buyerId' in old code. New schema 'Order' has 'companyId' (Seller?).
|
||||||
quantity: dto.quantity,
|
// Actually 'Order' usually has 'customerId' or 'userId' for Buyer.
|
||||||
total: product.price.mul(dto.quantity),
|
// 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()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) { }
|
||||||
|
|
||||||
async createWithCompany(dto: CreateUserDto, hashedPassword: string) {
|
async createWithCompany(dto: CreateUserDto, hashedPassword: string) {
|
||||||
return this.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
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 } });
|
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 } });
|
return this.prisma.user.findUnique({ where: { id }, include: { company: true } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSafeUser(id: number) {
|
async getSafeUser(id: string) {
|
||||||
const user = await this.findById(id);
|
const user = await this.findById(id);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { SellerDashboardPage } from './pages/SellerDashboard'
|
||||||
import { EmployeeDashboardPage } from './pages/EmployeeDashboard'
|
import { EmployeeDashboardPage } from './pages/EmployeeDashboard'
|
||||||
import { DeliveryDashboardPage } from './pages/DeliveryDashboard'
|
import { DeliveryDashboardPage } from './pages/DeliveryDashboard'
|
||||||
import { MyProfilePage } from './pages/MyProfile'
|
import { MyProfilePage } from './pages/MyProfile'
|
||||||
|
import { WalletPage } from './pages/Wallet'
|
||||||
import { CheckoutPage } from './pages/Checkout'
|
import { CheckoutPage } from './pages/Checkout'
|
||||||
import ProductSearch from './pages/ProductSearch'
|
import ProductSearch from './pages/ProductSearch'
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||||
|
|
@ -146,6 +147,14 @@ function App() {
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/wallet"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['owner', 'seller']}>
|
||||||
|
<WalletPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/checkout"
|
path="/checkout"
|
||||||
element={
|
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 { Shell } from '../layouts/Shell'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { adminService } from '../services/adminService'
|
import { adminService } from '../services/adminService'
|
||||||
|
import { financialService, CompanyDocument } from '../services/financialService'
|
||||||
|
import { Company } from '../services/adminService'
|
||||||
|
|
||||||
export function MyProfilePage() {
|
export function MyProfilePage() {
|
||||||
const { user, login } = useAuth()
|
const { user, login } = useAuth()
|
||||||
|
|
@ -13,6 +15,25 @@ export function MyProfilePage() {
|
||||||
username: user?.username || ''
|
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>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setFormData({ ...formData, [e.target.name]: e.target.value })
|
setFormData({ ...formData, [e.target.name]: e.target.value })
|
||||||
}
|
}
|
||||||
|
|
@ -28,7 +49,6 @@ export function MyProfilePage() {
|
||||||
username: formData.username
|
username: formData.username
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update local auth context
|
|
||||||
login(
|
login(
|
||||||
user.token,
|
user.token,
|
||||||
user.role,
|
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 (
|
return (
|
||||||
<Shell>
|
<Shell>
|
||||||
<div className="space-y-6 rounded-lg bg-white p-6 shadow-sm max-w-4xl mx-auto">
|
<div className="space-y-8 max-w-5xl mx-auto">
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div>
|
{/* User Profile Section */}
|
||||||
<h1 className="text-xl font-semibold text-medicalBlue">Meu Perfil</h1>
|
<div className="rounded-lg bg-white p-6 shadow-sm border border-gray-100">
|
||||||
<p className="text-sm text-gray-600">Acompanhe as informações básicas da sua conta.</p>
|
<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>
|
</div>
|
||||||
{!isEditing ? (
|
|
||||||
<button
|
<form onSubmit={handleSave} className="grid gap-6">
|
||||||
onClick={() => setIsEditing(true)}
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition-colors"
|
<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>
|
||||||
Editar Dados
|
{isEditing ? (
|
||||||
</button>
|
<input
|
||||||
) : (
|
name="name"
|
||||||
<button
|
value={formData.name}
|
||||||
onClick={() => {
|
onChange={handleChange}
|
||||||
setIsEditing(false)
|
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-medium text-gray-800 bg-transparent"
|
||||||
setFormData({
|
/>
|
||||||
name: user?.name || '',
|
) : (
|
||||||
email: user?.email || '',
|
<p className="text-sm font-medium text-gray-900">{user?.name ?? 'Não informado'}</p>
|
||||||
username: user?.username || ''
|
)}
|
||||||
})
|
</div>
|
||||||
}}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
|
<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>
|
||||||
Cancelar
|
<p className="text-sm font-medium text-gray-900 capitalize">{user?.role ?? 'Não informado'}</p>
|
||||||
</button>
|
{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>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSave} className="grid gap-6">
|
{/* Company Profile Section */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
{company && (
|
||||||
<div className="rounded-md border border-gray-200 p-4 relative">
|
<div className="rounded-lg bg-white p-6 shadow-sm border border-gray-100">
|
||||||
<label className="text-xs uppercase text-gray-400 block mb-1">Nome</label>
|
<div className="mb-6 flex items-center justify-between">
|
||||||
{isEditing ? (
|
<div>
|
||||||
<input
|
<h2 className="text-xl font-semibold text-medicalBlue">Dados da Empresa</h2>
|
||||||
name="name"
|
<p className="text-sm text-gray-600">Informações jurídicas e endereço.</p>
|
||||||
value={formData.name}
|
</div>
|
||||||
onChange={handleChange}
|
<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'}`}>
|
||||||
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-semibold text-gray-800"
|
{company.is_verified ? 'Verificada' : 'Pendente de Verificação'}
|
||||||
/>
|
</span>
|
||||||
) : (
|
|
||||||
<p className="text-sm font-semibold text-gray-800">{user?.name ?? 'Não informado'}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border border-gray-200 p-4 bg-gray-50">
|
<div className="grid gap-4 sm:grid-cols-2 mb-6">
|
||||||
<p className="text-xs uppercase text-gray-400 mb-1">Perfil</p>
|
<div className="p-4 border border-gray-100 rounded bg-gray-50">
|
||||||
<p className="text-sm font-semibold text-gray-800">{user?.role ?? 'Não informado'}</p>
|
<label className="text-xs text-gray-500 uppercase">Razão Social</label>
|
||||||
{isEditing && <span className="text-[10px] text-gray-500 absolute top-2 right-2">(Não editável)</span>}
|
<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>
|
||||||
|
|
||||||
<div className="rounded-md border border-gray-200 p-4">
|
{/* Documents Section */}
|
||||||
<label className="text-xs uppercase text-gray-400 block mb-1">E-mail</label>
|
<div className="border-t pt-6">
|
||||||
{isEditing ? (
|
<h3 className="text-lg font-medium text-gray-800 mb-4">Documentos da Empresa (KYC)</h3>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="rounded-md border border-gray-200 p-4">
|
<div className="bg-blue-50 border border-blue-100 rounded p-4 mb-4 flex gap-4 items-start">
|
||||||
<label className="text-xs uppercase text-gray-400 block mb-1">Usuário</label>
|
<div className="text-blue-600 mt-1">
|
||||||
{isEditing ? (
|
<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>
|
||||||
<input
|
</div>
|
||||||
name="username"
|
<div className="text-sm text-blue-800">
|
||||||
value={formData.username}
|
<p className="font-semibold">Envie seus documentos para verificação.</p>
|
||||||
onChange={handleChange}
|
<p>Necessário enviar documentos válidos (Licença Sanitária, CRF, Contrato Social) para desbloquear vendas no marketplace.</p>
|
||||||
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-semibold text-gray-800"
|
</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>
|
||||||
</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>
|
</div>
|
||||||
</Shell>
|
</Shell>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Shell } from '../layouts/Shell'
|
import { Shell } from '../layouts/Shell'
|
||||||
import { apiClient } from '../services/apiClient'
|
import { apiClient } from '../services/apiClient'
|
||||||
|
import { adminService } from '../services/adminService'
|
||||||
import { formatCents } from '../utils/format'
|
import { formatCents } from '../utils/format'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { ReviewModal } from '../components/ReviewModal'
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -36,6 +38,32 @@ export function OrdersPage() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
loadOrders()
|
loadOrders()
|
||||||
}, [activeTab])
|
}, [activeTab])
|
||||||
|
|
@ -354,6 +382,17 @@ export function OrdersPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -362,6 +401,13 @@ export function OrdersPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ReviewModal
|
||||||
|
isOpen={reviewModalOpen}
|
||||||
|
onClose={() => setReviewModalOpen(false)}
|
||||||
|
onSubmit={handleSubmitReview}
|
||||||
|
isSubmitting={submittingReview}
|
||||||
|
/>
|
||||||
</Shell>
|
</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() {
|
export function CompaniesPage() {
|
||||||
const [companies, setCompanies] = useState<Company[]>([])
|
const [companies, setCompanies] = useState<Company[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [filterStatus, setFilterStatus] = useState<'all' | 'verified' | 'pending'>('all')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
|
@ -19,7 +20,7 @@ export function CompaniesPage() {
|
||||||
state: 'GO'
|
state: 'GO'
|
||||||
})
|
})
|
||||||
|
|
||||||
const pageSize = 10
|
const pageSize = 50
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCompanies()
|
loadCompanies()
|
||||||
|
|
@ -109,12 +110,41 @@ export function CompaniesPage() {
|
||||||
setShowModal(true)
|
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)
|
const totalPages = Math.ceil(total / pageSize)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<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
|
<button
|
||||||
onClick={openCreate}
|
onClick={openCreate}
|
||||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
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...
|
Carregando...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : companies.length === 0 ? (
|
) : filteredCompanies.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-8 text-center text-gray-500">
|
<td colSpan={6} className="py-8 text-center text-gray-500">
|
||||||
Nenhuma empresa encontrada
|
Nenhuma empresa encontrada
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
companies.map((company) => (
|
filteredCompanies.map((company) => (
|
||||||
<tr key={company.id} className="hover:bg-gray-50">
|
<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 font-medium text-gray-900">{company.corporate_name}</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{company.cnpj}</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)
|
log('upsertShippingSettings result', result)
|
||||||
return 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 ==================
|
// ================== 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