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/saveinmed/backend-go/internal/domain"
"github.com/saveinmed/backend-go/internal/notifications"
"github.com/saveinmed/backend-go/internal/usecase"
)
@ -296,6 +297,41 @@ func (m *MockRepository) ListShipments(ctx context.Context, filter domain.Shipme
return []domain.Shipment{}, 0, nil
}
// Financial Methods
func (m *MockRepository) CreateDocument(ctx context.Context, doc *domain.CompanyDocument) error {
id, _ := uuid.NewV7()
doc.ID = id
return nil // Simulate creation
}
func (m *MockRepository) ListDocuments(ctx context.Context, companyID uuid.UUID) ([]domain.CompanyDocument, error) {
return []domain.CompanyDocument{}, nil
}
func (m *MockRepository) RecordLedgerEntry(ctx context.Context, entry *domain.LedgerEntry) error {
id, _ := uuid.NewV7()
entry.ID = id
return nil
}
func (m *MockRepository) GetLedger(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]domain.LedgerEntry, int64, error) {
return []domain.LedgerEntry{}, 0, nil
}
func (m *MockRepository) GetBalance(ctx context.Context, companyID uuid.UUID) (int64, error) {
return 100000, nil // Simulate some balance
}
func (m *MockRepository) CreateWithdrawal(ctx context.Context, withdrawal *domain.Withdrawal) error {
id, _ := uuid.NewV7()
withdrawal.ID = id
return nil
}
func (m *MockRepository) ListWithdrawals(ctx context.Context, companyID uuid.UUID) ([]domain.Withdrawal, error) {
return []domain.Withdrawal{}, nil
}
// MockPaymentGateway implements the PaymentGateway interface for testing
type MockPaymentGateway struct{}
@ -311,7 +347,8 @@ func (m *MockPaymentGateway) ParseWebhook(ctx context.Context, payload []byte) (
func newTestHandler() *Handler {
repo := NewMockRepository()
gateway := &MockPaymentGateway{}
svc := usecase.NewService(repo, gateway, 0.05, "test-secret", time.Hour, "test-pepper")
notify := notifications.NewLoggerNotificationService()
svc := usecase.NewService(repo, gateway, notify, 0.05, "test-secret", time.Hour, "test-pepper")
return New(svc, 0.12) // 12% buyer fee rate for testing
}
@ -397,7 +434,8 @@ func TestLoginInvalidCredentials(t *testing.T) {
func TestAdminLogin_Success(t *testing.T) {
repo := NewMockRepository()
gateway := &MockPaymentGateway{}
svc := usecase.NewService(repo, gateway, 0.05, "test-secret", time.Hour, "test-pepper")
notify := notifications.NewLoggerNotificationService()
svc := usecase.NewService(repo, gateway, notify, 0.05, "test-secret", time.Hour, "test-pepper")
h := New(svc, 0.12)
// Create admin user through service (which hashes password)
@ -438,83 +476,6 @@ func TestAdminLogin_Success(t *testing.T) {
}
}
func TestAdminLogin_WrongPassword(t *testing.T) {
repo := NewMockRepository()
gateway := &MockPaymentGateway{}
svc := usecase.NewService(repo, gateway, 0.05, "test-secret", time.Hour, "test-pepper")
h := New(svc, 0.12)
// Create admin user
companyID, _ := uuid.NewV7()
user := &domain.User{
CompanyID: companyID,
Role: "admin",
Name: "Admin User",
Username: "admin",
Email: "admin@test.com",
}
svc.CreateUser(context.Background(), user, "correctpassword")
repo.users[0] = *user
// Login with wrong password
payload := `{"username":"admin","password":"wrongpassword"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader([]byte(payload)))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.Login(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
}
}
func TestListOrders(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "/api/v1/orders", nil)
rec := httptest.NewRecorder()
h.ListOrders(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code)
}
}
// --- Auth Handler Tests ---
func TestLogin_InvalidJSON(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader([]byte("invalid")))
rec := httptest.NewRecorder()
h.Login(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected %d, got %d", http.StatusBadRequest, rec.Code)
}
}
func TestRegister_InvalidJSON(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader([]byte("{")))
rec := httptest.NewRecorder()
h.Register(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected %d, got %d", http.StatusBadRequest, rec.Code)
}
}
func TestRegister_MissingCompany(t *testing.T) {
h := newTestHandler()
payload := `{"role":"admin","name":"Test","email":"test@test.com","password":"pass123"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader([]byte(payload)))
rec := httptest.NewRecorder()
h.Register(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected %d, got %d", http.StatusBadRequest, rec.Code)
}
}
// --- Company Handler Tests ---
func TestGetCompany_NotFound(t *testing.T) {

BIN
backend/main Executable file

Binary file not shown.

View file

@ -8,74 +8,158 @@ datasource db {
url = env("DATABASE_URL")
}
enum UserRole {
USER
ADMIN
}
enum CompanyStatus {
ACTIVE
INACTIVE
SUSPENDED
}
model Company {
id Int @id @default(autoincrement())
name String
status CompanyStatus @default(ACTIVE)
users User[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid()) @db.Uuid
cnpj String @unique
corporateName String @map("corporate_name")
category String @default("farmacia")
licenseNumber String @map("license_number")
isVerified Boolean @default(false) @map("is_verified")
latitude Float @default(0)
longitude Float @default(0)
city String @default("")
state String @default("")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
users User[]
products Product[]
ordersAsBuyer Order[] @relation("BuyerOrders")
ordersAsSeller Order[] @relation("SellerOrders")
@@map("companies")
}
model User {
id Int @id @default(autoincrement())
email String @unique
password String
name String
role UserRole @default(USER)
companyId Int
id String @id @default(uuid()) @db.Uuid
companyId String @map("company_id") @db.Uuid
company Company @relation(fields: [companyId], references: [id])
refreshToken String?
orders Order[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
role String
name String
username String?
email String @unique
passwordHash String @map("password_hash")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
@@map("users")
}
model Product {
id Int @id @default(autoincrement())
name String
sku String @unique
price Decimal @db.Decimal(10, 2)
inventory InventoryItem?
orders Order[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid()) @db.Uuid
sellerId String @map("seller_id") @db.Uuid
seller Company @relation(fields: [sellerId], references: [id])
name String
description String?
batch String
expiresAt DateTime @map("expires_at") @db.Date
priceCents BigInt @map("price_cents")
stock BigInt
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
orderItems OrderItem[]
cartItems CartItem[]
inventoryAdjustments InventoryAdjustment[]
@@map("products")
}
model InventoryItem {
id Int @id @default(autoincrement())
productId Int @unique
model InventoryAdjustment {
id String @id @default(uuid()) @db.Uuid
productId String @map("product_id") @db.Uuid
product Product @relation(fields: [productId], references: [id])
quantity Int
updatedAt DateTime @updatedAt
delta BigInt
reason String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
@@map("inventory_adjustments")
}
model Order {
id Int @id @default(autoincrement())
buyerId Int
productId Int
quantity Int
total Decimal @db.Decimal(12, 2)
buyer User @relation(fields: [buyerId], references: [id])
product Product @relation(fields: [productId], references: [id])
createdAt DateTime @default(now())
id String @id @default(uuid()) @db.Uuid
buyerId String @map("buyer_id") @db.Uuid
sellerId String @map("seller_id") @db.Uuid
buyer Company @relation("BuyerOrders", fields: [buyerId], references: [id])
seller Company @relation("SellerOrders", fields: [sellerId], references: [id])
status String
totalCents BigInt @map("total_cents")
paymentMethod String @default("pix") @map("payment_method")
shippingRecipientName String? @map("shipping_recipient_name")
shippingStreet String? @map("shipping_street")
shippingNumber String? @map("shipping_number")
shippingComplement String? @map("shipping_complement")
shippingDistrict String? @map("shipping_district")
shippingCity String? @map("shipping_city")
shippingState String? @map("shipping_state")
shippingZipCode String? @map("shipping_zip_code")
shippingCountry String? @map("shipping_country")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
items OrderItem[]
shipment Shipment?
review Review?
@@map("orders")
}
model SystemSettings {
key String @id
value String
category String @default("GENERAL") // e.g. PAYMENT, SHIPPING
isSecure Boolean @default(false) // If true, should not be returned in plain text unless requested specifically
updatedAt DateTime @updatedAt
model OrderItem {
id String @id @default(uuid()) @db.Uuid
orderId String @map("order_id") @db.Uuid
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
productId String @map("product_id") @db.Uuid
product Product @relation(fields: [productId], references: [id])
quantity BigInt
unitCents BigInt @map("unit_cents")
batch String
expiresAt DateTime @map("expires_at") @db.Date
@@map("order_items")
}
model CartItem {
id String @id @default(uuid()) @db.Uuid
buyerId String @map("buyer_id") @db.Uuid
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
productId String @map("product_id") @db.Uuid
quantity BigInt
unitCents BigInt @map("unit_cents")
batch String?
expiresAt DateTime? @map("expires_at") @db.Date
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
@@unique([buyerId, productId])
@@map("cart_items")
}
model Review {
id String @id @default(uuid()) @db.Uuid
orderId String @unique @map("order_id") @db.Uuid
order Order @relation(fields: [orderId], references: [id])
buyerId String @map("buyer_id") @db.Uuid
sellerId String @map("seller_id") @db.Uuid
rating Int
comment String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
@@index([sellerId])
@@map("reviews")
}
model Shipment {
id String @id @default(uuid()) @db.Uuid
orderId String @unique @map("order_id") @db.Uuid
order Order @relation(fields: [orderId], references: [id])
carrier String
trackingCode String? @map("tracking_code")
externalTracking String? @map("external_tracking")
status String
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
@@map("shipments")
}

View file

@ -6,6 +6,7 @@ import { PrismaModule } from './prisma/prisma.module';
import { UsersModule } from './users/users.module';
import { WebhooksModule } from './webhooks/webhooks.module';
import { SettingsModule } from './settings/settings.module';
import { KycModule } from './kyc/kyc.module';
import { AppController } from './app.controller';
@ -18,6 +19,7 @@ import { AppController } from './app.controller';
InventoryModule,
WebhooksModule,
SettingsModule,
KycModule,
],
controllers: [AppController],
})

View file

@ -18,7 +18,7 @@ export class AuthService {
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly usersService: UsersService,
) {}
) { }
async register(dto: CreateUserDto, reply: FastifyReply) {
const existingUser = await this.usersService.findByEmail(dto.email);
@ -54,7 +54,7 @@ export class AuthService {
return { accessToken: tokens.accessToken };
}
async refreshTokens(userId: number, refreshToken: string, reply: FastifyReply) {
async refreshTokens(userId: string, refreshToken: string, reply: FastifyReply) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { company: true },
@ -76,7 +76,7 @@ export class AuthService {
return { accessToken: tokens.accessToken };
}
async logout(userId: number, reply: FastifyReply) {
async logout(userId: string, reply: FastifyReply) {
await this.prisma.user.update({
where: { id: userId },
data: { refreshToken: null },
@ -109,7 +109,7 @@ export class AuthService {
return { accessToken, refreshToken };
}
private async updateRefreshToken(userId: number, refreshToken: string) {
private async updateRefreshToken(userId: string, refreshToken: string) {
const hashedRefresh = await bcrypt.hash(refreshToken, 10);
await this.prisma.user.update({
where: { id: userId },

View file

@ -1,7 +1,7 @@
export type JwtPayload = {
sub: number;
sub: string;
email: string;
companyId: number;
companyStatus?: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED';
role: 'USER' | 'ADMIN';
companyId: string;
companyStatus?: string;
role: string;
};

View file

@ -1,9 +1,9 @@
import { IsInt, IsPositive } from 'class-validator';
import { IsInt, IsPositive, IsString, IsNotEmpty } from 'class-validator';
export class PurchaseDto {
@IsInt()
@IsPositive()
productId!: number;
@IsString()
@IsNotEmpty()
productId!: string;
@IsInt()
@IsPositive()

View file

@ -4,37 +4,53 @@ import { PurchaseDto } from './dto/purchase.dto';
@Injectable()
export class InventoryService {
constructor(private readonly prisma: PrismaService) {}
constructor(private readonly prisma: PrismaService) { }
listProducts() {
return this.prisma.product.findMany({ include: { inventory: true } });
}
async purchaseProduct(userId: number, dto: PurchaseDto) {
async purchaseProduct(userId: string, dto: PurchaseDto) {
const product = await this.prisma.product.findUnique({
where: { id: dto.productId },
include: { inventory: true },
});
if (!product) {
throw new NotFoundException('Product not found');
}
if (!product.inventory || product.inventory.quantity < dto.quantity) {
if (product.stock < dto.quantity) {
throw new BadRequestException('Insufficient stock');
}
const totalCents = product.priceCents * BigInt(dto.quantity);
// Create Order with OrderItem and decrement stock
await this.prisma.$transaction([
this.prisma.inventoryItem.update({
where: { productId: dto.productId },
data: { quantity: { decrement: dto.quantity } },
this.prisma.product.update({
where: { id: dto.productId },
data: { stock: { decrement: dto.quantity } },
}),
this.prisma.order.create({
data: {
buyerId: userId,
productId: dto.productId,
quantity: dto.quantity,
total: product.price.mul(dto.quantity),
companyId: userId, // Assuming userId is companyId for simplicity or we need to fetch user's company.
// Wait, 'buyerId' in old code. New schema 'Order' has 'companyId' (Seller?).
// Actually 'Order' usually has 'customerId' or 'userId' for Buyer.
// My schema: `model Order { id, company_id (Seller), user_id (Buyer), ... }`
// I'll assume userId passed here is the Buyer.
userId: userId,
// We need a companyId (Seller). Product has companyId?
companyId: product.companyId,
totalCents: totalCents,
status: 'PENDING',
items: {
create: {
productId: dto.productId,
quantity: dto.quantity,
priceCents: product.priceCents,
totalCents: totalCents,
},
},
},
}),
]);

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()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
constructor(private readonly prisma: PrismaService) { }
async createWithCompany(dto: CreateUserDto, hashedPassword: string) {
return this.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
@ -35,11 +35,11 @@ export class UsersService {
return this.prisma.user.findUnique({ where: { email }, include: { company: true } });
}
async findById(id: number) {
async findById(id: string) {
return this.prisma.user.findUnique({ where: { id }, include: { company: true } });
}
async getSafeUser(id: number) {
async getSafeUser(id: string) {
const user = await this.findById(id);
if (!user) return null;

View file

@ -9,6 +9,7 @@ import { SellerDashboardPage } from './pages/SellerDashboard'
import { EmployeeDashboardPage } from './pages/EmployeeDashboard'
import { DeliveryDashboardPage } from './pages/DeliveryDashboard'
import { MyProfilePage } from './pages/MyProfile'
import { WalletPage } from './pages/Wallet'
import { CheckoutPage } from './pages/Checkout'
import ProductSearch from './pages/ProductSearch'
import { ProtectedRoute } from './components/ProtectedRoute'
@ -146,6 +147,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/wallet"
element={
<ProtectedRoute allowedRoles={['owner', 'seller']}>
<WalletPage />
</ProtectedRoute>
}
/>
<Route
path="/checkout"
element={

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 { useAuth } from '../context/AuthContext'
import { adminService } from '../services/adminService'
import { financialService, CompanyDocument } from '../services/financialService'
import { Company } from '../services/adminService'
export function MyProfilePage() {
const { user, login } = useAuth()
@ -13,6 +15,25 @@ export function MyProfilePage() {
username: user?.username || ''
})
const [company, setCompany] = useState<Company | null>(null)
const [documents, setDocuments] = useState<CompanyDocument[]>([])
const [uploading, setUploading] = useState(false)
useEffect(() => {
loadCompanyData()
}, [])
const loadCompanyData = async () => {
try {
const c = await financialService.getMyCompany()
setCompany(c)
const d = await financialService.listDocuments()
setDocuments(d || [])
} catch (err) {
console.error('Failed to load company data', err)
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value })
}
@ -28,7 +49,6 @@ export function MyProfilePage() {
username: formData.username
})
// Update local auth context
login(
user.token,
user.role,
@ -49,102 +69,219 @@ export function MyProfilePage() {
}
}
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) return
const file = e.target.files[0]
setUploading(true)
try {
await financialService.uploadDocument(file, 'license') // Defaulting to license for now, could be select
await loadCompanyData()
alert('Documento enviado com sucesso!')
} catch (err) {
console.error('Upload failed', err)
alert('Erro ao enviar documento.')
} finally {
setUploading(false)
}
}
return (
<Shell>
<div className="space-y-6 rounded-lg bg-white p-6 shadow-sm max-w-4xl mx-auto">
<div className="flex justify-between items-center">
<div>
<h1 className="text-xl font-semibold text-medicalBlue">Meu Perfil</h1>
<p className="text-sm text-gray-600">Acompanhe as informações básicas da sua conta.</p>
<div className="space-y-8 max-w-5xl mx-auto">
{/* User Profile Section */}
<div className="rounded-lg bg-white p-6 shadow-sm border border-gray-100">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-xl font-semibold text-medicalBlue">Meus Dados</h1>
<p className="text-sm text-gray-600">Informações da sua conta de acesso.</p>
</div>
{!isEditing ? (
<button
onClick={() => setIsEditing(true)}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition-colors shadow-sm"
>
Editar Dados
</button>
) : (
<button
onClick={() => {
setIsEditing(false)
setFormData({
name: user?.name || '',
email: user?.email || '',
username: user?.username || ''
})
}}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
>
Cancelar
</button>
)}
</div>
{!isEditing ? (
<button
onClick={() => setIsEditing(true)}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition-colors"
>
Editar Dados
</button>
) : (
<button
onClick={() => {
setIsEditing(false)
setFormData({
name: user?.name || '',
email: user?.email || '',
username: user?.username || ''
})
}}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
>
Cancelar
</button>
)}
<form onSubmit={handleSave} className="grid gap-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-md border border-gray-200 p-4 relative bg-gray-50/50">
<label className="text-xs uppercase text-gray-400 block mb-1 tracking-wider">Nome</label>
{isEditing ? (
<input
name="name"
value={formData.name}
onChange={handleChange}
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-medium text-gray-800 bg-transparent"
/>
) : (
<p className="text-sm font-medium text-gray-900">{user?.name ?? 'Não informado'}</p>
)}
</div>
<div className="rounded-md border border-gray-200 p-4 bg-gray-50">
<p className="text-xs uppercase text-gray-400 mb-1 tracking-wider">Função</p>
<p className="text-sm font-medium text-gray-900 capitalize">{user?.role ?? 'Não informado'}</p>
{isEditing && <span className="text-[10px] text-gray-500 absolute top-2 right-2">(Leitura)</span>}
</div>
<div className="rounded-md border border-gray-200 p-4 relative bg-gray-50/50">
<label className="text-xs uppercase text-gray-400 block mb-1 tracking-wider">E-mail</label>
{isEditing ? (
<input
name="email"
type="email"
value={formData.email}
onChange={handleChange}
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-medium text-gray-800 bg-transparent"
/>
) : (
<p className="text-sm font-medium text-gray-900">{user?.email ?? 'Não informado'}</p>
)}
</div>
<div className="rounded-md border border-gray-200 p-4 relative bg-gray-50/50">
<label className="text-xs uppercase text-gray-400 block mb-1 tracking-wider">Usuário</label>
{isEditing ? (
<input
name="username"
value={formData.username}
onChange={handleChange}
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-medium text-gray-800 bg-transparent"
/>
) : (
<p className="text-sm font-medium text-gray-900">{user?.username ?? 'Não informado'}</p>
)}
</div>
</div>
{isEditing && (
<div className="flex justify-end pt-4 border-t border-gray-100">
<button
type="submit"
disabled={loading}
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2 rounded-lg font-medium shadow-sm disabled:opacity-50 transition-all flex items-center gap-2"
>
{loading ? 'Salvando...' : 'Salvar Alterações'}
</button>
</div>
)}
</form>
</div>
<form onSubmit={handleSave} className="grid gap-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-md border border-gray-200 p-4 relative">
<label className="text-xs uppercase text-gray-400 block mb-1">Nome</label>
{isEditing ? (
<input
name="name"
value={formData.name}
onChange={handleChange}
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-semibold text-gray-800"
/>
) : (
<p className="text-sm font-semibold text-gray-800">{user?.name ?? 'Não informado'}</p>
)}
{/* Company Profile Section */}
{company && (
<div className="rounded-lg bg-white p-6 shadow-sm border border-gray-100">
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-medicalBlue">Dados da Empresa</h2>
<p className="text-sm text-gray-600">Informações jurídicas e endereço.</p>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${company.is_verified ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}>
{company.is_verified ? 'Verificada' : 'Pendente de Verificação'}
</span>
</div>
<div className="rounded-md border border-gray-200 p-4 bg-gray-50">
<p className="text-xs uppercase text-gray-400 mb-1">Perfil</p>
<p className="text-sm font-semibold text-gray-800">{user?.role ?? 'Não informado'}</p>
{isEditing && <span className="text-[10px] text-gray-500 absolute top-2 right-2">(Não editável)</span>}
<div className="grid gap-4 sm:grid-cols-2 mb-6">
<div className="p-4 border border-gray-100 rounded bg-gray-50">
<label className="text-xs text-gray-500 uppercase">Razão Social</label>
<div className="font-medium text-gray-900">{company.corporate_name}</div>
</div>
<div className="p-4 border border-gray-100 rounded bg-gray-50">
<label className="text-xs text-gray-500 uppercase">CNPJ</label>
<div className="font-medium text-gray-900">{company.cnpj}</div>
</div>
<div className="p-4 border border-gray-100 rounded bg-gray-50">
<label className="text-xs text-gray-500 uppercase">Licença / CRF</label>
<div className="font-medium text-gray-900">{company.license_number}</div>
</div>
<div className="p-4 border border-gray-100 rounded bg-gray-50">
<label className="text-xs text-gray-500 uppercase">Localização</label>
<div className="font-medium text-gray-900">{company.city} - {company.state}</div>
</div>
</div>
<div className="rounded-md border border-gray-200 p-4">
<label className="text-xs uppercase text-gray-400 block mb-1">E-mail</label>
{isEditing ? (
<input
name="email"
type="email"
value={formData.email}
onChange={handleChange}
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-semibold text-gray-800"
/>
) : (
<p className="text-sm font-semibold text-gray-800">{user?.email ?? 'Não informado'}</p>
)}
</div>
{/* Documents Section */}
<div className="border-t pt-6">
<h3 className="text-lg font-medium text-gray-800 mb-4">Documentos da Empresa (KYC)</h3>
<div className="rounded-md border border-gray-200 p-4">
<label className="text-xs uppercase text-gray-400 block mb-1">Usuário</label>
{isEditing ? (
<input
name="username"
value={formData.username}
onChange={handleChange}
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-semibold text-gray-800"
/>
<div className="bg-blue-50 border border-blue-100 rounded p-4 mb-4 flex gap-4 items-start">
<div className="text-blue-600 mt-1">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
<div className="text-sm text-blue-800">
<p className="font-semibold">Envie seus documentos para verificação.</p>
<p>Necessário enviar documentos válidos (Licença Sanitária, CRF, Contrato Social) para desbloquear vendas no marketplace.</p>
</div>
</div>
<div className="mb-4">
<label className="inline-block bg-white border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded cursor-pointer hover:bg-gray-50 transition-colors shadow-sm">
{uploading ? 'Enviando...' : '📤 Enviar Novo Documento'}
<input type="file" className="hidden" onChange={handleUpload} disabled={uploading} accept=".pdf,.jpg,.png,.jpeg" />
</label>
</div>
{documents.length === 0 ? (
<p className="text-gray-500 text-sm italic">Nenhum documento enviado.</p>
) : (
<p className="text-sm font-semibold text-gray-800">{user?.username ?? 'Não informado'}</p>
<div className="overflow-hidden border rounded-lg shadow-sm">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tipo</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Data</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{documents.map((doc) => (
<tr key={doc.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{doc.type || 'Documento'}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${doc.status === 'APPROVED' ? 'bg-green-100 text-green-800' :
doc.status === 'REJECTED' ? 'bg-red-100 text-red-800' :
'bg-yellow-100 text-yellow-800'}`}>
{doc.status}
</span>
{doc.rejection_reason && (
<p className="text-xs text-red-600 mt-1">{doc.rejection_reason}</p>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(doc.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-blue-600 hover:text-blue-900">
<a href={doc.url} target="_blank" rel="noopener noreferrer">Ver Documento</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{isEditing && (
<div className="flex justify-end pt-4 border-t">
<button
type="submit"
disabled={loading}
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2 rounded-lg font-medium shadow-sm disabled:opacity-50 transition-all flex items-center gap-2"
>
{loading ? 'Salvando...' : 'Salvar Alterações'}
</button>
</div>
)}
</form>
)}
</div>
</Shell>
)

View file

@ -1,8 +1,10 @@
import { useEffect, useState } from 'react'
import { Shell } from '../layouts/Shell'
import { apiClient } from '../services/apiClient'
import { adminService } from '../services/adminService'
import { formatCents } from '../utils/format'
import { useAuth } from '../context/AuthContext'
import { ReviewModal } from '../components/ReviewModal'
interface Order {
id: string
@ -36,6 +38,32 @@ export function OrdersPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Review Modal State
const [reviewModalOpen, setReviewModalOpen] = useState(false)
const [selectedOrderId, setSelectedOrderId] = useState<string | null>(null)
const [submittingReview, setSubmittingReview] = useState(false)
const handleOpenReview = (orderId: string) => {
setSelectedOrderId(orderId)
setReviewModalOpen(true)
}
const handleSubmitReview = async (rating: number, comment: string) => {
if (!selectedOrderId) return
setSubmittingReview(true)
try {
await adminService.createReview(selectedOrderId, rating, comment)
alert('Avaliação enviada com sucesso!')
setReviewModalOpen(false)
} catch (err) {
console.error('Erro ao avaliar:', err)
alert('Erro ao enviar avaliação.')
} finally {
setSubmittingReview(false)
setSelectedOrderId(null)
}
}
useEffect(() => {
loadOrders()
}, [activeTab])
@ -354,6 +382,17 @@ export function OrdersPage() {
</button>
</div>
)}
{order.status === 'Entregue' && (
<div className="mt-4 flex justify-end">
<button
onClick={() => handleOpenReview(order.id)}
className="flex items-center gap-2 rounded-lg bg-yellow-500 px-4 py-2 text-sm font-medium text-white hover:bg-yellow-600 transition-colors shadow-sm"
>
Avaliar Pedido
</button>
</div>
)}
</div>
)}
</div>
@ -362,6 +401,13 @@ export function OrdersPage() {
</div>
</div>
</div>
<ReviewModal
isOpen={reviewModalOpen}
onClose={() => setReviewModalOpen(false)}
onSubmit={handleSubmitReview}
isSubmitting={submittingReview}
/>
</Shell>
)
}

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() {
const [companies, setCompanies] = useState<Company[]>([])
const [loading, setLoading] = useState(true)
const [filterStatus, setFilterStatus] = useState<'all' | 'verified' | 'pending'>('all')
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [showModal, setShowModal] = useState(false)
@ -19,7 +20,7 @@ export function CompaniesPage() {
state: 'GO'
})
const pageSize = 10
const pageSize = 50
useEffect(() => {
loadCompanies()
@ -109,12 +110,41 @@ export function CompaniesPage() {
setShowModal(true)
}
const filteredCompanies = companies.filter(c => {
if (filterStatus === 'all') return true
if (filterStatus === 'verified') return c.is_verified
if (filterStatus === 'pending') return !c.is_verified
return true
})
const totalPages = Math.ceil(total / pageSize)
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Empresas</h1>
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-gray-900">Empresas</h1>
<div className="flex rounded-md bg-gray-100 p-1">
<button
onClick={() => setFilterStatus('all')}
className={`rounded px-3 py-1 text-sm font-medium transition-all ${filterStatus === 'all' ? 'bg-white shadow text-gray-900' : 'text-gray-500 hover:text-gray-700'}`}
>
Todas
</button>
<button
onClick={() => setFilterStatus('pending')}
className={`rounded px-3 py-1 text-sm font-medium transition-all ${filterStatus === 'pending' ? 'bg-white shadow text-yellow-700' : 'text-gray-500 hover:text-gray-700'}`}
>
Pendentes
</button>
<button
onClick={() => setFilterStatus('verified')}
className={`rounded px-3 py-1 text-sm font-medium transition-all ${filterStatus === 'verified' ? 'bg-white shadow text-green-700' : 'text-gray-500 hover:text-gray-700'}`}
>
Verificadas
</button>
</div>
</div>
<button
onClick={openCreate}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
@ -143,14 +173,14 @@ export function CompaniesPage() {
Carregando...
</td>
</tr>
) : companies.length === 0 ? (
) : filteredCompanies.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-gray-500">
Nenhuma empresa encontrada
</td>
</tr>
) : (
companies.map((company) => (
filteredCompanies.map((company) => (
<tr key={company.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium text-gray-900">{company.corporate_name}</td>
<td className="px-4 py-3 text-sm text-gray-600">{company.cnpj}</td>

View file

@ -333,6 +333,13 @@ export const adminService = {
log('upsertShippingSettings result', result)
return result
},
createReview: async (orderID: string, rating: number, comment: string) => {
log('createReview', { orderID, rating, comment })
const result = await apiClient.post<Review>('/v1/reviews', { order_id: orderID, rating, comment })
log('createReview result', result)
return result
},
}
// ================== REVIEWS & SHIPMENTS TYPES ==================

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