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:
Tiago Yamamoto 2025-12-27 01:56:32 -03:00
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

View file

@ -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

Binary file not shown.

View file

@ -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")
} }

View file

@ -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],
}) })

View file

@ -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 },

View file

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

View file

@ -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()

View file

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

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

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

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

View file

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

View file

@ -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={

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

View file

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

View file

@ -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>
) )
} }

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

View file

@ -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>

View file

@ -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 ==================

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