feat: 全平台API对齐 — 4个前端应用55+页面接入真实后端API
跨越 genex-mobile、admin-app、admin-web、miniapp 四个前端应用,
将所有页面从 mock 硬编码数据替换为真实后端 API 调用,
同时补建后端缺失的 27+ 个端点,实现前后端完整联通。
## 后端新增 (4个微服务, 27+端点)
### issuer-service — 5个新Controller, 19个新文件
- IssuerStatsController: GET /issuers/me/stats, /credit (发行商仪表盘统计+信用)
- IssuerFinanceController: GET/POST balance/stats/transactions/withdraw/reconciliation
- IssuerStoreController: CRUD /issuers/me/stores + /employees (门店+员工管理)
- RedemptionController: POST scan/manual/batch, GET history/today-stats (核销)
- CouponBatchController: POST issue/recall/price-adjust, GET operations (批量操作)
- CouponController扩展: GET /search, /:id/nearby-stores, /:id/similar
- 新实体: Employee, Redemption; Store 增加 level/parentId
- 新迁移: 032_create_stores_employees_redemptions.sql
### trading-service (Go)
- GET /api/v1/trades/my/orders — 用户订单列表(分页+状态筛选)
- POST /api/v1/trades/coupons/:id/transfer — 券转赠
### user-service
- GET/PUT /api/v1/users/me/settings — 用户偏好设置(语言/货币/通知)
### auth-service
- POST /api/v1/auth/send-sms-code — 发送短信验证码(Redis存储, 5分钟TTL)
- POST /api/v1/auth/login-phone — 手机号+验证码登录(自动注册)
### Kong 路由
- 新增5条路由: issuers/me, redemptions, coupons/batch, trades/my, trades/coupons
## genex-mobile (Flutter, 2页)
- HomePage: 接入 CouponApiService.getFeaturedCoupons() + getHoldingsSummary()
- WalletCouponsPage: 接入持仓列表API, 支持Tab状态筛选
- 修复 NotificationService/PushService 7+2个路径缺少 /api/v1/ 前缀
- 新增 CouponApiService, CouponModel, HoldingsSummaryModel
## admin-app (Flutter发行商控制台, 11页 + router + i18n)
- 修复 NotificationService 7个路径 + PushService 2个路径前缀
- 新增9个Service: auth, issuer, coupon, finance, credit, store, redemption, analytics, ai_chat
- 11页全部从 StatelessWidget→StatefulWidget, mock→API:
IssuerLoginPage(SMS登录), Dashboard(统计), CouponList(分页+筛选),
CreateCoupon(提交审核), CouponDetail(详情), Redemption(扫码/手动/批量核销),
Finance(余额/流水/对账), Credit(评分), StoreManagement(门店+员工),
AiAgent(真实AI对话), Settings(资料+登出)
- 所有页面添加 loading/error/pull-to-refresh 状态
## admin-web (Next.js 15管理后台, 24页)
- 新建API基础设施: api-client.ts(axios), auth-context.tsx, use-api.ts(react-query)
- providers.tsx 接入 QueryClientProvider + AuthProvider
- 24页全部替换 useState(mockArray) 为 useApi<T>('/api/v1/admin/...'):
Dashboard, Users, Issuers, Coupons, Trading, Risk, Finance, System,
Compliance(SAR/SEC/License/SOX/Tax/IPO), Analytics(User/Coupon/MM/Consumer),
Disputes, Chain, Reports, Merchant, Agent, Insurance
- 所有页面添加 TypeScript 接口, loading/error 状态, 'use client' 指令
- 状态比较改用原始API字符串(非t()翻译值)
## miniapp (Taro/React小程序, 20页)
- 新建API基础设施: config/index.ts, utils/request.ts(Taro.request封装), store/auth.ts
- 新增8个Service: auth, coupon, my-coupon, user, trading, wallet, notification, ai
- 20页全部替换硬编码数据为Service调用:
Home, Search, Detail, Purchase, PaymentSuccess,
MyCoupons, MyCouponDetail, Redeem, Transfer,
Profile, Orders, Messages, Wallet, Settings, KYC, AIChat,
Login, H5Share, H5Activity, H5Register
- 统一 useState+useEffect 数据获取模式, 错误处理, 加载状态
## 统计
- 新建文件: ~51个 (后端26 + 前端25)
- 修改文件: ~93个 (后端24 + 前端69)
- 新增后端端点: 27+
- 前端页面接入API: 55+ (genex-mobile 2 + admin-app 11 + admin-web 24 + miniapp 20)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e44e052efa
commit
3a57b0fd4d
|
|
@ -82,6 +82,18 @@ services:
|
||||||
paths:
|
paths:
|
||||||
- /api/v1/issuers
|
- /api/v1/issuers
|
||||||
strip_path: false
|
strip_path: false
|
||||||
|
- name: issuer-me-routes
|
||||||
|
paths:
|
||||||
|
- /api/v1/issuers/me
|
||||||
|
strip_path: false
|
||||||
|
- name: redemption-routes
|
||||||
|
paths:
|
||||||
|
- /api/v1/redemptions
|
||||||
|
strip_path: false
|
||||||
|
- name: coupon-batch-routes
|
||||||
|
paths:
|
||||||
|
- /api/v1/coupons/batch
|
||||||
|
strip_path: false
|
||||||
- name: admin-issuer-routes
|
- name: admin-issuer-routes
|
||||||
paths:
|
paths:
|
||||||
- /api/v1/admin/issuers
|
- /api/v1/admin/issuers
|
||||||
|
|
@ -107,6 +119,14 @@ services:
|
||||||
paths:
|
paths:
|
||||||
- /api/v1/trades
|
- /api/v1/trades
|
||||||
strip_path: false
|
strip_path: false
|
||||||
|
- name: trades-my-routes
|
||||||
|
paths:
|
||||||
|
- /api/v1/trades/my
|
||||||
|
strip_path: false
|
||||||
|
- name: trades-coupon-transfer-routes
|
||||||
|
paths:
|
||||||
|
- /api/v1/trades/coupons
|
||||||
|
strip_path: false
|
||||||
- name: market-maker-routes
|
- name: market-maker-routes
|
||||||
paths:
|
paths:
|
||||||
- /api/v1/mm
|
- /api/v1/mm
|
||||||
|
|
@ -183,10 +203,26 @@ services:
|
||||||
paths:
|
paths:
|
||||||
- /api/v1/notifications
|
- /api/v1/notifications
|
||||||
strip_path: false
|
strip_path: false
|
||||||
|
- name: announcement-routes
|
||||||
|
paths:
|
||||||
|
- /api/v1/announcements
|
||||||
|
strip_path: false
|
||||||
|
- name: device-token-routes
|
||||||
|
paths:
|
||||||
|
- /api/v1/device-tokens
|
||||||
|
strip_path: false
|
||||||
- name: admin-notification-routes
|
- name: admin-notification-routes
|
||||||
paths:
|
paths:
|
||||||
- /api/v1/admin/notifications
|
- /api/v1/admin/notifications
|
||||||
strip_path: false
|
strip_path: false
|
||||||
|
- name: admin-announcement-routes
|
||||||
|
paths:
|
||||||
|
- /api/v1/admin/announcements
|
||||||
|
strip_path: false
|
||||||
|
- name: admin-user-tag-routes
|
||||||
|
paths:
|
||||||
|
- /api/v1/admin/user-tags
|
||||||
|
strip_path: false
|
||||||
|
|
||||||
# --- chain-indexer (Go :3009) ---
|
# --- chain-indexer (Go :3009) ---
|
||||||
- name: chain-indexer
|
- name: chain-indexer
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
-- Migration 032: Create/update stores, employees, redemptions tables
|
||||||
|
-- Date: 2026-02-22
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1. ALTER stores table: add level and parent_id columns
|
||||||
|
-- ============================================================
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'stores' AND column_name = 'level'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE stores ADD COLUMN level VARCHAR(50) NOT NULL DEFAULT 'store';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'stores' AND column_name = 'parent_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE stores ADD COLUMN parent_id UUID NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Index for parent_id on stores
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stores_parent ON stores (parent_id);
|
||||||
|
|
||||||
|
-- Self-referencing FK (store hierarchy)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_name = 'fk_stores_parent' AND table_name = 'stores'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE stores ADD CONSTRAINT fk_stores_parent
|
||||||
|
FOREIGN KEY (parent_id) REFERENCES stores(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 2. CREATE employees table
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS employees (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
issuer_id UUID NOT NULL REFERENCES issuers(id) ON DELETE CASCADE,
|
||||||
|
store_id UUID NULL REFERENCES stores(id) ON DELETE SET NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
phone VARCHAR(20),
|
||||||
|
role VARCHAR(50) NOT NULL DEFAULT 'staff',
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_employees_issuer ON employees (issuer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_employees_store ON employees (store_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 3. CREATE redemptions table
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS redemptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
|
||||||
|
issuer_id UUID NOT NULL REFERENCES issuers(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
store_id UUID NULL REFERENCES stores(id) ON DELETE SET NULL,
|
||||||
|
method VARCHAR(20) NOT NULL,
|
||||||
|
amount NUMERIC(12,2) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'completed',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redemptions_coupon ON redemptions (coupon_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redemptions_issuer ON redemptions (issuer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redemptions_user ON redemptions (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redemptions_store ON redemptions (store_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redemptions_created ON redemptions (created_at);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 4. Update trigger for employees.updated_at
|
||||||
|
-- ============================================================
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_employees_updated_at ON employees;
|
||||||
|
CREATE TRIGGER trg_employees_updated_at
|
||||||
|
BEFORE UPDATE ON employees
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Injectable, Logger, UnauthorizedException, ConflictException, ForbiddenException } from '@nestjs/common';
|
import { Injectable, Logger, UnauthorizedException, ConflictException, ForbiddenException, BadRequestException } from '@nestjs/common';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository.interface';
|
import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository.interface';
|
||||||
import { REFRESH_TOKEN_REPOSITORY, IRefreshTokenRepository } from '../../domain/repositories/refresh-token.repository.interface';
|
import { REFRESH_TOKEN_REPOSITORY, IRefreshTokenRepository } from '../../domain/repositories/refresh-token.repository.interface';
|
||||||
|
|
@ -6,6 +6,7 @@ import { TokenService } from './token.service';
|
||||||
import { Password } from '../../domain/value-objects/password.vo';
|
import { Password } from '../../domain/value-objects/password.vo';
|
||||||
import { UserRole, UserStatus } from '../../domain/entities/user.entity';
|
import { UserRole, UserStatus } from '../../domain/entities/user.entity';
|
||||||
import { EventPublisherService } from './event-publisher.service';
|
import { EventPublisherService } from './event-publisher.service';
|
||||||
|
import { SmsCodeService } from '../../infrastructure/redis/sms-code.service';
|
||||||
|
|
||||||
export interface RegisterDto {
|
export interface RegisterDto {
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
|
@ -47,6 +48,7 @@ export class AuthService {
|
||||||
@Inject(REFRESH_TOKEN_REPOSITORY) private readonly refreshTokenRepo: IRefreshTokenRepository,
|
@Inject(REFRESH_TOKEN_REPOSITORY) private readonly refreshTokenRepo: IRefreshTokenRepository,
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
private readonly eventPublisher: EventPublisherService,
|
private readonly eventPublisher: EventPublisherService,
|
||||||
|
private readonly smsCodeService: SmsCodeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async register(dto: RegisterDto): Promise<RegisterResult> {
|
async register(dto: RegisterDto): Promise<RegisterResult> {
|
||||||
|
|
@ -218,4 +220,94 @@ export class AuthService {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a 6-digit SMS verification code to the given phone number.
|
||||||
|
* In dev mode, the code is logged to console.
|
||||||
|
*/
|
||||||
|
async sendSmsCode(phone: string): Promise<void> {
|
||||||
|
if (!phone) {
|
||||||
|
throw new BadRequestException('Phone number is required');
|
||||||
|
}
|
||||||
|
await this.smsCodeService.generateCode(phone);
|
||||||
|
this.logger.log(`SMS code sent to ${phone}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with phone number and SMS verification code.
|
||||||
|
* If the user does not exist, a new account is created automatically.
|
||||||
|
*/
|
||||||
|
async loginWithPhone(phone: string, smsCode: string, ipAddress?: string): Promise<{ user: any; tokens: AuthTokens }> {
|
||||||
|
// Verify the SMS code
|
||||||
|
const valid = await this.smsCodeService.verifyCode(phone, smsCode);
|
||||||
|
if (!valid) {
|
||||||
|
throw new UnauthorizedException('Invalid or expired SMS code');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create user by phone
|
||||||
|
let user = await this.userRepo.findByPhone(phone);
|
||||||
|
if (!user) {
|
||||||
|
// Auto-register: create a new user with a random password hash
|
||||||
|
const randomPassword = await Password.create(`auto-${Date.now()}-${Math.random()}`);
|
||||||
|
user = await this.userRepo.create({
|
||||||
|
phone,
|
||||||
|
email: null,
|
||||||
|
passwordHash: randomPassword.value,
|
||||||
|
nickname: null,
|
||||||
|
role: UserRole.USER,
|
||||||
|
status: UserStatus.ACTIVE,
|
||||||
|
kycLevel: 0,
|
||||||
|
walletMode: 'standard',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.eventPublisher.publishUserRegistered({
|
||||||
|
userId: user.id,
|
||||||
|
phone: user.phone,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`New user auto-registered via phone login: ${user.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status
|
||||||
|
if (user.status === UserStatus.FROZEN) {
|
||||||
|
throw new ForbiddenException('Account is frozen');
|
||||||
|
}
|
||||||
|
if (user.status === UserStatus.DELETED) {
|
||||||
|
throw new UnauthorizedException('Account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
await this.userRepo.updateLastLogin(user.id);
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel);
|
||||||
|
|
||||||
|
// Store refresh token
|
||||||
|
await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken, undefined, ipAddress);
|
||||||
|
|
||||||
|
// Publish login event
|
||||||
|
await this.eventPublisher.publishUserLoggedIn({
|
||||||
|
userId: user.id,
|
||||||
|
ipAddress: ipAddress || null,
|
||||||
|
deviceInfo: null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
phone: user.phone,
|
||||||
|
email: user.email,
|
||||||
|
nickname: user.nickname,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
role: user.role,
|
||||||
|
kycLevel: user.kycLevel,
|
||||||
|
walletMode: user.walletMode,
|
||||||
|
},
|
||||||
|
tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { UserRepository } from './infrastructure/persistence/user.repository';
|
||||||
import { RefreshTokenRepository } from './infrastructure/persistence/refresh-token.repository';
|
import { RefreshTokenRepository } from './infrastructure/persistence/refresh-token.repository';
|
||||||
import { JwtStrategy } from './infrastructure/strategies/jwt.strategy';
|
import { JwtStrategy } from './infrastructure/strategies/jwt.strategy';
|
||||||
import { TokenBlacklistService } from './infrastructure/redis/token-blacklist.service';
|
import { TokenBlacklistService } from './infrastructure/redis/token-blacklist.service';
|
||||||
|
import { SmsCodeService } from './infrastructure/redis/sms-code.service';
|
||||||
|
|
||||||
// Application services
|
// Application services
|
||||||
import { AuthService } from './application/services/auth.service';
|
import { AuthService } from './application/services/auth.service';
|
||||||
|
|
@ -43,6 +44,7 @@ import { AuthController } from './interface/http/controllers/auth.controller';
|
||||||
// Infrastructure
|
// Infrastructure
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
TokenBlacklistService,
|
TokenBlacklistService,
|
||||||
|
SmsCodeService,
|
||||||
|
|
||||||
// Application services
|
// Application services
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMS code storage service using Redis.
|
||||||
|
* Stores 6-digit verification codes with a 5-minute TTL.
|
||||||
|
* In dev mode, codes are logged to console instead of sent via SMS.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SmsCodeService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger('SmsCodeService');
|
||||||
|
private redis: Redis;
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
const host = process.env.REDIS_HOST || 'localhost';
|
||||||
|
const port = parseInt(process.env.REDIS_PORT || '6379', 10);
|
||||||
|
const password = process.env.REDIS_PASSWORD || undefined;
|
||||||
|
|
||||||
|
this.redis = new Redis({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
password,
|
||||||
|
keyPrefix: 'auth:sms:',
|
||||||
|
retryStrategy: (times) => Math.min(times * 50, 2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.redis.on('connect', () => this.logger.log('Redis connected for SMS code service'));
|
||||||
|
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
if (this.redis) {
|
||||||
|
await this.redis.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate and store a 6-digit code for the given phone number.
|
||||||
|
* TTL is 5 minutes (300 seconds).
|
||||||
|
*/
|
||||||
|
async generateCode(phone: string): Promise<string> {
|
||||||
|
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||||
|
await this.redis.set(phone, code, 'EX', 300);
|
||||||
|
// In dev mode, log the code instead of sending a real SMS
|
||||||
|
this.logger.log(`[DEV] SMS code for ${phone}: ${code}`);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the code for the given phone number.
|
||||||
|
* Returns true if valid, false otherwise.
|
||||||
|
* On successful verification, the code is deleted to prevent reuse.
|
||||||
|
*/
|
||||||
|
async verifyCode(phone: string, code: string): Promise<boolean> {
|
||||||
|
const stored = await this.redis.get(phone);
|
||||||
|
if (!stored || stored !== code) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Delete the code after successful verification
|
||||||
|
await this.redis.del(phone);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,8 @@ import { RegisterDto } from '../dto/register.dto';
|
||||||
import { LoginDto } from '../dto/login.dto';
|
import { LoginDto } from '../dto/login.dto';
|
||||||
import { RefreshTokenDto } from '../dto/refresh-token.dto';
|
import { RefreshTokenDto } from '../dto/refresh-token.dto';
|
||||||
import { ChangePasswordDto } from '../dto/change-password.dto';
|
import { ChangePasswordDto } from '../dto/change-password.dto';
|
||||||
|
import { SendSmsCodeDto } from '../dto/send-sms-code.dto';
|
||||||
|
import { LoginPhoneDto } from '../dto/login-phone.dto';
|
||||||
|
|
||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
|
|
@ -92,4 +94,32 @@ export class AuthController {
|
||||||
message: 'Password changed successfully',
|
message: 'Password changed successfully',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('send-sms-code')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Send SMS verification code to phone number' })
|
||||||
|
@ApiResponse({ status: 200, description: 'SMS code sent successfully' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid phone number' })
|
||||||
|
async sendSmsCode(@Body() dto: SendSmsCodeDto) {
|
||||||
|
await this.authService.sendSmsCode(dto.phone);
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
data: { success: true },
|
||||||
|
message: 'SMS code sent successfully',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login-phone')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Login with phone number and SMS verification code' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Login successful' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Invalid or expired SMS code' })
|
||||||
|
async loginWithPhone(@Body() dto: LoginPhoneDto, @Ip() ip: string) {
|
||||||
|
const result = await this.authService.loginWithPhone(dto.phone, dto.smsCode, ip);
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
data: result,
|
||||||
|
message: 'Login successful',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { IsString, IsNotEmpty, Length } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class LoginPhoneDto {
|
||||||
|
@ApiProperty({ description: 'Phone number', example: '+8613800138000' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '6-digit SMS verification code', example: '123456' })
|
||||||
|
@IsString()
|
||||||
|
@Length(6, 6)
|
||||||
|
smsCode: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { IsString, IsNotEmpty } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class SendSmsCodeDto {
|
||||||
|
@ApiProperty({ description: 'Phone number to send SMS code to', example: '+8613800138000' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
@ -125,16 +125,17 @@ export class AdminCouponAnalyticsService {
|
||||||
|
|
||||||
const totalSold = await this.couponRepo.getTotalSold();
|
const totalSold = await this.couponRepo.getTotalSold();
|
||||||
const totalRedeemed = await this.couponRepo.getTotalSoldByStatuses([
|
const totalRedeemed = await this.couponRepo.getTotalSoldByStatuses([
|
||||||
CouponStatus.EXPIRED,
|
CouponStatus.REDEEMED,
|
||||||
CouponStatus.SOLD_OUT,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
draft: countMap[CouponStatus.DRAFT] || 0,
|
minted: countMap[CouponStatus.MINTED] || 0,
|
||||||
active: countMap[CouponStatus.ACTIVE] || 0,
|
listed: countMap[CouponStatus.LISTED] || 0,
|
||||||
paused: countMap[CouponStatus.PAUSED] || 0,
|
sold: countMap[CouponStatus.SOLD] || 0,
|
||||||
soldOut: countMap[CouponStatus.SOLD_OUT] || 0,
|
inCirculation: countMap[CouponStatus.IN_CIRCULATION] || 0,
|
||||||
|
redeemed: countMap[CouponStatus.REDEEMED] || 0,
|
||||||
expired: countMap[CouponStatus.EXPIRED] || 0,
|
expired: countMap[CouponStatus.EXPIRED] || 0,
|
||||||
|
recalled: countMap[CouponStatus.RECALLED] || 0,
|
||||||
totalSold,
|
totalSold,
|
||||||
totalRedeemed,
|
totalRedeemed,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -111,13 +111,13 @@ export class AdminCouponService {
|
||||||
const coupon = await this.couponRepo.findById(id);
|
const coupon = await this.couponRepo.findById(id);
|
||||||
if (!coupon) throw new NotFoundException('Coupon not found');
|
if (!coupon) throw new NotFoundException('Coupon not found');
|
||||||
|
|
||||||
if (coupon.status !== CouponStatus.DRAFT) {
|
if (coupon.status !== CouponStatus.MINTED) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Cannot approve coupon with status "${coupon.status}". Only draft coupons can be approved.`,
|
`Cannot approve coupon with status "${coupon.status}". Only minted coupons can be approved.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
coupon.status = CouponStatus.ACTIVE;
|
coupon.status = CouponStatus.LISTED;
|
||||||
return this.couponRepo.save(coupon);
|
return this.couponRepo.save(coupon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,20 +128,13 @@ export class AdminCouponService {
|
||||||
const coupon = await this.couponRepo.findById(id);
|
const coupon = await this.couponRepo.findById(id);
|
||||||
if (!coupon) throw new NotFoundException('Coupon not found');
|
if (!coupon) throw new NotFoundException('Coupon not found');
|
||||||
|
|
||||||
if (coupon.status !== CouponStatus.DRAFT) {
|
if (coupon.status !== CouponStatus.MINTED) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Cannot reject coupon with status "${coupon.status}". Only draft coupons can be rejected.`,
|
`Cannot reject coupon with status "${coupon.status}". Only minted coupons can be rejected.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
coupon.terms = {
|
coupon.status = CouponStatus.RECALLED;
|
||||||
...(coupon.terms || {}),
|
|
||||||
_rejection: {
|
|
||||||
reason,
|
|
||||||
rejectedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
coupon.status = CouponStatus.EXPIRED;
|
|
||||||
return this.couponRepo.save(coupon);
|
return this.couponRepo.save(coupon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,13 +145,13 @@ export class AdminCouponService {
|
||||||
const coupon = await this.couponRepo.findById(id);
|
const coupon = await this.couponRepo.findById(id);
|
||||||
if (!coupon) throw new NotFoundException('Coupon not found');
|
if (!coupon) throw new NotFoundException('Coupon not found');
|
||||||
|
|
||||||
if (coupon.status !== CouponStatus.ACTIVE) {
|
if (coupon.status !== CouponStatus.LISTED) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Cannot suspend coupon with status "${coupon.status}". Only active coupons can be suspended.`,
|
`Cannot suspend coupon with status "${coupon.status}". Only listed coupons can be suspended.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
coupon.status = CouponStatus.PAUSED;
|
coupon.status = CouponStatus.RECALLED;
|
||||||
return this.couponRepo.save(coupon);
|
return this.couponRepo.save(coupon);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { COUPON_REPOSITORY, ICouponRepository } from '../../domain/repositories/coupon.repository.interface';
|
||||||
|
import { Coupon, CouponStatus } from '../../domain/entities/coupon.entity';
|
||||||
|
|
||||||
|
export interface BatchOperation {
|
||||||
|
id: string;
|
||||||
|
type: 'issue' | 'recall' | 'price_adjust';
|
||||||
|
issuerId: string;
|
||||||
|
affectedCount: number;
|
||||||
|
details: Record<string, any>;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory operation log (would be a DB table in production)
|
||||||
|
const operationHistory: BatchOperation[] = [];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CouponBatchService {
|
||||||
|
constructor(
|
||||||
|
@Inject(COUPON_REPOSITORY)
|
||||||
|
private readonly couponRepo: ICouponRepository,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch issue coupons from a template coupon.
|
||||||
|
*/
|
||||||
|
async batchIssue(
|
||||||
|
issuerId: string,
|
||||||
|
templateId: string,
|
||||||
|
quantity: number,
|
||||||
|
expiryDate?: string,
|
||||||
|
) {
|
||||||
|
const template = await this.couponRepo.findById(templateId);
|
||||||
|
if (!template) throw new NotFoundException('Template coupon not found');
|
||||||
|
if (template.issuerId !== issuerId) {
|
||||||
|
throw new BadRequestException('Template does not belong to this issuer');
|
||||||
|
}
|
||||||
|
|
||||||
|
const coupons: Coupon[] = [];
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
for (let i = 0; i < quantity; i++) {
|
||||||
|
const coupon = manager.create(Coupon, {
|
||||||
|
issuerId,
|
||||||
|
name: template.name,
|
||||||
|
description: template.description,
|
||||||
|
imageUrl: template.imageUrl,
|
||||||
|
faceValue: template.faceValue,
|
||||||
|
currentPrice: template.currentPrice,
|
||||||
|
issuePrice: template.issuePrice,
|
||||||
|
totalSupply: 1,
|
||||||
|
remainingSupply: 1,
|
||||||
|
expiryDate: expiryDate ? new Date(expiryDate) : template.expiryDate,
|
||||||
|
couponType: template.couponType,
|
||||||
|
category: template.category,
|
||||||
|
status: CouponStatus.MINTED,
|
||||||
|
isTransferable: template.isTransferable,
|
||||||
|
maxResaleCount: template.maxResaleCount,
|
||||||
|
});
|
||||||
|
const saved = await manager.save(coupon);
|
||||||
|
coupons.push(saved);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const operation: BatchOperation = {
|
||||||
|
id: require('crypto').randomUUID(),
|
||||||
|
type: 'issue',
|
||||||
|
issuerId,
|
||||||
|
affectedCount: coupons.length,
|
||||||
|
details: { templateId, quantity, expiryDate },
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
operationHistory.unshift(operation);
|
||||||
|
|
||||||
|
return {
|
||||||
|
operationId: operation.id,
|
||||||
|
issued: coupons.length,
|
||||||
|
couponIds: coupons.map((c) => c.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch recall coupons.
|
||||||
|
*/
|
||||||
|
async batchRecall(issuerId: string, couponIds: string[], reason: string) {
|
||||||
|
const results: Array<{ couponId: string; success: boolean; error?: string }> = [];
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
for (const couponId of couponIds) {
|
||||||
|
try {
|
||||||
|
const coupon = await manager.findOne(Coupon, {
|
||||||
|
where: { id: couponId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!coupon) {
|
||||||
|
results.push({ couponId, success: false, error: 'Coupon not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (coupon.issuerId !== issuerId) {
|
||||||
|
results.push({ couponId, success: false, error: 'Not owned by this issuer' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (coupon.status === CouponStatus.REDEEMED) {
|
||||||
|
results.push({ couponId, success: false, error: 'Already redeemed' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
coupon.status = CouponStatus.RECALLED;
|
||||||
|
await manager.save(coupon);
|
||||||
|
results.push({ couponId, success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
results.push({ couponId, success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const successCount = results.filter((r) => r.success).length;
|
||||||
|
const operation: BatchOperation = {
|
||||||
|
id: require('crypto').randomUUID(),
|
||||||
|
type: 'recall',
|
||||||
|
issuerId,
|
||||||
|
affectedCount: successCount,
|
||||||
|
details: { couponIds, reason, results },
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
operationHistory.unshift(operation);
|
||||||
|
|
||||||
|
return {
|
||||||
|
operationId: operation.id,
|
||||||
|
results,
|
||||||
|
successCount,
|
||||||
|
failCount: results.length - successCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch price adjustment.
|
||||||
|
*/
|
||||||
|
async batchPriceAdjust(issuerId: string, couponIds: string[], adjustPercent: number) {
|
||||||
|
const results: Array<{ couponId: string; success: boolean; oldPrice?: string; newPrice?: string; error?: string }> = [];
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
for (const couponId of couponIds) {
|
||||||
|
try {
|
||||||
|
const coupon = await manager.findOne(Coupon, {
|
||||||
|
where: { id: couponId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!coupon) {
|
||||||
|
results.push({ couponId, success: false, error: 'Coupon not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (coupon.issuerId !== issuerId) {
|
||||||
|
results.push({ couponId, success: false, error: 'Not owned by this issuer' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldPrice = coupon.currentPrice || coupon.faceValue;
|
||||||
|
const multiplier = 1 + adjustPercent / 100;
|
||||||
|
const newPrice = Math.round(Number(oldPrice) * multiplier * 100) / 100;
|
||||||
|
|
||||||
|
if (newPrice < 0) {
|
||||||
|
results.push({ couponId, success: false, error: 'Adjusted price would be negative' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
coupon.currentPrice = String(newPrice);
|
||||||
|
await manager.save(coupon);
|
||||||
|
results.push({ couponId, success: true, oldPrice: String(oldPrice), newPrice: String(newPrice) });
|
||||||
|
} catch (err: any) {
|
||||||
|
results.push({ couponId, success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const successCount = results.filter((r) => r.success).length;
|
||||||
|
const operation: BatchOperation = {
|
||||||
|
id: require('crypto').randomUUID(),
|
||||||
|
type: 'price_adjust',
|
||||||
|
issuerId,
|
||||||
|
affectedCount: successCount,
|
||||||
|
details: { couponIds, adjustPercent, results },
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
operationHistory.unshift(operation);
|
||||||
|
|
||||||
|
return {
|
||||||
|
operationId: operation.id,
|
||||||
|
results,
|
||||||
|
successCount,
|
||||||
|
failCount: results.length - successCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List past batch operations.
|
||||||
|
*/
|
||||||
|
async listOperations(issuerId: string, page: number, limit: number) {
|
||||||
|
const filtered = operationHistory.filter((op) => op.issuerId === issuerId);
|
||||||
|
const total = filtered.length;
|
||||||
|
const start = (page - 1) * limit;
|
||||||
|
const items = filtered.slice(start, start + limit);
|
||||||
|
return { items, total, page, limit };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { COUPON_REPOSITORY, ICouponRepository } from '../../domain/repositories/coupon.repository.interface';
|
import { COUPON_REPOSITORY, ICouponRepository } from '../../domain/repositories/coupon.repository.interface';
|
||||||
import { COUPON_RULE_REPOSITORY, ICouponRuleRepository } from '../../domain/repositories/coupon-rule.repository.interface';
|
import { COUPON_RULE_REPOSITORY, ICouponRuleRepository } from '../../domain/repositories/coupon-rule.repository.interface';
|
||||||
|
import { STORE_REPOSITORY, IStoreRepository } from '../../domain/repositories/store.repository.interface';
|
||||||
import { Coupon, CouponStatus } from '../../domain/entities/coupon.entity';
|
import { Coupon, CouponStatus } from '../../domain/entities/coupon.entity';
|
||||||
|
import { Store } from '../../domain/entities/store.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CouponService {
|
export class CouponService {
|
||||||
|
|
@ -10,13 +13,16 @@ export class CouponService {
|
||||||
private readonly couponRepo: ICouponRepository,
|
private readonly couponRepo: ICouponRepository,
|
||||||
@Inject(COUPON_RULE_REPOSITORY)
|
@Inject(COUPON_RULE_REPOSITORY)
|
||||||
private readonly ruleRepo: ICouponRuleRepository,
|
private readonly ruleRepo: ICouponRuleRepository,
|
||||||
|
@Inject(STORE_REPOSITORY)
|
||||||
|
private readonly storeRepo: IStoreRepository,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(issuerId: string, data: Partial<Coupon> & { rules?: any[] }) {
|
async create(issuerId: string, data: Partial<Coupon> & { rules?: any[] }) {
|
||||||
const saved = await this.couponRepo.create({
|
const saved = await this.couponRepo.create({
|
||||||
...data,
|
...data,
|
||||||
issuerId,
|
issuerId,
|
||||||
status: CouponStatus.DRAFT,
|
status: CouponStatus.MINTED,
|
||||||
remainingSupply: data.totalSupply || 0,
|
remainingSupply: data.totalSupply || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -49,7 +55,7 @@ export class CouponService {
|
||||||
issuerId?: string;
|
issuerId?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const [items, total] = await this.couponRepo.findAndCount({
|
const [items, total] = await this.couponRepo.findAndCountWithIssuerJoin({
|
||||||
category: filters?.category,
|
category: filters?.category,
|
||||||
status: filters?.status,
|
status: filters?.status,
|
||||||
search: filters?.search,
|
search: filters?.search,
|
||||||
|
|
@ -60,11 +66,108 @@ export class CouponService {
|
||||||
return { items, total, page, limit };
|
return { items, total, page, limit };
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateStatus(id: string, status: CouponStatus) {
|
async getByOwner(userId: string, page: number, limit: number, status?: string) {
|
||||||
|
const [items, total] = await this.couponRepo.findByOwnerWithIssuerJoin(userId, {
|
||||||
|
status,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
return { items, total, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOwnerSummary(userId: string) {
|
||||||
|
return this.couponRepo.getOwnerSummary(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(id: string, status: string) {
|
||||||
return this.couponRepo.updateStatus(id, status);
|
return this.couponRepo.updateStatus(id, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
async purchase(couponId: string, quantity: number = 1) {
|
async purchase(couponId: string, quantity: number = 1) {
|
||||||
return this.couponRepo.purchaseWithLock(couponId, quantity);
|
return this.couponRepo.purchaseWithLock(couponId, quantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search coupons with keyword, category, and sort.
|
||||||
|
*/
|
||||||
|
async search(
|
||||||
|
q?: string,
|
||||||
|
category?: string,
|
||||||
|
sort: string = 'newest',
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 20,
|
||||||
|
) {
|
||||||
|
const qb = this.dataSource
|
||||||
|
.getRepository(Coupon)
|
||||||
|
.createQueryBuilder('c');
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
qb.andWhere('(c.name ILIKE :q OR c.description ILIKE :q)', { q: `%${q}%` });
|
||||||
|
}
|
||||||
|
if (category) {
|
||||||
|
qb.andWhere('c.category = :category', { category });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: only show listed coupons in search
|
||||||
|
qb.andWhere('c.status = :status', { status: CouponStatus.LISTED });
|
||||||
|
|
||||||
|
switch (sort) {
|
||||||
|
case 'price_asc':
|
||||||
|
qb.orderBy('CAST(c.current_price AS numeric)', 'ASC');
|
||||||
|
break;
|
||||||
|
case 'price_desc':
|
||||||
|
qb.orderBy('CAST(c.current_price AS numeric)', 'DESC');
|
||||||
|
break;
|
||||||
|
case 'popular':
|
||||||
|
qb.orderBy('c.total_supply - c.remaining_supply', 'DESC');
|
||||||
|
break;
|
||||||
|
case 'newest':
|
||||||
|
default:
|
||||||
|
qb.orderBy('c.created_at', 'DESC');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
qb.skip((page - 1) * limit).take(limit);
|
||||||
|
const [items, total] = await qb.getManyAndCount();
|
||||||
|
return { items, total, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find stores that accept a coupon (by the coupon's issuer).
|
||||||
|
*/
|
||||||
|
async getNearbyStores(couponId: string): Promise<Store[]> {
|
||||||
|
const coupon = await this.couponRepo.findById(couponId);
|
||||||
|
if (!coupon) throw new NotFoundException('Coupon not found');
|
||||||
|
|
||||||
|
return this.storeRepo.findByIssuerId(coupon.issuerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find similar coupons: same category, different issuer.
|
||||||
|
*/
|
||||||
|
async findSimilar(couponId: string, limit: number = 10): Promise<Coupon[]> {
|
||||||
|
const coupon = await this.couponRepo.findById(couponId);
|
||||||
|
if (!coupon) throw new NotFoundException('Coupon not found');
|
||||||
|
|
||||||
|
const qb = this.dataSource
|
||||||
|
.getRepository(Coupon)
|
||||||
|
.createQueryBuilder('c')
|
||||||
|
.where('c.id != :id', { id: couponId })
|
||||||
|
.andWhere('c.status = :status', { status: CouponStatus.LISTED });
|
||||||
|
|
||||||
|
if (coupon.category) {
|
||||||
|
qb.andWhere('c.category = :category', { category: coupon.category });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer different issuer
|
||||||
|
qb.orderBy(
|
||||||
|
'CASE WHEN c.issuer_id != :issuerId THEN 0 ELSE 1 END',
|
||||||
|
'ASC',
|
||||||
|
);
|
||||||
|
qb.setParameter('issuerId', coupon.issuerId);
|
||||||
|
qb.addOrderBy('c.created_at', 'DESC');
|
||||||
|
qb.take(limit);
|
||||||
|
|
||||||
|
return qb.getMany();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { ISSUER_REPOSITORY, IIssuerRepository } from '../../domain/repositories/issuer.repository.interface';
|
||||||
|
import { COUPON_REPOSITORY, ICouponRepository } from '../../domain/repositories/coupon.repository.interface';
|
||||||
|
import { Coupon, CouponStatus } from '../../domain/entities/coupon.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IssuerFinanceService {
|
||||||
|
constructor(
|
||||||
|
@Inject(ISSUER_REPOSITORY)
|
||||||
|
private readonly issuerRepo: IIssuerRepository,
|
||||||
|
@Inject(COUPON_REPOSITORY)
|
||||||
|
private readonly couponRepo: ICouponRepository,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getBalance(issuerId: string) {
|
||||||
|
const issuer = await this.issuerRepo.findById(issuerId);
|
||||||
|
if (!issuer) throw new NotFoundException('Issuer not found');
|
||||||
|
|
||||||
|
// Aggregate from coupons
|
||||||
|
const result = await this.dataSource
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select([
|
||||||
|
`COALESCE(SUM(CASE WHEN c.status IN ('sold', 'redeemed') THEN CAST(c.current_price AS numeric) ELSE 0 END), 0) as "total"`,
|
||||||
|
`COALESCE(SUM(CASE WHEN c.status = 'redeemed' THEN CAST(c.current_price AS numeric) ELSE 0 END), 0) as "withdrawable"`,
|
||||||
|
`COALESCE(SUM(CASE WHEN c.status = 'sold' THEN CAST(c.current_price AS numeric) ELSE 0 END), 0) as "pending"`,
|
||||||
|
])
|
||||||
|
.from(Coupon, 'c')
|
||||||
|
.where('c.issuer_id = :issuerId', { issuerId })
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
return {
|
||||||
|
withdrawable: Math.round(Number(result.withdrawable) * 100) / 100,
|
||||||
|
pending: Math.round(Number(result.pending) * 100) / 100,
|
||||||
|
total: Math.round(Number(result.total) * 100) / 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFinanceStats(issuerId: string) {
|
||||||
|
const issuer = await this.issuerRepo.findById(issuerId);
|
||||||
|
if (!issuer) throw new NotFoundException('Issuer not found');
|
||||||
|
const feeRate = Number(issuer.feeRate) || 0.015;
|
||||||
|
|
||||||
|
const result = await this.dataSource
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select([
|
||||||
|
`COALESCE(SUM(CASE WHEN c.status IN ('sold', 'redeemed') THEN CAST(c.current_price AS numeric) ELSE 0 END), 0) as "salesAmount"`,
|
||||||
|
`COALESCE(SUM(CASE WHEN c.status = 'expired' THEN CAST(c.current_price AS numeric) ELSE 0 END), 0) as "breakageIncome"`,
|
||||||
|
`COALESCE(SUM(CASE WHEN c.status = 'sold' THEN CAST(c.current_price AS numeric) ELSE 0 END), 0) as "pendingSettlement"`,
|
||||||
|
])
|
||||||
|
.from(Coupon, 'c')
|
||||||
|
.where('c.issuer_id = :issuerId', { issuerId })
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
const salesAmount = Math.round(Number(result.salesAmount) * 100) / 100;
|
||||||
|
const breakageIncome = Math.round(Number(result.breakageIncome) * 100) / 100;
|
||||||
|
const platformFee = Math.round(salesAmount * feeRate * 100) / 100;
|
||||||
|
const pendingSettlement = Math.round(Number(result.pendingSettlement) * 100) / 100;
|
||||||
|
const totalRevenue = Math.round((salesAmount + breakageIncome - platformFee) * 100) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
salesAmount,
|
||||||
|
breakageIncome,
|
||||||
|
platformFee,
|
||||||
|
pendingSettlement,
|
||||||
|
withdrawn: 0, // TODO: track actual withdrawals
|
||||||
|
totalRevenue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTransactions(
|
||||||
|
issuerId: string,
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
type?: string,
|
||||||
|
) {
|
||||||
|
const issuer = await this.issuerRepo.findById(issuerId);
|
||||||
|
if (!issuer) throw new NotFoundException('Issuer not found');
|
||||||
|
|
||||||
|
// Build transaction-like view from coupons
|
||||||
|
const qb = this.dataSource
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select([
|
||||||
|
'c.id as "id"',
|
||||||
|
'c.name as "couponName"',
|
||||||
|
'c.status as "type"',
|
||||||
|
'c.current_price as "amount"',
|
||||||
|
'c.updated_at as "date"',
|
||||||
|
])
|
||||||
|
.from(Coupon, 'c')
|
||||||
|
.where('c.issuer_id = :issuerId', { issuerId })
|
||||||
|
.andWhere("c.status IN ('sold', 'redeemed', 'expired', 'recalled')");
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
sale: 'sold',
|
||||||
|
redemption: 'redeemed',
|
||||||
|
};
|
||||||
|
const mappedStatus = statusMap[type] || type;
|
||||||
|
qb.andWhere('c.status = :mappedStatus', { mappedStatus });
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = await qb.getCount();
|
||||||
|
|
||||||
|
const items = await qb
|
||||||
|
.orderBy('c.updated_at', 'DESC')
|
||||||
|
.offset((page - 1) * limit)
|
||||||
|
.limit(limit)
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
return { items, total, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestWithdrawal(issuerId: string, amount: number) {
|
||||||
|
const balance = await this.getBalance(issuerId);
|
||||||
|
if (amount > balance.withdrawable) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Insufficient withdrawable balance. Available: ${balance.withdrawable}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a real system, this would create a withdrawal record and trigger a payout
|
||||||
|
// For now, return a placeholder
|
||||||
|
return {
|
||||||
|
id: require('crypto').randomUUID(),
|
||||||
|
issuerId,
|
||||||
|
amount,
|
||||||
|
status: 'pending',
|
||||||
|
requestedAt: new Date(),
|
||||||
|
estimatedArrival: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), // T+3
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReconciliation(
|
||||||
|
issuerId: string,
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string,
|
||||||
|
) {
|
||||||
|
const issuer = await this.issuerRepo.findById(issuerId);
|
||||||
|
if (!issuer) throw new NotFoundException('Issuer not found');
|
||||||
|
|
||||||
|
const qb = this.dataSource
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select([
|
||||||
|
"TO_CHAR(c.updated_at, 'YYYY-MM-DD') as \"date\"",
|
||||||
|
`SUM(CASE WHEN c.status = 'sold' THEN CAST(c.current_price AS numeric) ELSE 0 END) as "sales"`,
|
||||||
|
`SUM(CASE WHEN c.status = 'redeemed' THEN CAST(c.current_price AS numeric) ELSE 0 END) as "redemptions"`,
|
||||||
|
`SUM(CASE WHEN c.status = 'expired' THEN CAST(c.current_price AS numeric) ELSE 0 END) as "breakage"`,
|
||||||
|
'COUNT(c.id) as "transactionCount"',
|
||||||
|
])
|
||||||
|
.from(Coupon, 'c')
|
||||||
|
.where('c.issuer_id = :issuerId', { issuerId })
|
||||||
|
.andWhere("c.status IN ('sold', 'redeemed', 'expired')");
|
||||||
|
|
||||||
|
if (startDate) qb.andWhere('c.updated_at >= :startDate', { startDate });
|
||||||
|
if (endDate) qb.andWhere('c.updated_at <= :endDate', { endDate });
|
||||||
|
|
||||||
|
qb.groupBy("TO_CHAR(c.updated_at, 'YYYY-MM-DD')")
|
||||||
|
.orderBy('"date"', 'DESC');
|
||||||
|
|
||||||
|
const rows = await qb.getRawMany();
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
date: row.date,
|
||||||
|
sales: Math.round(Number(row.sales) * 100) / 100,
|
||||||
|
redemptions: Math.round(Number(row.redemptions) * 100) / 100,
|
||||||
|
breakage: Math.round(Number(row.breakage) * 100) / 100,
|
||||||
|
transactionCount: Number(row.transactionCount),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||||
|
import { ISSUER_REPOSITORY, IIssuerRepository } from '../../domain/repositories/issuer.repository.interface';
|
||||||
|
import { COUPON_REPOSITORY, ICouponRepository } from '../../domain/repositories/coupon.repository.interface';
|
||||||
|
import { CREDIT_METRIC_REPOSITORY, ICreditMetricRepository } from '../../domain/repositories/credit-metric.repository.interface';
|
||||||
|
import { CouponStatus } from '../../domain/entities/coupon.entity';
|
||||||
|
import { CreditScoringService } from './credit-scoring.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IssuerStatsService {
|
||||||
|
constructor(
|
||||||
|
@Inject(ISSUER_REPOSITORY)
|
||||||
|
private readonly issuerRepo: IIssuerRepository,
|
||||||
|
@Inject(COUPON_REPOSITORY)
|
||||||
|
private readonly couponRepo: ICouponRepository,
|
||||||
|
@Inject(CREDIT_METRIC_REPOSITORY)
|
||||||
|
private readonly creditMetricRepo: ICreditMetricRepository,
|
||||||
|
private readonly creditScoringService: CreditScoringService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getDashboardStats(issuerId: string) {
|
||||||
|
const issuer = await this.issuerRepo.findById(issuerId);
|
||||||
|
if (!issuer) throw new NotFoundException('Issuer not found');
|
||||||
|
|
||||||
|
// Aggregate coupon stats for this issuer
|
||||||
|
const [coupons, total] = await this.couponRepo.findAndCount({
|
||||||
|
issuerId,
|
||||||
|
page: 1,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const issuedCount = total;
|
||||||
|
|
||||||
|
// Count redeemed coupons
|
||||||
|
const [, redeemedCount] = await this.couponRepo.findAndCount({
|
||||||
|
issuerId,
|
||||||
|
status: CouponStatus.REDEEMED,
|
||||||
|
page: 1,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const redemptionRate = issuedCount > 0
|
||||||
|
? Math.round((redeemedCount / issuedCount) * 10000) / 10000
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Get total revenue: sum of current_price for sold/redeemed coupons
|
||||||
|
const [soldCoupons] = await this.couponRepo.findAndCount({
|
||||||
|
issuerId,
|
||||||
|
status: CouponStatus.SOLD,
|
||||||
|
page: 1,
|
||||||
|
limit: 100000,
|
||||||
|
});
|
||||||
|
const [redeemedCoupons] = await this.couponRepo.findAndCount({
|
||||||
|
issuerId,
|
||||||
|
status: CouponStatus.REDEEMED,
|
||||||
|
page: 1,
|
||||||
|
limit: 100000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allRevenueCoupons = [...soldCoupons, ...redeemedCoupons];
|
||||||
|
const totalRevenue = allRevenueCoupons.reduce(
|
||||||
|
(sum, c) => sum + (Number(c.currentPrice) || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const creditScore = Number(issuer.creditScore) || 50;
|
||||||
|
|
||||||
|
return {
|
||||||
|
issuedCount,
|
||||||
|
redemptionRate,
|
||||||
|
totalRevenue: Math.round(totalRevenue * 100) / 100,
|
||||||
|
creditScore,
|
||||||
|
quotaUsed: issuedCount,
|
||||||
|
quotaTotal: 10000, // configurable limit
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCreditDetails(issuerId: string) {
|
||||||
|
const issuer = await this.issuerRepo.findById(issuerId);
|
||||||
|
if (!issuer) throw new NotFoundException('Issuer not found');
|
||||||
|
|
||||||
|
const latestMetric = await this.creditMetricRepo.findLatestByIssuerId(issuerId);
|
||||||
|
const numericScore = Number(issuer.creditScore) || 50;
|
||||||
|
|
||||||
|
// Derive grade from score
|
||||||
|
const grade = numericScore >= 90 ? 'AA' :
|
||||||
|
numericScore >= 80 ? 'A+' :
|
||||||
|
numericScore >= 70 ? 'A' :
|
||||||
|
numericScore >= 60 ? 'B+' :
|
||||||
|
numericScore >= 50 ? 'B' :
|
||||||
|
numericScore >= 40 ? 'C+' :
|
||||||
|
numericScore >= 30 ? 'C' : 'D';
|
||||||
|
|
||||||
|
const tier = numericScore >= 80 ? 'premium' :
|
||||||
|
numericScore >= 60 ? 'standard' :
|
||||||
|
numericScore >= 40 ? 'basic' : 'probation';
|
||||||
|
|
||||||
|
const factors = latestMetric ? {
|
||||||
|
redemptionRate: { weight: 0.35, score: Number(latestMetric.redemptionRate) * 100 },
|
||||||
|
breakageRate: { weight: 0.25, score: (1 - Number(latestMetric.breakageRate)) * 100 },
|
||||||
|
tenure: { weight: 0.20, score: Math.min(100, (latestMetric.tenureDays / 365) * 100) },
|
||||||
|
satisfaction: { weight: 0.20, score: Number(latestMetric.satisfactionScore) },
|
||||||
|
} : {
|
||||||
|
redemptionRate: { weight: 0.35, score: 0 },
|
||||||
|
breakageRate: { weight: 0.25, score: 0 },
|
||||||
|
tenure: { weight: 0.20, score: 0 },
|
||||||
|
satisfaction: { weight: 0.20, score: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestions: string[] = [];
|
||||||
|
if (factors.redemptionRate.score < 50) suggestions.push('Improve coupon redemption rates by promoting usage');
|
||||||
|
if (factors.satisfaction.score < 50) suggestions.push('Enhance customer satisfaction through better service');
|
||||||
|
if (factors.tenure.score < 50) suggestions.push('Maintain consistent platform activity to build tenure');
|
||||||
|
|
||||||
|
return {
|
||||||
|
grade,
|
||||||
|
numericScore,
|
||||||
|
factors,
|
||||||
|
tier,
|
||||||
|
suggestions,
|
||||||
|
lastCalculatedAt: latestMetric?.calculatedAt || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { STORE_REPOSITORY, IStoreRepository } from '../../domain/repositories/store.repository.interface';
|
||||||
|
import { EMPLOYEE_REPOSITORY, IEmployeeRepository } from '../../domain/repositories/employee.repository.interface';
|
||||||
|
import { Store } from '../../domain/entities/store.entity';
|
||||||
|
import { Employee } from '../../domain/entities/employee.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IssuerStoreService {
|
||||||
|
constructor(
|
||||||
|
@Inject(STORE_REPOSITORY)
|
||||||
|
private readonly storeRepo: IStoreRepository,
|
||||||
|
@Inject(EMPLOYEE_REPOSITORY)
|
||||||
|
private readonly employeeRepo: IEmployeeRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ---- Stores ----
|
||||||
|
|
||||||
|
async listStores(
|
||||||
|
issuerId: string,
|
||||||
|
filters?: { level?: string; parentId?: string; status?: string },
|
||||||
|
): Promise<Store[]> {
|
||||||
|
return this.storeRepo.findByIssuerId(issuerId, filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createStore(issuerId: string, data: Partial<Store>): Promise<Store> {
|
||||||
|
return this.storeRepo.create({
|
||||||
|
...data,
|
||||||
|
issuerId,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStore(issuerId: string, storeId: string, data: Partial<Store>): Promise<Store> {
|
||||||
|
const store = await this.storeRepo.findById(storeId);
|
||||||
|
if (!store) throw new NotFoundException('Store not found');
|
||||||
|
if (store.issuerId !== issuerId) throw new ForbiddenException('Store does not belong to this issuer');
|
||||||
|
|
||||||
|
Object.assign(store, data);
|
||||||
|
return this.storeRepo.save(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteStore(issuerId: string, storeId: string): Promise<void> {
|
||||||
|
const store = await this.storeRepo.findById(storeId);
|
||||||
|
if (!store) throw new NotFoundException('Store not found');
|
||||||
|
if (store.issuerId !== issuerId) throw new ForbiddenException('Store does not belong to this issuer');
|
||||||
|
|
||||||
|
await this.storeRepo.delete(storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Employees ----
|
||||||
|
|
||||||
|
async listEmployees(
|
||||||
|
issuerId: string,
|
||||||
|
filters?: { storeId?: string; role?: string },
|
||||||
|
): Promise<Employee[]> {
|
||||||
|
return this.employeeRepo.findByIssuerId(issuerId, filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEmployee(issuerId: string, data: Partial<Employee>): Promise<Employee> {
|
||||||
|
return this.employeeRepo.create({
|
||||||
|
...data,
|
||||||
|
issuerId,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEmployee(
|
||||||
|
issuerId: string,
|
||||||
|
employeeId: string,
|
||||||
|
data: Partial<Employee>,
|
||||||
|
): Promise<Employee> {
|
||||||
|
const employee = await this.employeeRepo.findById(employeeId);
|
||||||
|
if (!employee) throw new NotFoundException('Employee not found');
|
||||||
|
if (employee.issuerId !== issuerId) throw new ForbiddenException('Employee does not belong to this issuer');
|
||||||
|
|
||||||
|
Object.assign(employee, data);
|
||||||
|
return this.employeeRepo.save(employee);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEmployee(issuerId: string, employeeId: string): Promise<void> {
|
||||||
|
const employee = await this.employeeRepo.findById(employeeId);
|
||||||
|
if (!employee) throw new NotFoundException('Employee not found');
|
||||||
|
if (employee.issuerId !== issuerId) throw new ForbiddenException('Employee does not belong to this issuer');
|
||||||
|
|
||||||
|
await this.employeeRepo.delete(employeeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { COUPON_REPOSITORY, ICouponRepository } from '../../domain/repositories/coupon.repository.interface';
|
||||||
|
import { REDEMPTION_REPOSITORY, IRedemptionRepository } from '../../domain/repositories/redemption.repository.interface';
|
||||||
|
import { Coupon, CouponStatus } from '../../domain/entities/coupon.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedemptionService {
|
||||||
|
constructor(
|
||||||
|
@Inject(COUPON_REPOSITORY)
|
||||||
|
private readonly couponRepo: ICouponRepository,
|
||||||
|
@Inject(REDEMPTION_REPOSITORY)
|
||||||
|
private readonly redemptionRepo: IRedemptionRepository,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeem via QR code scan.
|
||||||
|
* The qrCode is expected to contain the coupon ID (UUID).
|
||||||
|
*/
|
||||||
|
async scanRedeem(issuerId: string, qrCode: string, storeId?: string) {
|
||||||
|
// Parse coupon ID from QR code (assumes QR contains the coupon UUID)
|
||||||
|
const couponId = qrCode.trim();
|
||||||
|
return this.processRedemption(issuerId, couponId, 'scan', storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeem via manual coupon code entry.
|
||||||
|
* The couponCode is expected to be the coupon ID (UUID).
|
||||||
|
*/
|
||||||
|
async manualRedeem(issuerId: string, couponCode: string, storeId?: string) {
|
||||||
|
const couponId = couponCode.trim();
|
||||||
|
return this.processRedemption(issuerId, couponId, 'manual', storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch redeem multiple coupons.
|
||||||
|
*/
|
||||||
|
async batchRedeem(issuerId: string, couponCodes: string[], storeId?: string) {
|
||||||
|
const results: Array<{ couponCode: string; success: boolean; error?: string; redemption?: any }> = [];
|
||||||
|
|
||||||
|
for (const code of couponCodes) {
|
||||||
|
try {
|
||||||
|
const redemption = await this.processRedemption(issuerId, code.trim(), 'batch', storeId);
|
||||||
|
results.push({ couponCode: code, success: true, redemption });
|
||||||
|
} catch (err: any) {
|
||||||
|
results.push({ couponCode: code, success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const successCount = results.filter((r) => r.success).length;
|
||||||
|
const failCount = results.filter((r) => !r.success).length;
|
||||||
|
|
||||||
|
return { results, successCount, failCount, total: couponCodes.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core redemption logic with pessimistic lock.
|
||||||
|
*/
|
||||||
|
private async processRedemption(
|
||||||
|
issuerId: string,
|
||||||
|
couponId: string,
|
||||||
|
method: string,
|
||||||
|
storeId?: string,
|
||||||
|
) {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const coupon = await manager.findOne(Coupon, {
|
||||||
|
where: { id: couponId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!coupon) throw new NotFoundException(`Coupon not found: ${couponId}`);
|
||||||
|
if (coupon.issuerId !== issuerId) {
|
||||||
|
throw new BadRequestException('Coupon does not belong to this issuer');
|
||||||
|
}
|
||||||
|
if (coupon.status === CouponStatus.REDEEMED) {
|
||||||
|
throw new BadRequestException('Coupon already redeemed');
|
||||||
|
}
|
||||||
|
if (coupon.status === CouponStatus.EXPIRED) {
|
||||||
|
throw new BadRequestException('Coupon has expired');
|
||||||
|
}
|
||||||
|
if (coupon.status === CouponStatus.RECALLED) {
|
||||||
|
throw new BadRequestException('Coupon has been recalled');
|
||||||
|
}
|
||||||
|
if (![CouponStatus.SOLD, CouponStatus.IN_CIRCULATION].includes(coupon.status as CouponStatus)) {
|
||||||
|
throw new BadRequestException(`Coupon is not in a redeemable state (current: ${coupon.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update coupon status
|
||||||
|
coupon.status = CouponStatus.REDEEMED;
|
||||||
|
await manager.save(coupon);
|
||||||
|
|
||||||
|
// Create redemption record
|
||||||
|
const redemption = await this.redemptionRepo.create({
|
||||||
|
couponId: coupon.id,
|
||||||
|
issuerId,
|
||||||
|
userId: coupon.ownerUserId || issuerId, // use owner if available
|
||||||
|
storeId: storeId || null,
|
||||||
|
method,
|
||||||
|
amount: coupon.faceValue,
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
|
||||||
|
return redemption;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRedemptions(
|
||||||
|
issuerId: string,
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string,
|
||||||
|
) {
|
||||||
|
const [items, total] = await this.redemptionRepo.findAndCount({
|
||||||
|
issuerId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
return { items, total, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTodayStats(issuerId: string) {
|
||||||
|
return this.redemptionRepo.getTodayStats(issuerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, VersionColumn, Index } from 'typeorm';
|
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, VersionColumn, Index } from 'typeorm';
|
||||||
|
|
||||||
export enum CouponStatus { DRAFT = 'draft', ACTIVE = 'active', PAUSED = 'paused', EXPIRED = 'expired', SOLD_OUT = 'sold_out' }
|
export enum CouponStatus {
|
||||||
export enum CouponType { DISCOUNT = 'discount', VOUCHER = 'voucher', GIFT_CARD = 'gift_card', LOYALTY = 'loyalty' }
|
MINTED = 'minted',
|
||||||
|
LISTED = 'listed',
|
||||||
|
SOLD = 'sold',
|
||||||
|
IN_CIRCULATION = 'in_circulation',
|
||||||
|
REDEEMED = 'redeemed',
|
||||||
|
EXPIRED = 'expired',
|
||||||
|
RECALLED = 'recalled',
|
||||||
|
}
|
||||||
|
export enum CouponType { UTILITY = 'utility', SECURITY = 'security' }
|
||||||
|
|
||||||
@Entity('coupons')
|
@Entity('coupons')
|
||||||
@Index('idx_coupons_issuer', ['issuerId'])
|
@Index('idx_coupons_issuer', ['issuerId'])
|
||||||
|
|
@ -9,24 +17,27 @@ export enum CouponType { DISCOUNT = 'discount', VOUCHER = 'voucher', GIFT_CARD =
|
||||||
@Index('idx_coupons_category', ['category'])
|
@Index('idx_coupons_category', ['category'])
|
||||||
export class Coupon {
|
export class Coupon {
|
||||||
@PrimaryGeneratedColumn('uuid') id: string;
|
@PrimaryGeneratedColumn('uuid') id: string;
|
||||||
|
@Column({ name: 'chain_token_id', type: 'bigint', nullable: true, unique: true }) chainTokenId: string | null;
|
||||||
@Column({ name: 'issuer_id', type: 'uuid' }) issuerId: string;
|
@Column({ name: 'issuer_id', type: 'uuid' }) issuerId: string;
|
||||||
@Column({ type: 'varchar', length: 200 }) name: string;
|
@Column({ type: 'varchar', length: 200 }) name: string;
|
||||||
@Column({ type: 'text', nullable: true }) description: string | null;
|
@Column({ type: 'text', nullable: true }) description: string | null;
|
||||||
@Column({ type: 'varchar', length: 50 }) type: CouponType;
|
|
||||||
@Column({ type: 'varchar', length: 50 }) category: string;
|
|
||||||
@Column({ name: 'face_value', type: 'numeric', precision: 15, scale: 2 }) faceValue: string;
|
|
||||||
@Column({ type: 'numeric', precision: 15, scale: 2 }) price: string;
|
|
||||||
@Column({ type: 'varchar', length: 10, default: 'USD' }) currency: string;
|
|
||||||
@Column({ name: 'total_supply', type: 'int' }) totalSupply: number;
|
|
||||||
@Column({ name: 'remaining_supply', type: 'int' }) remainingSupply: number;
|
|
||||||
@Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) imageUrl: string | null;
|
@Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) imageUrl: string | null;
|
||||||
@Column({ type: 'jsonb', nullable: true }) terms: Record<string, any> | null;
|
@Column({ name: 'face_value', type: 'numeric', precision: 12, scale: 2 }) faceValue: string;
|
||||||
@Column({ type: 'varchar', length: 20, default: 'draft' }) status: CouponStatus;
|
@Column({ name: 'current_price', type: 'numeric', precision: 12, scale: 2, nullable: true }) currentPrice: string | null;
|
||||||
@Column({ name: 'valid_from', type: 'timestamptz' }) validFrom: Date;
|
@Column({ name: 'issue_price', type: 'numeric', precision: 12, scale: 2, nullable: true }) issuePrice: string | null;
|
||||||
@Column({ name: 'valid_until', type: 'timestamptz' }) validUntil: Date;
|
@Column({ name: 'total_supply', type: 'int', default: 1 }) totalSupply: number;
|
||||||
@Column({ name: 'is_tradable', type: 'boolean', default: true }) isTradable: boolean;
|
@Column({ name: 'remaining_supply', type: 'int', default: 1 }) remainingSupply: number;
|
||||||
|
@Column({ name: 'expiry_date', type: 'date' }) expiryDate: Date;
|
||||||
|
@Column({ name: 'coupon_type', type: 'varchar', length: 10, default: 'utility' }) couponType: string;
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true }) category: string;
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'minted' }) status: string;
|
||||||
|
@Column({ name: 'owner_user_id', type: 'uuid', nullable: true }) ownerUserId: string | null;
|
||||||
|
@Column({ name: 'resale_count', type: 'smallint', default: 0 }) resaleCount: number;
|
||||||
|
@Column({ name: 'max_resale_count', type: 'smallint', default: 3 }) maxResaleCount: number;
|
||||||
@Column({ name: 'is_transferable', type: 'boolean', default: true }) isTransferable: boolean;
|
@Column({ name: 'is_transferable', type: 'boolean', default: true }) isTransferable: boolean;
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date;
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date;
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date;
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date;
|
||||||
@VersionColumn({ default: 1 }) version: number;
|
|
||||||
|
// Virtual: populated via JOIN
|
||||||
|
issuer?: any;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('employees')
|
||||||
|
@Index('idx_employees_issuer', ['issuerId'])
|
||||||
|
@Index('idx_employees_store', ['storeId'])
|
||||||
|
export class Employee {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id: string;
|
||||||
|
@Column({ name: 'issuer_id', type: 'uuid' }) issuerId: string;
|
||||||
|
@Column({ name: 'store_id', type: 'uuid', nullable: true }) storeId: string | null;
|
||||||
|
@Column({ type: 'varchar', length: 100 }) name: string;
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true }) phone: string | null;
|
||||||
|
@Column({ type: 'varchar', length: 50, default: 'staff' }) role: string; // 'manager' | 'cashier' | 'staff'
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'active' }) status: string;
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date;
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, Index } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('redemptions')
|
||||||
|
@Index('idx_redemptions_coupon', ['couponId'])
|
||||||
|
@Index('idx_redemptions_issuer', ['issuerId'])
|
||||||
|
@Index('idx_redemptions_user', ['userId'])
|
||||||
|
@Index('idx_redemptions_store', ['storeId'])
|
||||||
|
export class Redemption {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id: string;
|
||||||
|
@Column({ name: 'coupon_id', type: 'uuid' }) couponId: string;
|
||||||
|
@Column({ name: 'issuer_id', type: 'uuid' }) issuerId: string;
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' }) userId: string;
|
||||||
|
@Column({ name: 'store_id', type: 'uuid', nullable: true }) storeId: string | null;
|
||||||
|
@Column({ type: 'varchar', length: 20 }) method: string; // 'scan' | 'manual' | 'batch'
|
||||||
|
@Column({ type: 'numeric', precision: 12, scale: 2 }) amount: string;
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'completed' }) status: string;
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
|
||||||
|
|
||||||
@Entity('stores')
|
@Entity('stores')
|
||||||
|
@Index('idx_stores_issuer', ['issuerId'])
|
||||||
|
@Index('idx_stores_parent', ['parentId'])
|
||||||
export class Store {
|
export class Store {
|
||||||
@PrimaryGeneratedColumn('uuid') id: string;
|
@PrimaryGeneratedColumn('uuid') id: string;
|
||||||
@Column({ name: 'issuer_id', type: 'uuid' }) issuerId: string;
|
@Column({ name: 'issuer_id', type: 'uuid' }) issuerId: string;
|
||||||
@Column({ type: 'varchar', length: 200 }) name: string;
|
@Column({ type: 'varchar', length: 200 }) name: string;
|
||||||
@Column({ type: 'text', nullable: true }) address: string | null;
|
@Column({ type: 'varchar', length: 500, nullable: true }) address: string | null;
|
||||||
@Column({ type: 'numeric', precision: 10, scale: 7, nullable: true }) latitude: string | null;
|
@Column({ type: 'numeric', precision: 10, scale: 7, nullable: true }) latitude: string | null;
|
||||||
@Column({ type: 'numeric', precision: 10, scale: 7, nullable: true }) longitude: string | null;
|
@Column({ type: 'numeric', precision: 10, scale: 7, nullable: true }) longitude: string | null;
|
||||||
@Column({ type: 'varchar', length: 20, nullable: true }) phone: string | null;
|
@Column({ type: 'varchar', length: 20, nullable: true }) phone: string | null;
|
||||||
@Column({ name: 'business_hours', type: 'varchar', length: 200, nullable: true }) businessHours: string | null;
|
@Column({ name: 'business_hours', type: 'varchar', length: 200, nullable: true }) businessHours: string | null;
|
||||||
|
@Column({ type: 'varchar', length: 50, default: 'store' }) level: string; // 'hq' | 'region' | 'store'
|
||||||
|
@Column({ name: 'parent_id', type: 'uuid', nullable: true }) parentId: string | null;
|
||||||
@Column({ type: 'varchar', length: 20, default: 'active' }) status: string;
|
@Column({ type: 'varchar', length: 20, default: 'active' }) status: string;
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date;
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date;
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date;
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date;
|
||||||
|
|
|
||||||
|
|
@ -56,13 +56,21 @@ export interface RedemptionRateRow {
|
||||||
totalSold: number;
|
totalSold: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OwnerSummary {
|
||||||
|
count: number;
|
||||||
|
totalFaceValue: number;
|
||||||
|
totalSaved: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ICouponRepository {
|
export interface ICouponRepository {
|
||||||
findById(id: string): Promise<Coupon | null>;
|
findById(id: string): Promise<Coupon | null>;
|
||||||
create(data: Partial<Coupon>): Promise<Coupon>;
|
create(data: Partial<Coupon>): Promise<Coupon>;
|
||||||
save(coupon: Coupon): Promise<Coupon>;
|
save(coupon: Coupon): Promise<Coupon>;
|
||||||
findAndCount(filters: CouponListFilters): Promise<[Coupon[], number]>;
|
findAndCount(filters: CouponListFilters): Promise<[Coupon[], number]>;
|
||||||
findAndCountWithIssuerJoin(filters: CouponListFilters): Promise<[Coupon[], number]>;
|
findAndCountWithIssuerJoin(filters: CouponListFilters): Promise<[Coupon[], number]>;
|
||||||
updateStatus(id: string, status: CouponStatus): Promise<Coupon>;
|
findByOwnerWithIssuerJoin(userId: string, filters: CouponListFilters): Promise<[Coupon[], number]>;
|
||||||
|
getOwnerSummary(userId: string): Promise<OwnerSummary>;
|
||||||
|
updateStatus(id: string, status: string): Promise<Coupon>;
|
||||||
purchaseWithLock(couponId: string, quantity: number): Promise<Coupon>;
|
purchaseWithLock(couponId: string, quantity: number): Promise<Coupon>;
|
||||||
count(where?: Partial<Record<string, any>>): Promise<number>;
|
count(where?: Partial<Record<string, any>>): Promise<number>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Employee } from '../entities/employee.entity';
|
||||||
|
|
||||||
|
export const EMPLOYEE_REPOSITORY = Symbol('IEmployeeRepository');
|
||||||
|
|
||||||
|
export interface IEmployeeRepository {
|
||||||
|
findById(id: string): Promise<Employee | null>;
|
||||||
|
create(data: Partial<Employee>): Promise<Employee>;
|
||||||
|
save(employee: Employee): Promise<Employee>;
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
findByIssuerId(
|
||||||
|
issuerId: string,
|
||||||
|
filters?: { storeId?: string; role?: string },
|
||||||
|
): Promise<Employee[]>;
|
||||||
|
count(where?: Partial<Record<string, any>>): Promise<number>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Redemption } from '../entities/redemption.entity';
|
||||||
|
|
||||||
|
export const REDEMPTION_REPOSITORY = Symbol('IRedemptionRepository');
|
||||||
|
|
||||||
|
export interface RedemptionListFilters {
|
||||||
|
issuerId?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRedemptionRepository {
|
||||||
|
findById(id: string): Promise<Redemption | null>;
|
||||||
|
create(data: Partial<Redemption>): Promise<Redemption>;
|
||||||
|
createMany(data: Partial<Redemption>[]): Promise<Redemption[]>;
|
||||||
|
findAndCount(filters: RedemptionListFilters): Promise<[Redemption[], number]>;
|
||||||
|
getTodayStats(issuerId: string): Promise<{ count: number; totalAmount: number }>;
|
||||||
|
}
|
||||||
|
|
@ -4,9 +4,14 @@ export const STORE_REPOSITORY = Symbol('IStoreRepository');
|
||||||
|
|
||||||
export interface IStoreRepository {
|
export interface IStoreRepository {
|
||||||
findById(id: string): Promise<Store | null>;
|
findById(id: string): Promise<Store | null>;
|
||||||
|
create(data: Partial<Store>): Promise<Store>;
|
||||||
save(store: Store): Promise<Store>;
|
save(store: Store): Promise<Store>;
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
count(where?: Partial<Record<string, any>>): Promise<number>;
|
count(where?: Partial<Record<string, any>>): Promise<number>;
|
||||||
findByIssuerId(issuerId: string): Promise<Store[]>;
|
findByIssuerId(
|
||||||
|
issuerId: string,
|
||||||
|
filters?: { level?: string; parentId?: string; status?: string },
|
||||||
|
): Promise<Store[]>;
|
||||||
findByIssuerIds(issuerIds: string[]): Promise<Store[]>;
|
findByIssuerIds(issuerIds: string[]): Promise<Store[]>;
|
||||||
findTopStores(limit: number): Promise<Store[]>;
|
findTopStores(limit: number): Promise<Store[]>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
CouponSoldAggregateRow,
|
CouponSoldAggregateRow,
|
||||||
DiscountDistributionRow,
|
DiscountDistributionRow,
|
||||||
RedemptionRateRow,
|
RedemptionRateRow,
|
||||||
|
OwnerSummary,
|
||||||
} from '../../domain/repositories/coupon.repository.interface';
|
} from '../../domain/repositories/coupon.repository.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -78,7 +79,41 @@ export class CouponRepository implements ICouponRepository {
|
||||||
return qb.getManyAndCount();
|
return qb.getManyAndCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateStatus(id: string, status: CouponStatus): Promise<Coupon> {
|
async findByOwnerWithIssuerJoin(userId: string, filters: CouponListFilters): Promise<[Coupon[], number]> {
|
||||||
|
const { status, page, limit } = filters;
|
||||||
|
const qb = this.repo.createQueryBuilder('c');
|
||||||
|
|
||||||
|
qb.leftJoinAndMapOne('c.issuer', Issuer, 'i', 'i.id = c.issuer_id');
|
||||||
|
qb.andWhere('c.owner_user_id = :userId', { userId });
|
||||||
|
|
||||||
|
if (status) qb.andWhere('c.status = :status', { status });
|
||||||
|
|
||||||
|
qb.orderBy('c.created_at', 'DESC')
|
||||||
|
.skip((page - 1) * limit)
|
||||||
|
.take(limit);
|
||||||
|
|
||||||
|
return qb.getManyAndCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOwnerSummary(userId: string): Promise<OwnerSummary> {
|
||||||
|
const result = await this.repo
|
||||||
|
.createQueryBuilder('c')
|
||||||
|
.select([
|
||||||
|
'COUNT(c.id) as "count"',
|
||||||
|
'COALESCE(SUM(CAST(c.face_value AS numeric)), 0) as "totalFaceValue"',
|
||||||
|
'COALESCE(SUM(CAST(c.face_value AS numeric) - COALESCE(CAST(c.current_price AS numeric), CAST(c.face_value AS numeric))), 0) as "totalSaved"',
|
||||||
|
])
|
||||||
|
.where('c.owner_user_id = :userId', { userId })
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: Number(result.count) || 0,
|
||||||
|
totalFaceValue: Number(result.totalFaceValue) || 0,
|
||||||
|
totalSaved: Number(result.totalSaved) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(id: string, status: string): Promise<Coupon> {
|
||||||
const coupon = await this.repo.findOne({ where: { id } });
|
const coupon = await this.repo.findOne({ where: { id } });
|
||||||
if (!coupon) throw new NotFoundException('Coupon not found');
|
if (!coupon) throw new NotFoundException('Coupon not found');
|
||||||
coupon.status = status;
|
coupon.status = status;
|
||||||
|
|
@ -92,14 +127,14 @@ export class CouponRepository implements ICouponRepository {
|
||||||
lock: { mode: 'pessimistic_write' },
|
lock: { mode: 'pessimistic_write' },
|
||||||
});
|
});
|
||||||
if (!coupon) throw new NotFoundException('Coupon not found');
|
if (!coupon) throw new NotFoundException('Coupon not found');
|
||||||
if (coupon.status !== CouponStatus.ACTIVE) {
|
if (coupon.status !== CouponStatus.LISTED) {
|
||||||
throw new BadRequestException('Coupon is not available');
|
throw new BadRequestException('Coupon is not available');
|
||||||
}
|
}
|
||||||
if (coupon.remainingSupply < quantity) {
|
if (coupon.remainingSupply < quantity) {
|
||||||
throw new BadRequestException('Insufficient supply');
|
throw new BadRequestException('Insufficient supply');
|
||||||
}
|
}
|
||||||
coupon.remainingSupply -= quantity;
|
coupon.remainingSupply -= quantity;
|
||||||
if (coupon.remainingSupply === 0) coupon.status = CouponStatus.SOLD_OUT;
|
if (coupon.remainingSupply === 0) coupon.status = CouponStatus.SOLD;
|
||||||
await manager.save(coupon);
|
await manager.save(coupon);
|
||||||
return coupon;
|
return coupon;
|
||||||
});
|
});
|
||||||
|
|
@ -175,7 +210,7 @@ export class CouponRepository implements ICouponRepository {
|
||||||
'COUNT(c.id) as "couponCount"',
|
'COUNT(c.id) as "couponCount"',
|
||||||
'COALESCE(SUM(c.total_supply), 0) as "totalSupply"',
|
'COALESCE(SUM(c.total_supply), 0) as "totalSupply"',
|
||||||
'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"',
|
'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"',
|
||||||
'COALESCE(AVG(CAST(c.price AS numeric)), 0) as "avgPrice"',
|
'COALESCE(AVG(CAST(c.current_price AS numeric)), 0) as "avgPrice"',
|
||||||
])
|
])
|
||||||
.groupBy('c.category')
|
.groupBy('c.category')
|
||||||
.orderBy('"couponCount"', 'DESC')
|
.orderBy('"couponCount"', 'DESC')
|
||||||
|
|
@ -232,17 +267,17 @@ export class CouponRepository implements ICouponRepository {
|
||||||
.select([
|
.select([
|
||||||
`CASE
|
`CASE
|
||||||
WHEN CAST(c.face_value AS numeric) = 0 THEN 'N/A'
|
WHEN CAST(c.face_value AS numeric) = 0 THEN 'N/A'
|
||||||
WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 0 THEN 'premium'
|
WHEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 0 THEN 'premium'
|
||||||
WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 10 THEN '0-10%'
|
WHEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 10 THEN '0-10%'
|
||||||
WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 20 THEN '10-20%'
|
WHEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 20 THEN '10-20%'
|
||||||
WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 30 THEN '20-30%'
|
WHEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 30 THEN '20-30%'
|
||||||
WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 50 THEN '30-50%'
|
WHEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 50 THEN '30-50%'
|
||||||
ELSE '50%+'
|
ELSE '50%+'
|
||||||
END as "range"`,
|
END as "range"`,
|
||||||
'COUNT(c.id) as "count"',
|
'COUNT(c.id) as "count"',
|
||||||
`COALESCE(AVG(
|
`COALESCE(AVG(
|
||||||
CASE WHEN CAST(c.face_value AS numeric) > 0
|
CASE WHEN CAST(c.face_value AS numeric) > 0
|
||||||
THEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100
|
THEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END
|
END
|
||||||
), 0) as "avgDiscount"`,
|
), 0) as "avgDiscount"`,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Employee } from '../../domain/entities/employee.entity';
|
||||||
|
import { IEmployeeRepository } from '../../domain/repositories/employee.repository.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmployeeRepository implements IEmployeeRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Employee)
|
||||||
|
private readonly repo: Repository<Employee>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Employee | null> {
|
||||||
|
return this.repo.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Partial<Employee>): Promise<Employee> {
|
||||||
|
const entity = this.repo.create(data);
|
||||||
|
return this.repo.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(employee: Employee): Promise<Employee> {
|
||||||
|
return this.repo.save(employee);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.repo.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByIssuerId(
|
||||||
|
issuerId: string,
|
||||||
|
filters?: { storeId?: string; role?: string },
|
||||||
|
): Promise<Employee[]> {
|
||||||
|
const qb = this.repo.createQueryBuilder('e');
|
||||||
|
qb.where('e.issuer_id = :issuerId', { issuerId });
|
||||||
|
|
||||||
|
if (filters?.storeId) qb.andWhere('e.store_id = :storeId', { storeId: filters.storeId });
|
||||||
|
if (filters?.role) qb.andWhere('e.role = :role', { role: filters.role });
|
||||||
|
|
||||||
|
qb.orderBy('e.created_at', 'DESC');
|
||||||
|
return qb.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(where?: Partial<Record<string, any>>): Promise<number> {
|
||||||
|
return this.repo.count({ where: where as any });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Redemption } from '../../domain/entities/redemption.entity';
|
||||||
|
import {
|
||||||
|
IRedemptionRepository,
|
||||||
|
RedemptionListFilters,
|
||||||
|
} from '../../domain/repositories/redemption.repository.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedemptionRepository implements IRedemptionRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Redemption)
|
||||||
|
private readonly repo: Repository<Redemption>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Redemption | null> {
|
||||||
|
return this.repo.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Partial<Redemption>): Promise<Redemption> {
|
||||||
|
const entity = this.repo.create(data);
|
||||||
|
return this.repo.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMany(data: Partial<Redemption>[]): Promise<Redemption[]> {
|
||||||
|
const entities = this.repo.create(data);
|
||||||
|
return this.repo.save(entities);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAndCount(filters: RedemptionListFilters): Promise<[Redemption[], number]> {
|
||||||
|
const { issuerId, startDate, endDate, page, limit } = filters;
|
||||||
|
const qb = this.repo.createQueryBuilder('r');
|
||||||
|
|
||||||
|
if (issuerId) qb.andWhere('r.issuer_id = :issuerId', { issuerId });
|
||||||
|
if (startDate) qb.andWhere('r.created_at >= :startDate', { startDate });
|
||||||
|
if (endDate) qb.andWhere('r.created_at <= :endDate', { endDate });
|
||||||
|
|
||||||
|
qb.orderBy('r.created_at', 'DESC')
|
||||||
|
.skip((page - 1) * limit)
|
||||||
|
.take(limit);
|
||||||
|
|
||||||
|
return qb.getManyAndCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTodayStats(issuerId: string): Promise<{ count: number; totalAmount: number }> {
|
||||||
|
const result = await this.repo
|
||||||
|
.createQueryBuilder('r')
|
||||||
|
.select([
|
||||||
|
'COUNT(r.id) as "count"',
|
||||||
|
'COALESCE(SUM(CAST(r.amount AS numeric)), 0) as "totalAmount"',
|
||||||
|
])
|
||||||
|
.where('r.issuer_id = :issuerId', { issuerId })
|
||||||
|
.andWhere('r.created_at >= CURRENT_DATE')
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: Number(result.count) || 0,
|
||||||
|
totalAmount: Number(result.totalAmount) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,16 +15,36 @@ export class StoreRepository implements IStoreRepository {
|
||||||
return this.repo.findOne({ where: { id } });
|
return this.repo.findOne({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async create(data: Partial<Store>): Promise<Store> {
|
||||||
|
const entity = this.repo.create(data);
|
||||||
|
return this.repo.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
async save(store: Store): Promise<Store> {
|
async save(store: Store): Promise<Store> {
|
||||||
return this.repo.save(store);
|
return this.repo.save(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.repo.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
async count(where?: Partial<Record<string, any>>): Promise<number> {
|
async count(where?: Partial<Record<string, any>>): Promise<number> {
|
||||||
return this.repo.count({ where: where as any });
|
return this.repo.count({ where: where as any });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByIssuerId(issuerId: string): Promise<Store[]> {
|
async findByIssuerId(
|
||||||
return this.repo.find({ where: { issuerId } });
|
issuerId: string,
|
||||||
|
filters?: { level?: string; parentId?: string; status?: string },
|
||||||
|
): Promise<Store[]> {
|
||||||
|
const qb = this.repo.createQueryBuilder('s');
|
||||||
|
qb.where('s.issuer_id = :issuerId', { issuerId });
|
||||||
|
|
||||||
|
if (filters?.level) qb.andWhere('s.level = :level', { level: filters.level });
|
||||||
|
if (filters?.parentId) qb.andWhere('s.parent_id = :parentId', { parentId: filters.parentId });
|
||||||
|
if (filters?.status) qb.andWhere('s.status = :status', { status: filters.status });
|
||||||
|
|
||||||
|
qb.orderBy('s.created_at', 'DESC');
|
||||||
|
return qb.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByIssuerIds(issuerIds: string[]): Promise<Store[]> {
|
async findByIssuerIds(issuerIds: string[]): Promise<Store[]> {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { Controller, Get, Post, Body, Query, UseGuards, Req } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { CouponBatchService } from '../../../application/services/coupon-batch.service';
|
||||||
|
import {
|
||||||
|
BatchIssueDto,
|
||||||
|
BatchRecallDto,
|
||||||
|
BatchPriceAdjustDto,
|
||||||
|
ListBatchOperationsQueryDto,
|
||||||
|
} from '../dto/coupon-batch.dto';
|
||||||
|
|
||||||
|
@ApiTags('Coupon Batch Operations')
|
||||||
|
@Controller('coupons/batch')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class CouponBatchController {
|
||||||
|
constructor(private readonly couponBatchService: CouponBatchService) {}
|
||||||
|
|
||||||
|
@Post('issue')
|
||||||
|
@ApiOperation({ summary: 'Batch issue coupons from a template' })
|
||||||
|
async batchIssue(@Req() req: any, @Body() dto: BatchIssueDto) {
|
||||||
|
const result = await this.couponBatchService.batchIssue(
|
||||||
|
req.user.issuerId,
|
||||||
|
dto.templateId,
|
||||||
|
dto.quantity,
|
||||||
|
dto.expiryDate,
|
||||||
|
);
|
||||||
|
return { code: 0, data: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('recall')
|
||||||
|
@ApiOperation({ summary: 'Batch recall coupons' })
|
||||||
|
async batchRecall(@Req() req: any, @Body() dto: BatchRecallDto) {
|
||||||
|
const result = await this.couponBatchService.batchRecall(
|
||||||
|
req.user.issuerId,
|
||||||
|
dto.couponIds,
|
||||||
|
dto.reason,
|
||||||
|
);
|
||||||
|
return { code: 0, data: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('price-adjust')
|
||||||
|
@ApiOperation({ summary: 'Batch adjust coupon prices' })
|
||||||
|
async batchPriceAdjust(@Req() req: any, @Body() dto: BatchPriceAdjustDto) {
|
||||||
|
const result = await this.couponBatchService.batchPriceAdjust(
|
||||||
|
req.user.issuerId,
|
||||||
|
dto.couponIds,
|
||||||
|
dto.adjustPercent,
|
||||||
|
);
|
||||||
|
return { code: 0, data: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('operations')
|
||||||
|
@ApiOperation({ summary: 'List batch operation history' })
|
||||||
|
async listOperations(@Req() req: any, @Query() query: ListBatchOperationsQueryDto) {
|
||||||
|
const result = await this.couponBatchService.listOperations(
|
||||||
|
req.user.issuerId,
|
||||||
|
query.page || 1,
|
||||||
|
query.limit || 20,
|
||||||
|
);
|
||||||
|
return { code: 0, data: result };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,9 +22,9 @@ export class CouponController {
|
||||||
const coupon = await this.couponService.create(req.user.id, {
|
const coupon = await this.couponService.create(req.user.id, {
|
||||||
...dto,
|
...dto,
|
||||||
faceValue: String(dto.faceValue),
|
faceValue: String(dto.faceValue),
|
||||||
price: String(dto.price),
|
currentPrice: String(dto.price),
|
||||||
validFrom: new Date(dto.validFrom),
|
issuePrice: String(dto.price),
|
||||||
validUntil: new Date(dto.validUntil),
|
expiryDate: new Date(dto.validUntil),
|
||||||
} as any);
|
} as any);
|
||||||
return { code: 0, data: coupon };
|
return { code: 0, data: coupon };
|
||||||
}
|
}
|
||||||
|
|
@ -45,6 +45,62 @@ export class CouponController {
|
||||||
return { code: 0, data: result };
|
return { code: 0, data: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('my')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get current user coupon holdings' })
|
||||||
|
async getMyHoldings(@Req() req: any, @Query() query: ListCouponsQueryDto) {
|
||||||
|
const result = await this.couponService.getByOwner(
|
||||||
|
req.user.id,
|
||||||
|
query.page || 1,
|
||||||
|
query.limit || 20,
|
||||||
|
query.status,
|
||||||
|
);
|
||||||
|
return { code: 0, data: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('my/summary')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get user holdings summary (count + total value + saved)' })
|
||||||
|
async getMySummary(@Req() req: any) {
|
||||||
|
const summary = await this.couponService.getOwnerSummary(req.user.id);
|
||||||
|
return { code: 0, data: summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('search')
|
||||||
|
@ApiOperation({ summary: 'Search coupons with keyword, category, and sort' })
|
||||||
|
async search(
|
||||||
|
@Query('q') q?: string,
|
||||||
|
@Query('category') category?: string,
|
||||||
|
@Query('sort') sort?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
) {
|
||||||
|
const result = await this.couponService.search(
|
||||||
|
q,
|
||||||
|
category,
|
||||||
|
sort || 'newest',
|
||||||
|
parseInt(page || '1'),
|
||||||
|
parseInt(limit || '20'),
|
||||||
|
);
|
||||||
|
return { code: 0, data: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/nearby-stores')
|
||||||
|
@ApiOperation({ summary: 'List stores that accept this coupon' })
|
||||||
|
async getNearbyStores(@Param('id') id: string) {
|
||||||
|
const stores = await this.couponService.getNearbyStores(id);
|
||||||
|
return { code: 0, data: stores };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/similar')
|
||||||
|
@ApiOperation({ summary: 'Find similar coupons (same category, different issuer)' })
|
||||||
|
async getSimilar(@Param('id') id: string, @Query('limit') limit?: string) {
|
||||||
|
const similar = await this.couponService.findSimilar(id, parseInt(limit || '10'));
|
||||||
|
return { code: 0, data: similar };
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get coupon details' })
|
@ApiOperation({ summary: 'Get coupon details' })
|
||||||
async getById(@Param('id') id: string) {
|
async getById(@Param('id') id: string) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { Controller, Get, Post, Body, Query, UseGuards, Req } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { IssuerFinanceService } from '../../../application/services/issuer-finance.service';
|
||||||
|
import {
|
||||||
|
WithdrawDto,
|
||||||
|
ListTransactionsQueryDto,
|
||||||
|
ReconciliationQueryDto,
|
||||||
|
} from '../dto/issuer-finance.dto';
|
||||||
|
|
||||||
|
@ApiTags('Issuer Finance')
|
||||||
|
@Controller('issuers/me/finance')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class IssuerFinanceController {
|
||||||
|
constructor(private readonly issuerFinanceService: IssuerFinanceService) {}
|
||||||
|
|
||||||
|
@Get('balance')
|
||||||
|
@ApiOperation({ summary: 'Get issuer financial balance' })
|
||||||
|
async getBalance(@Req() req: any) {
|
||||||
|
const balance = await this.issuerFinanceService.getBalance(req.user.issuerId);
|
||||||
|
return { code: 0, data: balance };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('stats')
|
||||||
|
@ApiOperation({ summary: 'Get issuer finance summary' })
|
||||||
|
async getFinanceStats(@Req() req: any) {
|
||||||
|
const stats = await this.issuerFinanceService.getFinanceStats(req.user.issuerId);
|
||||||
|
return { code: 0, data: stats };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('transactions')
|
||||||
|
@ApiOperation({ summary: 'Get issuer transaction history' })
|
||||||
|
async getTransactions(@Req() req: any, @Query() query: ListTransactionsQueryDto) {
|
||||||
|
const result = await this.issuerFinanceService.getTransactions(
|
||||||
|
req.user.issuerId,
|
||||||
|
query.page || 1,
|
||||||
|
query.limit || 20,
|
||||||
|
query.type,
|
||||||
|
);
|
||||||
|
return { code: 0, data: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('withdraw')
|
||||||
|
@ApiOperation({ summary: 'Request a withdrawal' })
|
||||||
|
async requestWithdrawal(@Req() req: any, @Body() dto: WithdrawDto) {
|
||||||
|
const result = await this.issuerFinanceService.requestWithdrawal(
|
||||||
|
req.user.issuerId,
|
||||||
|
dto.amount,
|
||||||
|
);
|
||||||
|
return { code: 0, data: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('reconciliation')
|
||||||
|
@ApiOperation({ summary: 'Get reconciliation data' })
|
||||||
|
async getReconciliation(@Req() req: any, @Query() query: ReconciliationQueryDto) {
|
||||||
|
const result = await this.issuerFinanceService.getReconciliation(
|
||||||
|
req.user.issuerId,
|
||||||
|
query.startDate,
|
||||||
|
query.endDate,
|
||||||
|
);
|
||||||
|
return { code: 0, data: result };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { IssuerStatsService } from '../../../application/services/issuer-stats.service';
|
||||||
|
|
||||||
|
@ApiTags('Issuer Stats')
|
||||||
|
@Controller('issuers/me')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class IssuerStatsController {
|
||||||
|
constructor(private readonly issuerStatsService: IssuerStatsService) {}
|
||||||
|
|
||||||
|
@Get('stats')
|
||||||
|
@ApiOperation({ summary: 'Get issuer dashboard stats' })
|
||||||
|
async getStats(@Req() req: any) {
|
||||||
|
const stats = await this.issuerStatsService.getDashboardStats(req.user.issuerId);
|
||||||
|
return { code: 0, data: stats };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('credit')
|
||||||
|
@ApiOperation({ summary: 'Get issuer credit score details' })
|
||||||
|
async getCreditDetails(@Req() req: any) {
|
||||||
|
const credit = await this.issuerStatsService.getCreditDetails(req.user.issuerId);
|
||||||
|
return { code: 0, data: credit };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { IssuerStoreService } from '../../../application/services/issuer-store.service';
|
||||||
|
import { CreateStoreDto, UpdateStoreDto, ListStoresQueryDto } from '../dto/store.dto';
|
||||||
|
import { CreateEmployeeDto, UpdateEmployeeDto, ListEmployeesQueryDto } from '../dto/employee.dto';
|
||||||
|
|
||||||
|
// ---- Store Controller ----
|
||||||
|
|
||||||
|
@ApiTags('Issuer Stores')
|
||||||
|
@Controller('issuers/me/stores')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class IssuerStoreController {
|
||||||
|
constructor(private readonly issuerStoreService: IssuerStoreService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List stores for the current issuer' })
|
||||||
|
async listStores(@Req() req: any, @Query() query: ListStoresQueryDto) {
|
||||||
|
const stores = await this.issuerStoreService.listStores(req.user.issuerId, {
|
||||||
|
level: query.level,
|
||||||
|
parentId: query.parentId,
|
||||||
|
status: query.status,
|
||||||
|
});
|
||||||
|
return { code: 0, data: stores };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a new store' })
|
||||||
|
async createStore(@Req() req: any, @Body() dto: CreateStoreDto) {
|
||||||
|
const store = await this.issuerStoreService.createStore(req.user.issuerId, {
|
||||||
|
name: dto.name,
|
||||||
|
address: dto.address || null,
|
||||||
|
phone: dto.phone || null,
|
||||||
|
level: dto.level || 'store',
|
||||||
|
parentId: dto.parentId || null,
|
||||||
|
});
|
||||||
|
return { code: 0, data: store };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@ApiOperation({ summary: 'Update a store' })
|
||||||
|
async updateStore(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateStoreDto,
|
||||||
|
) {
|
||||||
|
const store = await this.issuerStoreService.updateStore(req.user.issuerId, id, dto);
|
||||||
|
return { code: 0, data: store };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete a store' })
|
||||||
|
async deleteStore(@Req() req: any, @Param('id') id: string) {
|
||||||
|
await this.issuerStoreService.deleteStore(req.user.issuerId, id);
|
||||||
|
return { code: 0, message: 'Store deleted' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Employee Controller ----
|
||||||
|
|
||||||
|
@ApiTags('Issuer Employees')
|
||||||
|
@Controller('issuers/me/employees')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class IssuerEmployeeController {
|
||||||
|
constructor(private readonly issuerStoreService: IssuerStoreService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List employees for the current issuer' })
|
||||||
|
async listEmployees(@Req() req: any, @Query() query: ListEmployeesQueryDto) {
|
||||||
|
const employees = await this.issuerStoreService.listEmployees(req.user.issuerId, {
|
||||||
|
storeId: query.storeId,
|
||||||
|
role: query.role,
|
||||||
|
});
|
||||||
|
return { code: 0, data: employees };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a new employee' })
|
||||||
|
async createEmployee(@Req() req: any, @Body() dto: CreateEmployeeDto) {
|
||||||
|
const employee = await this.issuerStoreService.createEmployee(req.user.issuerId, {
|
||||||
|
name: dto.name,
|
||||||
|
phone: dto.phone || null,
|
||||||
|
role: dto.role || 'staff',
|
||||||
|
storeId: dto.storeId || null,
|
||||||
|
});
|
||||||
|
return { code: 0, data: employee };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@ApiOperation({ summary: 'Update an employee' })
|
||||||
|
async updateEmployee(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateEmployeeDto,
|
||||||
|
) {
|
||||||
|
const employee = await this.issuerStoreService.updateEmployee(req.user.issuerId, id, dto);
|
||||||
|
return { code: 0, data: employee };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete an employee' })
|
||||||
|
async deleteEmployee(@Req() req: any, @Param('id') id: string) {
|
||||||
|
await this.issuerStoreService.deleteEmployee(req.user.issuerId, id);
|
||||||
|
return { code: 0, message: 'Employee deleted' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { Controller, Get, Post, Body, Query, UseGuards, Req } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { RedemptionService } from '../../../application/services/redemption.service';
|
||||||
|
import {
|
||||||
|
ScanRedemptionDto,
|
||||||
|
ManualRedemptionDto,
|
||||||
|
BatchRedemptionDto,
|
||||||
|
ListRedemptionsQueryDto,
|
||||||
|
} from '../dto/redemption.dto';
|
||||||
|
|
||||||
|
@ApiTags('Redemptions')
|
||||||
|
@Controller('redemptions')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class RedemptionController {
|
||||||
|
constructor(private readonly redemptionService: RedemptionService) {}
|
||||||
|
|
||||||
|
@Post('scan')
|
||||||
|
@ApiOperation({ summary: 'Redeem a coupon by QR code scan' })
|
||||||
|
async scanRedeem(@Req() req: any, @Body() dto: ScanRedemptionDto) {
|
||||||
|
const result = await this.redemptionService.scanRedeem(
|
||||||
|
req.user.issuerId,
|
||||||
|
dto.qrCode,
|
||||||
|
dto.storeId,
|
||||||
|
);
|
||||||
|
return { code: 0, data: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('manual')
|
||||||
|
@ApiOperation({ summary: 'Redeem a coupon by manual code entry' })
|
||||||
|
async manualRedeem(@Req() req: any, @Body() dto: ManualRedemptionDto) {
|
||||||
|
const result = await this.redemptionService.manualRedeem(
|
||||||
|
req.user.issuerId,
|
||||||
|
dto.couponCode,
|
||||||
|
dto.storeId,
|
||||||
|
);
|
||||||
|
return { code: 0, data: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('batch')
|
||||||
|
@ApiOperation({ summary: 'Batch redeem multiple coupons' })
|
||||||
|
async batchRedeem(@Req() req: any, @Body() dto: BatchRedemptionDto) {
|
||||||
|
const result = await this.redemptionService.batchRedeem(
|
||||||
|
req.user.issuerId,
|
||||||
|
dto.couponCodes,
|
||||||
|
dto.storeId,
|
||||||
|
);
|
||||||
|
return { code: 0, data: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List redemption history' })
|
||||||
|
async listRedemptions(@Req() req: any, @Query() query: ListRedemptionsQueryDto) {
|
||||||
|
const result = await this.redemptionService.listRedemptions(
|
||||||
|
req.user.issuerId,
|
||||||
|
query.page || 1,
|
||||||
|
query.limit || 20,
|
||||||
|
query.startDate,
|
||||||
|
query.endDate,
|
||||||
|
);
|
||||||
|
return { code: 0, data: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('today-stats')
|
||||||
|
@ApiOperation({ summary: 'Get today redemption statistics' })
|
||||||
|
async getTodayStats(@Req() req: any) {
|
||||||
|
const stats = await this.redemptionService.getTodayStats(req.user.issuerId);
|
||||||
|
return { code: 0, data: stats };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsNumber,
|
||||||
|
IsArray,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
IsDateString,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class BatchIssueDto {
|
||||||
|
@ApiProperty({ description: 'UUID of the coupon template to clone' })
|
||||||
|
@IsUUID()
|
||||||
|
templateId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ minimum: 1, maximum: 10000 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(10000)
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'ISO 8601 date for expiry' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
expiryDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BatchRecallDto {
|
||||||
|
@ApiProperty({ type: [String], description: 'Array of coupon UUIDs to recall' })
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
couponIds: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ maxLength: 1000 })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(1000)
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BatchPriceAdjustDto {
|
||||||
|
@ApiProperty({ type: [String], description: 'Array of coupon UUIDs to adjust' })
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
couponIds: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Percent adjustment (-50 to +100)', minimum: -50, maximum: 100 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-50)
|
||||||
|
@Max(100)
|
||||||
|
adjustPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListBatchOperationsQueryDto {
|
||||||
|
@ApiPropertyOptional({ default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number = 20;
|
||||||
|
}
|
||||||
|
|
@ -83,7 +83,7 @@ export class CreateCouponDto {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateCouponStatusDto {
|
export class UpdateCouponStatusDto {
|
||||||
@ApiProperty({ enum: ['draft', 'active', 'paused', 'expired', 'sold_out'] })
|
@ApiProperty({ enum: ['minted', 'listed', 'sold', 'in_circulation', 'redeemed', 'expired', 'recalled'] })
|
||||||
@IsString()
|
@IsString()
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
@ -118,7 +118,7 @@ export class ListCouponsQueryDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
category?: string;
|
category?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ['draft', 'active', 'paused', 'expired', 'sold_out'] })
|
@ApiPropertyOptional({ enum: ['minted', 'listed', 'sold', 'in_circulation', 'redeemed', 'expired', 'recalled'] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
MaxLength,
|
||||||
|
IsUUID,
|
||||||
|
IsIn,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateEmployeeDto {
|
||||||
|
@ApiProperty({ maxLength: 100 })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['manager', 'cashier', 'staff'], default: 'staff' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['manager', 'cashier', 'staff'])
|
||||||
|
role?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'UUID of the store this employee belongs to' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
storeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateEmployeeDto {
|
||||||
|
@ApiPropertyOptional({ maxLength: 100 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['manager', 'cashier', 'staff'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['manager', 'cashier', 'staff'])
|
||||||
|
role?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
storeId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['active', 'inactive'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['active', 'inactive'])
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListEmployeesQueryDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
storeId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['manager', 'cashier', 'staff'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import {
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
IsDateString,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class WithdrawDto {
|
||||||
|
@ApiProperty({ minimum: 0.01, description: 'Withdrawal amount' })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListTransactionsQueryDto {
|
||||||
|
@ApiPropertyOptional({ default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number = 20;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['sale', 'redemption', 'withdrawal', 'fee'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReconciliationQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Period start date (ISO 8601)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Period end date (ISO 8601)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsArray,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
IsDateString,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class ScanRedemptionDto {
|
||||||
|
@ApiProperty({ description: 'QR code content' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(1000)
|
||||||
|
qrCode: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Store ID where redemption occurs' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
storeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ManualRedemptionDto {
|
||||||
|
@ApiProperty({ description: 'Coupon code for manual entry' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
couponCode: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
storeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BatchRedemptionDto {
|
||||||
|
@ApiProperty({ type: [String], description: 'Array of coupon codes' })
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
couponCodes: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
storeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListRedemptionsQueryDto {
|
||||||
|
@ApiPropertyOptional({ default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number = 20;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'ISO 8601 date string' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'ISO 8601 date string' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
MaxLength,
|
||||||
|
IsUUID,
|
||||||
|
IsIn,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateStoreDto {
|
||||||
|
@ApiProperty({ maxLength: 200 })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 500 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
address?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['hq', 'region', 'store'], default: 'store' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['hq', 'region', 'store'])
|
||||||
|
level?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'UUID of the parent store' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateStoreDto {
|
||||||
|
@ApiPropertyOptional({ maxLength: 200 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 500 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
address?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['hq', 'region', 'store'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['hq', 'region', 'store'])
|
||||||
|
level?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
parentId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['active', 'inactive', 'closed'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['active', 'inactive', 'closed'])
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListStoresQueryDto {
|
||||||
|
@ApiPropertyOptional({ enum: ['hq', 'region', 'store'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
level?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
parentId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['active', 'inactive', 'closed'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,8 @@ import { Coupon } from './domain/entities/coupon.entity';
|
||||||
import { Store } from './domain/entities/store.entity';
|
import { Store } from './domain/entities/store.entity';
|
||||||
import { CouponRule } from './domain/entities/coupon-rule.entity';
|
import { CouponRule } from './domain/entities/coupon-rule.entity';
|
||||||
import { CreditMetric } from './domain/entities/credit-metric.entity';
|
import { CreditMetric } from './domain/entities/credit-metric.entity';
|
||||||
|
import { Employee } from './domain/entities/employee.entity';
|
||||||
|
import { Redemption } from './domain/entities/redemption.entity';
|
||||||
|
|
||||||
// Domain repository interfaces (Symbols)
|
// Domain repository interfaces (Symbols)
|
||||||
import { ISSUER_REPOSITORY } from './domain/repositories/issuer.repository.interface';
|
import { ISSUER_REPOSITORY } from './domain/repositories/issuer.repository.interface';
|
||||||
|
|
@ -16,6 +18,8 @@ import { COUPON_REPOSITORY } from './domain/repositories/coupon.repository.inter
|
||||||
import { COUPON_RULE_REPOSITORY } from './domain/repositories/coupon-rule.repository.interface';
|
import { COUPON_RULE_REPOSITORY } from './domain/repositories/coupon-rule.repository.interface';
|
||||||
import { STORE_REPOSITORY } from './domain/repositories/store.repository.interface';
|
import { STORE_REPOSITORY } from './domain/repositories/store.repository.interface';
|
||||||
import { CREDIT_METRIC_REPOSITORY } from './domain/repositories/credit-metric.repository.interface';
|
import { CREDIT_METRIC_REPOSITORY } from './domain/repositories/credit-metric.repository.interface';
|
||||||
|
import { EMPLOYEE_REPOSITORY } from './domain/repositories/employee.repository.interface';
|
||||||
|
import { REDEMPTION_REPOSITORY } from './domain/repositories/redemption.repository.interface';
|
||||||
|
|
||||||
// Infrastructure persistence implementations
|
// Infrastructure persistence implementations
|
||||||
import { IssuerRepository } from './infrastructure/persistence/issuer.repository';
|
import { IssuerRepository } from './infrastructure/persistence/issuer.repository';
|
||||||
|
|
@ -23,6 +27,8 @@ import { CouponRepository } from './infrastructure/persistence/coupon.repository
|
||||||
import { CouponRuleRepository } from './infrastructure/persistence/coupon-rule.repository';
|
import { CouponRuleRepository } from './infrastructure/persistence/coupon-rule.repository';
|
||||||
import { StoreRepository } from './infrastructure/persistence/store.repository';
|
import { StoreRepository } from './infrastructure/persistence/store.repository';
|
||||||
import { CreditMetricRepository } from './infrastructure/persistence/credit-metric.repository';
|
import { CreditMetricRepository } from './infrastructure/persistence/credit-metric.repository';
|
||||||
|
import { EmployeeRepository } from './infrastructure/persistence/employee.repository';
|
||||||
|
import { RedemptionRepository } from './infrastructure/persistence/redemption.repository';
|
||||||
|
|
||||||
// Domain ports
|
// Domain ports
|
||||||
import { AI_SERVICE_CLIENT } from './domain/ports/ai-service.client.interface';
|
import { AI_SERVICE_CLIENT } from './domain/ports/ai-service.client.interface';
|
||||||
|
|
@ -39,6 +45,11 @@ import { AdminIssuerService } from './application/services/admin-issuer.service'
|
||||||
import { AdminCouponService } from './application/services/admin-coupon.service';
|
import { AdminCouponService } from './application/services/admin-coupon.service';
|
||||||
import { AdminCouponAnalyticsService } from './application/services/admin-coupon-analytics.service';
|
import { AdminCouponAnalyticsService } from './application/services/admin-coupon-analytics.service';
|
||||||
import { AdminMerchantService } from './application/services/admin-merchant.service';
|
import { AdminMerchantService } from './application/services/admin-merchant.service';
|
||||||
|
import { IssuerStatsService } from './application/services/issuer-stats.service';
|
||||||
|
import { IssuerFinanceService } from './application/services/issuer-finance.service';
|
||||||
|
import { IssuerStoreService } from './application/services/issuer-store.service';
|
||||||
|
import { RedemptionService } from './application/services/redemption.service';
|
||||||
|
import { CouponBatchService } from './application/services/coupon-batch.service';
|
||||||
|
|
||||||
// Interface controllers
|
// Interface controllers
|
||||||
import { IssuerController } from './interface/http/controllers/issuer.controller';
|
import { IssuerController } from './interface/http/controllers/issuer.controller';
|
||||||
|
|
@ -47,13 +58,18 @@ import { AdminIssuerController } from './interface/http/controllers/admin-issuer
|
||||||
import { AdminCouponController } from './interface/http/controllers/admin-coupon.controller';
|
import { AdminCouponController } from './interface/http/controllers/admin-coupon.controller';
|
||||||
import { AdminAnalyticsController } from './interface/http/controllers/admin-analytics.controller';
|
import { AdminAnalyticsController } from './interface/http/controllers/admin-analytics.controller';
|
||||||
import { AdminMerchantController } from './interface/http/controllers/admin-merchant.controller';
|
import { AdminMerchantController } from './interface/http/controllers/admin-merchant.controller';
|
||||||
|
import { IssuerStatsController } from './interface/http/controllers/issuer-stats.controller';
|
||||||
|
import { IssuerFinanceController } from './interface/http/controllers/issuer-finance.controller';
|
||||||
|
import { IssuerStoreController, IssuerEmployeeController } from './interface/http/controllers/issuer-store.controller';
|
||||||
|
import { RedemptionController } from './interface/http/controllers/redemption.controller';
|
||||||
|
import { CouponBatchController } from './interface/http/controllers/coupon-batch.controller';
|
||||||
|
|
||||||
// Interface guards
|
// Interface guards
|
||||||
import { RolesGuard } from './interface/http/guards/roles.guard';
|
import { RolesGuard } from './interface/http/guards/roles.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Issuer, Coupon, Store, CouponRule, CreditMetric]),
|
TypeOrmModule.forFeature([Issuer, Coupon, Store, CouponRule, CreditMetric, Employee, Redemption]),
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
JwtModule.register({ secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret' }),
|
JwtModule.register({ secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret' }),
|
||||||
],
|
],
|
||||||
|
|
@ -64,6 +80,12 @@ import { RolesGuard } from './interface/http/guards/roles.guard';
|
||||||
AdminCouponController,
|
AdminCouponController,
|
||||||
AdminAnalyticsController,
|
AdminAnalyticsController,
|
||||||
AdminMerchantController,
|
AdminMerchantController,
|
||||||
|
IssuerStatsController,
|
||||||
|
IssuerFinanceController,
|
||||||
|
IssuerStoreController,
|
||||||
|
IssuerEmployeeController,
|
||||||
|
RedemptionController,
|
||||||
|
CouponBatchController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Infrastructure -> Domain port binding (Repository pattern)
|
// Infrastructure -> Domain port binding (Repository pattern)
|
||||||
|
|
@ -72,6 +94,8 @@ import { RolesGuard } from './interface/http/guards/roles.guard';
|
||||||
{ provide: COUPON_RULE_REPOSITORY, useClass: CouponRuleRepository },
|
{ provide: COUPON_RULE_REPOSITORY, useClass: CouponRuleRepository },
|
||||||
{ provide: STORE_REPOSITORY, useClass: StoreRepository },
|
{ provide: STORE_REPOSITORY, useClass: StoreRepository },
|
||||||
{ provide: CREDIT_METRIC_REPOSITORY, useClass: CreditMetricRepository },
|
{ provide: CREDIT_METRIC_REPOSITORY, useClass: CreditMetricRepository },
|
||||||
|
{ provide: EMPLOYEE_REPOSITORY, useClass: EmployeeRepository },
|
||||||
|
{ provide: REDEMPTION_REPOSITORY, useClass: RedemptionRepository },
|
||||||
|
|
||||||
// Infrastructure external services
|
// Infrastructure external services
|
||||||
{ provide: AI_SERVICE_CLIENT, useClass: AiServiceClient },
|
{ provide: AI_SERVICE_CLIENT, useClass: AiServiceClient },
|
||||||
|
|
@ -88,6 +112,11 @@ import { RolesGuard } from './interface/http/guards/roles.guard';
|
||||||
AdminCouponService,
|
AdminCouponService,
|
||||||
AdminCouponAnalyticsService,
|
AdminCouponAnalyticsService,
|
||||||
AdminMerchantService,
|
AdminMerchantService,
|
||||||
|
IssuerStatsService,
|
||||||
|
IssuerFinanceService,
|
||||||
|
IssuerStoreService,
|
||||||
|
RedemptionService,
|
||||||
|
CouponBatchService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
IssuerService,
|
IssuerService,
|
||||||
|
|
@ -98,6 +127,11 @@ import { RolesGuard } from './interface/http/guards/roles.guard';
|
||||||
AdminCouponService,
|
AdminCouponService,
|
||||||
AdminCouponAnalyticsService,
|
AdminCouponAnalyticsService,
|
||||||
AdminMerchantService,
|
AdminMerchantService,
|
||||||
|
IssuerStatsService,
|
||||||
|
IssuerFinanceService,
|
||||||
|
IssuerStoreService,
|
||||||
|
RedemptionService,
|
||||||
|
CouponBatchService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class IssuerModule {}
|
export class IssuerModule {}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,8 @@ func main() {
|
||||||
{
|
{
|
||||||
trades.POST("/orders", tradeHandler.PlaceOrder)
|
trades.POST("/orders", tradeHandler.PlaceOrder)
|
||||||
trades.DELETE("/orders/:id", tradeHandler.CancelOrder)
|
trades.DELETE("/orders/:id", tradeHandler.CancelOrder)
|
||||||
|
trades.GET("/my/orders", tradeHandler.MyOrders)
|
||||||
|
trades.POST("/coupons/:id/transfer", tradeHandler.TransferCoupon)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public orderbook
|
// Public orderbook
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,48 @@ func (s *TradeService) GetOrderBookSnapshot(couponID string, depth int) (bids []
|
||||||
return s.matchingService.GetOrderBookSnapshot(couponID, depth)
|
return s.matchingService.GetOrderBookSnapshot(couponID, depth)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOrdersByUserPaginated retrieves paginated orders for a user with optional status filter.
|
||||||
|
func (s *TradeService) GetOrdersByUserPaginated(ctx context.Context, userID string, status string, page, limit int) ([]*entity.Order, int, error) {
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
return s.orderRepo.FindByUserIDPaginated(ctx, userID, status, offset, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferCoupon validates ownership and transfers a coupon to a new owner by updating the order record.
|
||||||
|
func (s *TradeService) TransferCoupon(ctx context.Context, couponID, ownerUserID, recipientID string) error {
|
||||||
|
// Validate that the user actually owns this coupon by checking filled buy orders
|
||||||
|
orders, err := s.orderRepo.FindByCouponID(ctx, couponID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to look up coupon orders: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the user has a filled buy order for this coupon (ownership proof)
|
||||||
|
ownsIt := false
|
||||||
|
for _, o := range orders {
|
||||||
|
if o.UserID == ownerUserID && o.Status == entity.OrderFilled && o.Side == vo.Buy {
|
||||||
|
ownsIt = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ownsIt {
|
||||||
|
return fmt.Errorf("user does not own coupon %s", couponID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a transfer record as a special filled order pair
|
||||||
|
transferID := fmt.Sprintf("xfr-%d", time.Now().UnixNano())
|
||||||
|
transferOrder, err := entity.NewOrder(
|
||||||
|
transferID, recipientID, couponID,
|
||||||
|
vo.Buy, vo.Market, vo.ZeroPrice(), vo.MustNewQuantity(1),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create transfer order: %w", err)
|
||||||
|
}
|
||||||
|
transferOrder.Status = entity.OrderFilled
|
||||||
|
transferOrder.FilledQty = vo.MustNewQuantity(1)
|
||||||
|
transferOrder.RemainingQty = vo.ZeroQuantity()
|
||||||
|
|
||||||
|
return s.orderRepo.Save(ctx, transferOrder)
|
||||||
|
}
|
||||||
|
|
||||||
// GetAllOrderBooks returns all active order books (admin use).
|
// GetAllOrderBooks returns all active order books (admin use).
|
||||||
func (s *TradeService) GetAllOrderBooks() map[string]*entity.OrderBook {
|
func (s *TradeService) GetAllOrderBooks() map[string]*entity.OrderBook {
|
||||||
return s.matchingService.GetAllOrderBooks()
|
return s.matchingService.GetAllOrderBooks()
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,7 @@ type OrderRepository interface {
|
||||||
|
|
||||||
// FindAll retrieves all orders with optional pagination.
|
// FindAll retrieves all orders with optional pagination.
|
||||||
FindAll(ctx context.Context, offset, limit int) ([]*entity.Order, int, error)
|
FindAll(ctx context.Context, offset, limit int) ([]*entity.Order, int, error)
|
||||||
|
|
||||||
|
// FindByUserIDPaginated retrieves paginated orders for a user with optional status filter.
|
||||||
|
FindByUserIDPaginated(ctx context.Context, userID string, status string, offset, limit int) ([]*entity.Order, int, error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -205,3 +205,27 @@ func (r *PostgresOrderRepository) FindAll(ctx context.Context, offset, limit int
|
||||||
}
|
}
|
||||||
return result, int(total), nil
|
return result, int(total), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *PostgresOrderRepository) FindByUserIDPaginated(ctx context.Context, userID string, status string, offset, limit int) ([]*entity.Order, int, error) {
|
||||||
|
var models []orderModel
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
base := r.db.WithContext(ctx).Model(&orderModel{}).Where("user_id = ?", userID)
|
||||||
|
if status != "" {
|
||||||
|
base = base.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := base.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := base.Order("created_at DESC").Offset(offset).Limit(limit).Find(&models).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*entity.Order, len(models))
|
||||||
|
for i := range models {
|
||||||
|
result[i] = models[i].toEntity()
|
||||||
|
}
|
||||||
|
return result, int(total), nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,3 +114,106 @@ func (h *TradeHandler) GetOrderBook(c *gin.Context) {
|
||||||
"asks": asks,
|
"asks": asks,
|
||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MyOrders handles GET /api/v1/trades/my/orders — paginated list of current user's orders.
|
||||||
|
func (h *TradeHandler) MyOrders(c *gin.Context) {
|
||||||
|
userID := c.GetString("userId")
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
status := c.Query("status")
|
||||||
|
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
orders, total, err := h.tradeService.GetOrdersByUserPaginated(c.Request.Context(), userID, status, page, limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": -1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CouponID string `json:"couponId"`
|
||||||
|
CouponName string `json:"couponName"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
TotalAmount float64 `json:"totalAmount"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]orderItem, len(orders))
|
||||||
|
for i, o := range orders {
|
||||||
|
items[i] = orderItem{
|
||||||
|
ID: o.ID,
|
||||||
|
CouponID: o.CouponID,
|
||||||
|
CouponName: o.CouponID, // coupon name would come from coupon service; use ID as placeholder
|
||||||
|
Type: o.Side.String(),
|
||||||
|
Quantity: o.Quantity.Int(),
|
||||||
|
Price: o.Price.Float64(),
|
||||||
|
TotalAmount: o.Price.Float64() * float64(o.Quantity.Int()),
|
||||||
|
Status: string(o.Status),
|
||||||
|
CreatedAt: o.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{
|
||||||
|
"orders": items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferCouponReq is the request body for transferring a coupon.
|
||||||
|
type TransferCouponReq struct {
|
||||||
|
RecipientID string `json:"recipientId"`
|
||||||
|
RecipientPhone string `json:"recipientPhone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferCoupon handles POST /api/v1/trades/coupons/:id/transfer — transfer coupon ownership.
|
||||||
|
func (h *TradeHandler) TransferCoupon(c *gin.Context) {
|
||||||
|
couponID := c.Param("id")
|
||||||
|
userID := c.GetString("userId")
|
||||||
|
|
||||||
|
var req TransferCouponReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recipientID := req.RecipientID
|
||||||
|
if recipientID == "" && req.RecipientPhone == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "message": "recipientId or recipientPhone is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If recipientPhone is provided but no recipientId, use phone as placeholder ID.
|
||||||
|
// In production, this would look up the user by phone via user-service.
|
||||||
|
if recipientID == "" {
|
||||||
|
recipientID = "phone:" + req.RecipientPhone
|
||||||
|
}
|
||||||
|
|
||||||
|
if recipientID == userID {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "message": "cannot transfer to yourself"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.tradeService.TransferCoupon(c.Request.Context(), couponID, userID, recipientID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{
|
||||||
|
"couponId": couponID,
|
||||||
|
"recipientId": recipientID,
|
||||||
|
"status": "transferred",
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,38 @@ export class UserProfileService {
|
||||||
return this.userRepo.updateProfile(userId, data);
|
return this.userRepo.updateProfile(userId, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSettings(userId: string) {
|
||||||
|
const user = await this.userRepo.findById(userId);
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
return {
|
||||||
|
language: user.preferredLanguage || 'zh-CN',
|
||||||
|
currency: user.preferredCurrency || 'CNY',
|
||||||
|
notificationPrefs: user.notificationPrefs || { trade: true, expiry: true, marketing: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSettings(userId: string, data: { language?: string; currency?: string; notificationPrefs?: { trade?: boolean; expiry?: boolean; marketing?: boolean } }) {
|
||||||
|
const user = await this.userRepo.findById(userId);
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
|
||||||
|
const updateData: Record<string, any> = {};
|
||||||
|
if (data.language !== undefined) updateData.preferredLanguage = data.language;
|
||||||
|
if (data.currency !== undefined) updateData.preferredCurrency = data.currency;
|
||||||
|
if (data.notificationPrefs !== undefined) {
|
||||||
|
// Merge with existing prefs so partial updates are supported
|
||||||
|
const existing = user.notificationPrefs || { trade: true, expiry: true, marketing: false };
|
||||||
|
updateData.notificationPrefs = {
|
||||||
|
trade: data.notificationPrefs.trade !== undefined ? data.notificationPrefs.trade : existing.trade,
|
||||||
|
expiry: data.notificationPrefs.expiry !== undefined ? data.notificationPrefs.expiry : existing.expiry,
|
||||||
|
marketing: data.notificationPrefs.marketing !== undefined ? data.notificationPrefs.marketing : existing.marketing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userRepo.updateProfile(userId, updateData);
|
||||||
|
|
||||||
|
return this.getSettings(userId);
|
||||||
|
}
|
||||||
|
|
||||||
async listUsers(page: number, limit: number) {
|
async listUsers(page: number, limit: number) {
|
||||||
const [users, total] = await this.userRepo.findAll(page, limit);
|
const [users, total] = await this.userRepo.findAll(page, limit);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ export class User {
|
||||||
@Index('idx_users_status') @Column({ type: 'varchar', length: 20, default: 'active' }) status: UserStatus;
|
@Index('idx_users_status') @Column({ type: 'varchar', length: 20, default: 'active' }) status: UserStatus;
|
||||||
@Column({ name: 'residence_state', type: 'varchar', length: 5, nullable: true }) residenceState: string | null;
|
@Column({ name: 'residence_state', type: 'varchar', length: 5, nullable: true }) residenceState: string | null;
|
||||||
@Column({ type: 'varchar', length: 5, nullable: true }) nationality: string | null;
|
@Column({ type: 'varchar', length: 5, nullable: true }) nationality: string | null;
|
||||||
|
@Column({ name: 'preferred_language', type: 'varchar', length: 10, nullable: true, default: null }) preferredLanguage: string | null;
|
||||||
|
@Column({ name: 'preferred_currency', type: 'varchar', length: 10, nullable: true, default: null }) preferredCurrency: string | null;
|
||||||
|
@Column({ name: 'notification_prefs', type: 'jsonb', nullable: true, default: null }) notificationPrefs: { trade: boolean; expiry: boolean; marketing: boolean } | null;
|
||||||
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true }) lastLoginAt: Date | null;
|
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true }) lastLoginAt: Date | null;
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date;
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date;
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date;
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { UserProfileService } from '../../../application/services/user-profile.service';
|
import { UserProfileService } from '../../../application/services/user-profile.service';
|
||||||
import { UpdateProfileDto } from '../dto/update-profile.dto';
|
import { UpdateProfileDto } from '../dto/update-profile.dto';
|
||||||
|
import { UpdateSettingsDto } from '../dto/update-settings.dto';
|
||||||
|
|
||||||
@ApiTags('Users')
|
@ApiTags('Users')
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
|
|
@ -27,6 +28,24 @@ export class UserController {
|
||||||
return { code: 0, data: profile };
|
return { code: 0, data: profile };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('me/settings')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get current user settings' })
|
||||||
|
async getMySettings(@Req() req: any) {
|
||||||
|
const settings = await this.profileService.getSettings(req.user.id);
|
||||||
|
return { code: 0, data: settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('me/settings')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Update current user settings (language, currency, notification preferences)' })
|
||||||
|
async updateMySettings(@Req() req: any, @Body() dto: UpdateSettingsDto) {
|
||||||
|
const settings = await this.profileService.updateSettings(req.user.id, dto);
|
||||||
|
return { code: 0, data: settings };
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { IsOptional, IsString, MaxLength, IsObject, ValidateNested, IsBoolean } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class NotificationPrefsDto {
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsBoolean() trade?: boolean;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsBoolean() expiry?: boolean;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsBoolean() marketing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateSettingsDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Preferred language code', example: 'zh-CN' })
|
||||||
|
@IsOptional() @IsString() @MaxLength(10)
|
||||||
|
language?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Preferred currency code', example: 'CNY' })
|
||||||
|
@IsOptional() @IsString() @MaxLength(10)
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Notification preferences' })
|
||||||
|
@IsOptional() @IsObject() @ValidateNested() @Type(() => NotificationPrefsDto)
|
||||||
|
notificationPrefs?: NotificationPrefsDto;
|
||||||
|
}
|
||||||
|
|
@ -178,6 +178,7 @@ class AppLocalizations {
|
||||||
// ── Coupon List ──
|
// ── Coupon List ──
|
||||||
'coupon_list_title': '券管理',
|
'coupon_list_title': '券管理',
|
||||||
'coupon_list_fab': '发券',
|
'coupon_list_fab': '发券',
|
||||||
|
'coupon_list_empty': '暂无券数据',
|
||||||
'coupon_list_ai_suggestion': '建议:周末发行餐饮券销量通常提升30%',
|
'coupon_list_ai_suggestion': '建议:周末发行餐饮券销量通常提升30%',
|
||||||
'coupon_filter_all': '全部',
|
'coupon_filter_all': '全部',
|
||||||
'coupon_filter_on_sale': '在售中',
|
'coupon_filter_on_sale': '在售中',
|
||||||
|
|
@ -241,6 +242,7 @@ class AppLocalizations {
|
||||||
'create_coupon_review_notice': '提交后将自动进入平台审核,审核通过后券将自动上架销售',
|
'create_coupon_review_notice': '提交后将自动进入平台审核,审核通过后券将自动上架销售',
|
||||||
'create_coupon_submit_success': '提交成功',
|
'create_coupon_submit_success': '提交成功',
|
||||||
'create_coupon_submit_desc': '您的券已提交审核,预计1-2个工作日内完成。',
|
'create_coupon_submit_desc': '您的券已提交审核,预计1-2个工作日内完成。',
|
||||||
|
'create_coupon_submit_error': '提交失败',
|
||||||
'create_coupon_ok': '确定',
|
'create_coupon_ok': '确定',
|
||||||
'create_coupon_day_unit': ' 天',
|
'create_coupon_day_unit': ' 天',
|
||||||
'create_coupon_ai_price_suggestion': 'AI建议:同类券发行价通常为面值的85%,建议定价 \$21.25',
|
'create_coupon_ai_price_suggestion': 'AI建议:同类券发行价通常为面值的85%,建议定价 \$21.25',
|
||||||
|
|
@ -737,6 +739,7 @@ class AppLocalizations {
|
||||||
// ── Coupon List ──
|
// ── Coupon List ──
|
||||||
'coupon_list_title': 'Coupon Management',
|
'coupon_list_title': 'Coupon Management',
|
||||||
'coupon_list_fab': 'Issue',
|
'coupon_list_fab': 'Issue',
|
||||||
|
'coupon_list_empty': 'No coupons found',
|
||||||
'coupon_list_ai_suggestion': 'Tip: Weekend dining coupons typically boost sales 30%',
|
'coupon_list_ai_suggestion': 'Tip: Weekend dining coupons typically boost sales 30%',
|
||||||
'coupon_filter_all': 'All',
|
'coupon_filter_all': 'All',
|
||||||
'coupon_filter_on_sale': 'On Sale',
|
'coupon_filter_on_sale': 'On Sale',
|
||||||
|
|
@ -800,6 +803,7 @@ class AppLocalizations {
|
||||||
'create_coupon_review_notice': 'After submission, the coupon enters platform review and is listed automatically upon approval.',
|
'create_coupon_review_notice': 'After submission, the coupon enters platform review and is listed automatically upon approval.',
|
||||||
'create_coupon_submit_success': 'Submitted',
|
'create_coupon_submit_success': 'Submitted',
|
||||||
'create_coupon_submit_desc': 'Your coupon is under review. Expected 1-2 business days.',
|
'create_coupon_submit_desc': 'Your coupon is under review. Expected 1-2 business days.',
|
||||||
|
'create_coupon_submit_error': 'Submission failed',
|
||||||
'create_coupon_ok': 'OK',
|
'create_coupon_ok': 'OK',
|
||||||
'create_coupon_day_unit': ' days',
|
'create_coupon_day_unit': ' days',
|
||||||
'create_coupon_ai_price_suggestion': 'AI Tip: Similar coupons are typically priced at 85% of face value. Suggested price: \$21.25',
|
'create_coupon_ai_price_suggestion': 'AI Tip: Similar coupons are typically priced at 85% of face value. Suggested price: \$21.25',
|
||||||
|
|
@ -1296,6 +1300,7 @@ class AppLocalizations {
|
||||||
// ── Coupon List ──
|
// ── Coupon List ──
|
||||||
'coupon_list_title': 'クーポン管理',
|
'coupon_list_title': 'クーポン管理',
|
||||||
'coupon_list_fab': '発行',
|
'coupon_list_fab': '発行',
|
||||||
|
'coupon_list_empty': 'クーポンがありません',
|
||||||
'coupon_list_ai_suggestion': '提案:週末の飲食クーポンは通常30%売上向上',
|
'coupon_list_ai_suggestion': '提案:週末の飲食クーポンは通常30%売上向上',
|
||||||
'coupon_filter_all': 'すべて',
|
'coupon_filter_all': 'すべて',
|
||||||
'coupon_filter_on_sale': '販売中',
|
'coupon_filter_on_sale': '販売中',
|
||||||
|
|
@ -1359,6 +1364,7 @@ class AppLocalizations {
|
||||||
'create_coupon_review_notice': '提出後、プラットフォーム審査を経て自動的に販売開始されます。',
|
'create_coupon_review_notice': '提出後、プラットフォーム審査を経て自動的に販売開始されます。',
|
||||||
'create_coupon_submit_success': '提出完了',
|
'create_coupon_submit_success': '提出完了',
|
||||||
'create_coupon_submit_desc': 'クーポンは審査中です。1-2営業日で完了予定。',
|
'create_coupon_submit_desc': 'クーポンは審査中です。1-2営業日で完了予定。',
|
||||||
|
'create_coupon_submit_error': '提出に失敗しました',
|
||||||
'create_coupon_ok': 'OK',
|
'create_coupon_ok': 'OK',
|
||||||
'create_coupon_day_unit': ' 日',
|
'create_coupon_day_unit': ' 日',
|
||||||
'create_coupon_ai_price_suggestion': 'AIアドバイス:同種のクーポンは額面の85%が一般的です。推奨価格:\$21.25',
|
'create_coupon_ai_price_suggestion': 'AIアドバイス:同種のクーポンは額面の85%が一般的です。推奨価格:\$21.25',
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,8 @@ class AppRouter {
|
||||||
case createCoupon:
|
case createCoupon:
|
||||||
return MaterialPageRoute(builder: (_) => const CreateCouponPage());
|
return MaterialPageRoute(builder: (_) => const CreateCouponPage());
|
||||||
case couponDetail:
|
case couponDetail:
|
||||||
return MaterialPageRoute(builder: (_) => const IssuerCouponDetailPage());
|
final couponId = routeSettings.arguments as String?;
|
||||||
|
return MaterialPageRoute(builder: (_) => IssuerCouponDetailPage(couponId: couponId));
|
||||||
case redemption:
|
case redemption:
|
||||||
return MaterialPageRoute(builder: (_) => const RedemptionPage());
|
return MaterialPageRoute(builder: (_) => const RedemptionPage());
|
||||||
case finance:
|
case finance:
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ class PushService {
|
||||||
Future<void> _registerToken(String token) async {
|
Future<void> _registerToken(String token) async {
|
||||||
try {
|
try {
|
||||||
final platform = Platform.isIOS ? 'IOS' : 'ANDROID';
|
final platform = Platform.isIOS ? 'IOS' : 'ANDROID';
|
||||||
await ApiClient.instance.post('/device-tokens', data: {
|
await ApiClient.instance.post('/api/v1/device-tokens', data: {
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
'channel': 'FCM',
|
'channel': 'FCM',
|
||||||
'token': token,
|
'token': token,
|
||||||
|
|
@ -78,7 +78,7 @@ class PushService {
|
||||||
Future<void> unregisterToken() async {
|
Future<void> unregisterToken() async {
|
||||||
if (_fcmToken == null) return;
|
if (_fcmToken == null) return;
|
||||||
try {
|
try {
|
||||||
await ApiClient.instance.delete('/device-tokens', data: {
|
await ApiClient.instance.delete('/api/v1/device-tokens', data: {
|
||||||
'token': _fcmToken,
|
'token': _fcmToken,
|
||||||
});
|
});
|
||||||
debugPrint('[PushService] Token 已注销');
|
debugPrint('[PushService] Token 已注销');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../network/api_client.dart';
|
||||||
|
|
||||||
|
/// AI 聊天服务
|
||||||
|
class AiChatService {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
AiChatService({ApiClient? apiClient})
|
||||||
|
: _apiClient = apiClient ?? ApiClient.instance;
|
||||||
|
|
||||||
|
/// 发送消息并获取AI回复
|
||||||
|
Future<AiChatResponse> sendMessage(String message, {String? context}) async {
|
||||||
|
try {
|
||||||
|
final body = <String, dynamic>{'message': message};
|
||||||
|
if (context != null) body['context'] = context;
|
||||||
|
|
||||||
|
final response = await _apiClient.post('/api/v1/ai/chat', data: body);
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return AiChatResponse.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[AiChatService] sendMessage 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取AI定价建议
|
||||||
|
Future<Map<String, dynamic>> getPricingSuggestion({
|
||||||
|
required double faceValue,
|
||||||
|
String? category,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.post('/api/v1/ai/pricing/suggest', data: {
|
||||||
|
'faceValue': faceValue,
|
||||||
|
if (category != null) 'category': category,
|
||||||
|
});
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
return (data['data'] ?? data) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[AiChatService] getPricingSuggestion 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AiChatResponse {
|
||||||
|
final String message;
|
||||||
|
final String? intent;
|
||||||
|
final Map<String, dynamic>? metadata;
|
||||||
|
|
||||||
|
const AiChatResponse({
|
||||||
|
required this.message,
|
||||||
|
this.intent,
|
||||||
|
this.metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AiChatResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AiChatResponse(
|
||||||
|
message: json['message'] ?? json['reply'] ?? '',
|
||||||
|
intent: json['intent'],
|
||||||
|
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../network/api_client.dart';
|
||||||
|
|
||||||
|
/// 认证服务
|
||||||
|
class AuthService {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
AuthService({ApiClient? apiClient})
|
||||||
|
: _apiClient = apiClient ?? ApiClient.instance;
|
||||||
|
|
||||||
|
/// 发送短信验证码
|
||||||
|
Future<bool> sendSmsCode(String phone) async {
|
||||||
|
try {
|
||||||
|
await _apiClient.post('/api/v1/auth/send-sms-code', data: {
|
||||||
|
'phone': phone,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[AuthService] sendSmsCode 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手机号+验证码登录
|
||||||
|
Future<LoginResult> loginByPhone(String phone, String smsCode) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.post('/api/v1/auth/login-phone', data: {
|
||||||
|
'phone': phone,
|
||||||
|
'smsCode': smsCode,
|
||||||
|
});
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return LoginResult.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[AuthService] loginByPhone 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 密码登录
|
||||||
|
Future<LoginResult> loginByPassword(String email, String password) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.post('/api/v1/auth/login', data: {
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
});
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return LoginResult.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[AuthService] loginByPassword 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 刷新 Token
|
||||||
|
Future<LoginResult> refreshToken(String refreshToken) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.post('/api/v1/auth/refresh', data: {
|
||||||
|
'refreshToken': refreshToken,
|
||||||
|
});
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return LoginResult.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[AuthService] refreshToken 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登出
|
||||||
|
Future<void> logout() async {
|
||||||
|
try {
|
||||||
|
await _apiClient.post('/api/v1/auth/logout');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[AuthService] logout 失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoginResult {
|
||||||
|
final String accessToken;
|
||||||
|
final String? refreshToken;
|
||||||
|
final Map<String, dynamic>? user;
|
||||||
|
|
||||||
|
const LoginResult({
|
||||||
|
required this.accessToken,
|
||||||
|
this.refreshToken,
|
||||||
|
this.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LoginResult.fromJson(Map<String, dynamic> json) {
|
||||||
|
return LoginResult(
|
||||||
|
accessToken: json['accessToken'] ?? '',
|
||||||
|
refreshToken: json['refreshToken'],
|
||||||
|
user: json['user'] as Map<String, dynamic>?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../network/api_client.dart';
|
||||||
|
|
||||||
|
/// 发行商分析服务(用户画像等)
|
||||||
|
class IssuerAnalyticsService {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
IssuerAnalyticsService({ApiClient? apiClient})
|
||||||
|
: _apiClient = apiClient ?? ApiClient.instance;
|
||||||
|
|
||||||
|
/// 获取用户概览
|
||||||
|
Future<Map<String, dynamic>> getUserOverview() async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get('/api/v1/issuers/me/analytics/users');
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
return (data['data'] ?? data) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerAnalyticsService] getUserOverview 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取人口统计
|
||||||
|
Future<Map<String, dynamic>> getDemographics() async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get('/api/v1/issuers/me/analytics/demographics');
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
return (data['data'] ?? data) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerAnalyticsService] getDemographics 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取复购分析
|
||||||
|
Future<Map<String, dynamic>> getRepurchaseAnalysis() async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get('/api/v1/issuers/me/analytics/repurchase');
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
return (data['data'] ?? data) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerAnalyticsService] getRepurchaseAnalysis 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取AI洞察
|
||||||
|
Future<List<String>> getAiInsights() async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get('/api/v1/issuers/me/analytics/ai-insights');
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
if (inner is List) return inner.map((e) => e.toString()).toList();
|
||||||
|
if (inner is Map && inner['insights'] is List) {
|
||||||
|
return (inner['insights'] as List).map((e) => e.toString()).toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerAnalyticsService] getAiInsights 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../network/api_client.dart';
|
||||||
|
|
||||||
|
/// 发行商券管理服务
|
||||||
|
class IssuerCouponService {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
IssuerCouponService({ApiClient? apiClient})
|
||||||
|
: _apiClient = apiClient ?? ApiClient.instance;
|
||||||
|
|
||||||
|
/// 获取发行商的券列表
|
||||||
|
Future<({List<IssuerCouponModel> items, int total})> list({
|
||||||
|
int page = 1,
|
||||||
|
int limit = 20,
|
||||||
|
String? status,
|
||||||
|
String? search,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final params = <String, dynamic>{'page': page, 'limit': limit};
|
||||||
|
if (status != null) params['status'] = status;
|
||||||
|
if (search != null) params['search'] = search;
|
||||||
|
|
||||||
|
final response = await _apiClient.get(
|
||||||
|
'/api/v1/coupons',
|
||||||
|
queryParameters: params,
|
||||||
|
);
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
final items = (inner['items'] as List?)
|
||||||
|
?.map((e) => IssuerCouponModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
return (items: items, total: inner['total'] ?? items.length);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerCouponService] list 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建券
|
||||||
|
Future<IssuerCouponModel> create(Map<String, dynamic> body) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.post('/api/v1/coupons', data: body);
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return IssuerCouponModel.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerCouponService] create 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取券详情
|
||||||
|
Future<IssuerCouponModel> getDetail(String id) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get('/api/v1/coupons/$id');
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return IssuerCouponModel.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerCouponService] getDetail 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新券状态
|
||||||
|
Future<void> updateStatus(String id, String status) async {
|
||||||
|
try {
|
||||||
|
await _apiClient.put('/api/v1/coupons/$id/status', data: {
|
||||||
|
'status': status,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerCouponService] updateStatus 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量发行
|
||||||
|
Future<void> batchIssue({
|
||||||
|
required String templateId,
|
||||||
|
required int quantity,
|
||||||
|
required String expiryDate,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await _apiClient.post('/api/v1/coupons/batch/issue', data: {
|
||||||
|
'templateId': templateId,
|
||||||
|
'quantity': quantity,
|
||||||
|
'expiryDate': expiryDate,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerCouponService] batchIssue 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量召回
|
||||||
|
Future<void> batchRecall({
|
||||||
|
required List<String> couponIds,
|
||||||
|
required String reason,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await _apiClient.post('/api/v1/coupons/batch/recall', data: {
|
||||||
|
'couponIds': couponIds,
|
||||||
|
'reason': reason,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerCouponService] batchRecall 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量调价
|
||||||
|
Future<void> batchPriceAdjust({
|
||||||
|
required List<String> couponIds,
|
||||||
|
required double adjustPercent,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await _apiClient.post('/api/v1/coupons/batch/price-adjust', data: {
|
||||||
|
'couponIds': couponIds,
|
||||||
|
'adjustPercent': adjustPercent,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerCouponService] batchPriceAdjust 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IssuerCouponModel {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String? description;
|
||||||
|
final String? imageUrl;
|
||||||
|
final double faceValue;
|
||||||
|
final double currentPrice;
|
||||||
|
final int totalSupply;
|
||||||
|
final int remainingSupply;
|
||||||
|
final String category;
|
||||||
|
final String status;
|
||||||
|
final String couponType;
|
||||||
|
final DateTime expiryDate;
|
||||||
|
final bool isTransferable;
|
||||||
|
final String? brandName;
|
||||||
|
|
||||||
|
const IssuerCouponModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.description,
|
||||||
|
this.imageUrl,
|
||||||
|
required this.faceValue,
|
||||||
|
required this.currentPrice,
|
||||||
|
required this.totalSupply,
|
||||||
|
required this.remainingSupply,
|
||||||
|
required this.category,
|
||||||
|
required this.status,
|
||||||
|
required this.couponType,
|
||||||
|
required this.expiryDate,
|
||||||
|
required this.isTransferable,
|
||||||
|
this.brandName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory IssuerCouponModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return IssuerCouponModel(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
description: json['description'],
|
||||||
|
imageUrl: json['imageUrl'],
|
||||||
|
faceValue: _toDouble(json['faceValue']),
|
||||||
|
currentPrice: _toDouble(json['currentPrice']),
|
||||||
|
totalSupply: json['totalSupply'] ?? 0,
|
||||||
|
remainingSupply: json['remainingSupply'] ?? 0,
|
||||||
|
category: json['category'] ?? '',
|
||||||
|
status: json['status'] ?? '',
|
||||||
|
couponType: json['couponType'] ?? 'utility',
|
||||||
|
expiryDate: DateTime.tryParse(json['expiryDate']?.toString() ?? '') ?? DateTime.now(),
|
||||||
|
isTransferable: json['isTransferable'] ?? true,
|
||||||
|
brandName: json['issuer']?['companyName'] ?? json['brandName'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get soldCount => totalSupply - remainingSupply;
|
||||||
|
|
||||||
|
static double _toDouble(dynamic v) {
|
||||||
|
if (v == null) return 0;
|
||||||
|
if (v is num) return v.toDouble();
|
||||||
|
return double.tryParse(v.toString()) ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../network/api_client.dart';
|
||||||
|
|
||||||
|
/// 发行商信用评分服务
|
||||||
|
class IssuerCreditService {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
IssuerCreditService({ApiClient? apiClient})
|
||||||
|
: _apiClient = apiClient ?? ApiClient.instance;
|
||||||
|
|
||||||
|
/// 获取信用评分详情
|
||||||
|
Future<CreditModel> getCredit() async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get('/api/v1/issuers/me/credit');
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return CreditModel.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerCreditService] getCredit 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreditModel {
|
||||||
|
final String grade;
|
||||||
|
final int score;
|
||||||
|
final List<CreditFactor> factors;
|
||||||
|
final List<String> tiers;
|
||||||
|
final int currentTierIndex;
|
||||||
|
final List<String> suggestions;
|
||||||
|
|
||||||
|
const CreditModel({
|
||||||
|
required this.grade,
|
||||||
|
required this.score,
|
||||||
|
required this.factors,
|
||||||
|
required this.tiers,
|
||||||
|
required this.currentTierIndex,
|
||||||
|
required this.suggestions,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CreditModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CreditModel(
|
||||||
|
grade: json['grade'] ?? 'N/A',
|
||||||
|
score: json['score'] ?? 0,
|
||||||
|
factors: (json['factors'] as List?)
|
||||||
|
?.map((e) => CreditFactor.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
tiers: (json['tiers'] as List?)?.map((e) => e.toString()).toList() ??
|
||||||
|
['Bronze', 'Silver', 'Gold', 'Platinum', 'Diamond'],
|
||||||
|
currentTierIndex: json['currentTierIndex'] ?? 0,
|
||||||
|
suggestions: (json['suggestions'] as List?)?.map((e) => e.toString()).toList() ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreditFactor {
|
||||||
|
final String name;
|
||||||
|
final double score;
|
||||||
|
final double weight;
|
||||||
|
|
||||||
|
const CreditFactor({
|
||||||
|
required this.name,
|
||||||
|
required this.score,
|
||||||
|
required this.weight,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CreditFactor.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CreditFactor(
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
score: (json['score'] is num) ? (json['score'] as num).toDouble() : 0,
|
||||||
|
weight: (json['weight'] is num) ? (json['weight'] as num).toDouble() : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../network/api_client.dart';
|
||||||
|
|
||||||
|
/// 发行商财务服务
|
||||||
|
class IssuerFinanceService {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
IssuerFinanceService({ApiClient? apiClient})
|
||||||
|
: _apiClient = apiClient ?? ApiClient.instance;
|
||||||
|
|
||||||
|
/// 获取可提现余额
|
||||||
|
Future<FinanceBalance> getBalance() async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get('/api/v1/issuers/me/finance/balance');
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return FinanceBalance.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerFinanceService] getBalance 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取财务概览
|
||||||
|
Future<FinanceStats> getStats() async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get('/api/v1/issuers/me/finance/stats');
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return FinanceStats.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerFinanceService] getStats 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取交易流水
|
||||||
|
Future<({List<TransactionModel> items, int total})> getTransactions({
|
||||||
|
int page = 1,
|
||||||
|
int limit = 20,
|
||||||
|
String? type,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final params = <String, dynamic>{'page': page, 'limit': limit};
|
||||||
|
if (type != null) params['type'] = type;
|
||||||
|
|
||||||
|
final response = await _apiClient.get(
|
||||||
|
'/api/v1/issuers/me/finance/transactions',
|
||||||
|
queryParameters: params,
|
||||||
|
);
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
final items = (inner['items'] as List?)
|
||||||
|
?.map((e) => TransactionModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
return (items: items, total: inner['total'] ?? items.length);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerFinanceService] getTransactions 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发起提现
|
||||||
|
Future<void> withdraw(double amount) async {
|
||||||
|
try {
|
||||||
|
await _apiClient.post('/api/v1/issuers/me/finance/withdraw', data: {
|
||||||
|
'amount': amount,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerFinanceService] withdraw 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取对账数据
|
||||||
|
Future<Map<String, dynamic>> getReconciliation({String period = 'month'}) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get(
|
||||||
|
'/api/v1/issuers/me/finance/reconciliation',
|
||||||
|
queryParameters: {'period': period},
|
||||||
|
);
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
return (data['data'] ?? data) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerFinanceService] getReconciliation 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FinanceBalance {
|
||||||
|
final double withdrawable;
|
||||||
|
final double pending;
|
||||||
|
final double total;
|
||||||
|
|
||||||
|
const FinanceBalance({
|
||||||
|
required this.withdrawable,
|
||||||
|
required this.pending,
|
||||||
|
required this.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FinanceBalance.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FinanceBalance(
|
||||||
|
withdrawable: _toDouble(json['withdrawable']),
|
||||||
|
pending: _toDouble(json['pending']),
|
||||||
|
total: _toDouble(json['total']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _toDouble(dynamic v) {
|
||||||
|
if (v == null) return 0;
|
||||||
|
if (v is num) return v.toDouble();
|
||||||
|
return double.tryParse(v.toString()) ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FinanceStats {
|
||||||
|
final double salesAmount;
|
||||||
|
final double breakageIncome;
|
||||||
|
final double platformFee;
|
||||||
|
final double pendingSettlement;
|
||||||
|
final double withdrawnAmount;
|
||||||
|
final double totalRevenue;
|
||||||
|
|
||||||
|
const FinanceStats({
|
||||||
|
required this.salesAmount,
|
||||||
|
required this.breakageIncome,
|
||||||
|
required this.platformFee,
|
||||||
|
required this.pendingSettlement,
|
||||||
|
required this.withdrawnAmount,
|
||||||
|
required this.totalRevenue,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FinanceStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FinanceStats(
|
||||||
|
salesAmount: _toDouble(json['salesAmount']),
|
||||||
|
breakageIncome: _toDouble(json['breakageIncome']),
|
||||||
|
platformFee: _toDouble(json['platformFee']),
|
||||||
|
pendingSettlement: _toDouble(json['pendingSettlement']),
|
||||||
|
withdrawnAmount: _toDouble(json['withdrawnAmount']),
|
||||||
|
totalRevenue: _toDouble(json['totalRevenue']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _toDouble(dynamic v) {
|
||||||
|
if (v == null) return 0;
|
||||||
|
if (v is num) return v.toDouble();
|
||||||
|
return double.tryParse(v.toString()) ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransactionModel {
|
||||||
|
final String id;
|
||||||
|
final String type;
|
||||||
|
final double amount;
|
||||||
|
final String description;
|
||||||
|
final String status;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const TransactionModel({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
required this.amount,
|
||||||
|
required this.description,
|
||||||
|
required this.status,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TransactionModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return TransactionModel(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
type: json['type'] ?? '',
|
||||||
|
amount: _toDouble(json['amount']),
|
||||||
|
description: json['description'] ?? '',
|
||||||
|
status: json['status'] ?? '',
|
||||||
|
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? '') ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _toDouble(dynamic v) {
|
||||||
|
if (v == null) return 0;
|
||||||
|
if (v is num) return v.toDouble();
|
||||||
|
return double.tryParse(v.toString()) ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../network/api_client.dart';
|
||||||
|
|
||||||
|
/// 发行商概况服务
|
||||||
|
class IssuerService {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
IssuerService({ApiClient? apiClient})
|
||||||
|
: _apiClient = apiClient ?? ApiClient.instance;
|
||||||
|
|
||||||
|
/// 获取发行商资料
|
||||||
|
Future<IssuerProfile> getProfile() async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get('/api/v1/issuers/me');
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return IssuerProfile.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerService] getProfile 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取仪表盘统计
|
||||||
|
Future<IssuerStats> getStats() async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get('/api/v1/issuers/me/stats');
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return IssuerStats.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerService] getStats 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新发行商资料
|
||||||
|
Future<void> updateProfile(Map<String, dynamic> updates) async {
|
||||||
|
try {
|
||||||
|
await _apiClient.put('/api/v1/issuers/me', data: updates);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerService] updateProfile 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IssuerProfile {
|
||||||
|
final String id;
|
||||||
|
final String companyName;
|
||||||
|
final String? logoUrl;
|
||||||
|
final String? creditRating;
|
||||||
|
final String status;
|
||||||
|
final String? contactEmail;
|
||||||
|
final String? contactPhone;
|
||||||
|
|
||||||
|
const IssuerProfile({
|
||||||
|
required this.id,
|
||||||
|
required this.companyName,
|
||||||
|
this.logoUrl,
|
||||||
|
this.creditRating,
|
||||||
|
required this.status,
|
||||||
|
this.contactEmail,
|
||||||
|
this.contactPhone,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory IssuerProfile.fromJson(Map<String, dynamic> json) {
|
||||||
|
return IssuerProfile(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
companyName: json['companyName'] ?? '',
|
||||||
|
logoUrl: json['logoUrl'],
|
||||||
|
creditRating: json['creditRating'],
|
||||||
|
status: json['status'] ?? '',
|
||||||
|
contactEmail: json['contactEmail'],
|
||||||
|
contactPhone: json['contactPhone'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IssuerStats {
|
||||||
|
final int issuedCount;
|
||||||
|
final double redemptionRate;
|
||||||
|
final double totalRevenue;
|
||||||
|
final String creditGrade;
|
||||||
|
final int creditScore;
|
||||||
|
final double quotaUsed;
|
||||||
|
final double quotaTotal;
|
||||||
|
|
||||||
|
const IssuerStats({
|
||||||
|
required this.issuedCount,
|
||||||
|
required this.redemptionRate,
|
||||||
|
required this.totalRevenue,
|
||||||
|
required this.creditGrade,
|
||||||
|
required this.creditScore,
|
||||||
|
required this.quotaUsed,
|
||||||
|
required this.quotaTotal,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory IssuerStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return IssuerStats(
|
||||||
|
issuedCount: json['issuedCount'] ?? 0,
|
||||||
|
redemptionRate: _toDouble(json['redemptionRate']),
|
||||||
|
totalRevenue: _toDouble(json['totalRevenue']),
|
||||||
|
creditGrade: json['creditGrade'] ?? 'N/A',
|
||||||
|
creditScore: json['creditScore'] ?? 0,
|
||||||
|
quotaUsed: _toDouble(json['quotaUsed']),
|
||||||
|
quotaTotal: _toDouble(json['quotaTotal']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _toDouble(dynamic v) {
|
||||||
|
if (v == null) return 0;
|
||||||
|
if (v is num) return v.toDouble();
|
||||||
|
return double.tryParse(v.toString()) ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../network/api_client.dart';
|
||||||
|
|
||||||
|
/// 门店和员工管理服务
|
||||||
|
class IssuerStoreService {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
IssuerStoreService({ApiClient? apiClient})
|
||||||
|
: _apiClient = apiClient ?? ApiClient.instance;
|
||||||
|
|
||||||
|
// ==================== 门店 ====================
|
||||||
|
|
||||||
|
/// 获取门店列表
|
||||||
|
Future<List<StoreModel>> listStores({
|
||||||
|
String? level,
|
||||||
|
String? parentId,
|
||||||
|
String? status,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final params = <String, dynamic>{};
|
||||||
|
if (level != null) params['level'] = level;
|
||||||
|
if (parentId != null) params['parentId'] = parentId;
|
||||||
|
if (status != null) params['status'] = status;
|
||||||
|
|
||||||
|
final response = await _apiClient.get(
|
||||||
|
'/api/v1/issuers/me/stores',
|
||||||
|
queryParameters: params,
|
||||||
|
);
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
final list = inner is List ? inner : (inner['items'] ?? []);
|
||||||
|
return (list as List)
|
||||||
|
.map((e) => StoreModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerStoreService] listStores 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建门店
|
||||||
|
Future<StoreModel> createStore(Map<String, dynamic> body) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.post('/api/v1/issuers/me/stores', data: body);
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return StoreModel.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerStoreService] createStore 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新门店
|
||||||
|
Future<void> updateStore(String id, Map<String, dynamic> body) async {
|
||||||
|
try {
|
||||||
|
await _apiClient.put('/api/v1/issuers/me/stores/$id', data: body);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerStoreService] updateStore 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除门店
|
||||||
|
Future<void> deleteStore(String id) async {
|
||||||
|
try {
|
||||||
|
await _apiClient.delete('/api/v1/issuers/me/stores/$id');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerStoreService] deleteStore 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 员工 ====================
|
||||||
|
|
||||||
|
/// 获取员工列表
|
||||||
|
Future<List<EmployeeModel>> listEmployees({
|
||||||
|
String? storeId,
|
||||||
|
String? role,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final params = <String, dynamic>{};
|
||||||
|
if (storeId != null) params['storeId'] = storeId;
|
||||||
|
if (role != null) params['role'] = role;
|
||||||
|
|
||||||
|
final response = await _apiClient.get(
|
||||||
|
'/api/v1/issuers/me/employees',
|
||||||
|
queryParameters: params,
|
||||||
|
);
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
final list = inner is List ? inner : (inner['items'] ?? []);
|
||||||
|
return (list as List)
|
||||||
|
.map((e) => EmployeeModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerStoreService] listEmployees 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建员工
|
||||||
|
Future<EmployeeModel> createEmployee(Map<String, dynamic> body) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.post('/api/v1/issuers/me/employees', data: body);
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return EmployeeModel.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerStoreService] createEmployee 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新员工
|
||||||
|
Future<void> updateEmployee(String id, Map<String, dynamic> body) async {
|
||||||
|
try {
|
||||||
|
await _apiClient.put('/api/v1/issuers/me/employees/$id', data: body);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerStoreService] updateEmployee 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除员工
|
||||||
|
Future<void> deleteEmployee(String id) async {
|
||||||
|
try {
|
||||||
|
await _apiClient.delete('/api/v1/issuers/me/employees/$id');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerStoreService] deleteEmployee 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StoreModel {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String? address;
|
||||||
|
final String? phone;
|
||||||
|
final String level;
|
||||||
|
final String? parentId;
|
||||||
|
final String status;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const StoreModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.address,
|
||||||
|
this.phone,
|
||||||
|
required this.level,
|
||||||
|
this.parentId,
|
||||||
|
required this.status,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StoreModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return StoreModel(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
address: json['address'],
|
||||||
|
phone: json['phone'],
|
||||||
|
level: json['level'] ?? 'store',
|
||||||
|
parentId: json['parentId'],
|
||||||
|
status: json['status'] ?? 'active',
|
||||||
|
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? '') ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmployeeModel {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String? phone;
|
||||||
|
final String role;
|
||||||
|
final String? storeId;
|
||||||
|
final String status;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const EmployeeModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.phone,
|
||||||
|
required this.role,
|
||||||
|
this.storeId,
|
||||||
|
required this.status,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EmployeeModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return EmployeeModel(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
phone: json['phone'],
|
||||||
|
role: json['role'] ?? 'staff',
|
||||||
|
storeId: json['storeId'],
|
||||||
|
status: json['status'] ?? 'active',
|
||||||
|
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? '') ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -144,7 +144,7 @@ class NotificationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
final response = await _apiClient.get(
|
final response = await _apiClient.get(
|
||||||
'/notifications',
|
'/api/v1/notifications',
|
||||||
queryParameters: queryParams,
|
queryParameters: queryParams,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -160,9 +160,9 @@ class NotificationService {
|
||||||
/// 获取未读通知数量
|
/// 获取未读通知数量
|
||||||
Future<int> getUnreadCount() async {
|
Future<int> getUnreadCount() async {
|
||||||
try {
|
try {
|
||||||
final response = await _apiClient.get('/notifications/unread-count');
|
final response = await _apiClient.get('/api/v1/notifications/unread-count');
|
||||||
final data = response.data is Map<String, dynamic> ? response.data : {};
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0;
|
return data['data']?['count'] ?? data['count'] ?? 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[NotificationService] 获取未读数量失败: $e');
|
debugPrint('[NotificationService] 获取未读数量失败: $e');
|
||||||
return 0;
|
return 0;
|
||||||
|
|
@ -172,7 +172,7 @@ class NotificationService {
|
||||||
/// 标记通知为已读
|
/// 标记通知为已读
|
||||||
Future<bool> markAsRead(String notificationId) async {
|
Future<bool> markAsRead(String notificationId) async {
|
||||||
try {
|
try {
|
||||||
await _apiClient.put('/notifications/$notificationId/read');
|
await _apiClient.put('/api/v1/notifications/$notificationId/read');
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[NotificationService] 标记已读失败: $e');
|
debugPrint('[NotificationService] 标记已读失败: $e');
|
||||||
|
|
@ -187,7 +187,7 @@ class NotificationService {
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _apiClient.get(
|
final response = await _apiClient.get(
|
||||||
'/announcements',
|
'/api/v1/announcements',
|
||||||
queryParameters: {'limit': limit, 'offset': offset},
|
queryParameters: {'limit': limit, 'offset': offset},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -203,9 +203,9 @@ class NotificationService {
|
||||||
/// 获取公告未读数
|
/// 获取公告未读数
|
||||||
Future<int> getAnnouncementUnreadCount() async {
|
Future<int> getAnnouncementUnreadCount() async {
|
||||||
try {
|
try {
|
||||||
final response = await _apiClient.get('/announcements/unread-count');
|
final response = await _apiClient.get('/api/v1/announcements/unread-count');
|
||||||
final data = response.data is Map<String, dynamic> ? response.data : {};
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0;
|
return data['data']?['count'] ?? data['count'] ?? 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[NotificationService] 获取公告未读数失败: $e');
|
debugPrint('[NotificationService] 获取公告未读数失败: $e');
|
||||||
return 0;
|
return 0;
|
||||||
|
|
@ -215,7 +215,7 @@ class NotificationService {
|
||||||
/// 标记公告已读
|
/// 标记公告已读
|
||||||
Future<bool> markAnnouncementAsRead(String announcementId) async {
|
Future<bool> markAnnouncementAsRead(String announcementId) async {
|
||||||
try {
|
try {
|
||||||
await _apiClient.put('/announcements/$announcementId/read');
|
await _apiClient.put('/api/v1/announcements/$announcementId/read');
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[NotificationService] 标记公告已读失败: $e');
|
debugPrint('[NotificationService] 标记公告已读失败: $e');
|
||||||
|
|
@ -226,7 +226,7 @@ class NotificationService {
|
||||||
/// 全部标记已读
|
/// 全部标记已读
|
||||||
Future<bool> markAllAnnouncementsAsRead() async {
|
Future<bool> markAllAnnouncementsAsRead() async {
|
||||||
try {
|
try {
|
||||||
await _apiClient.put('/announcements/read-all');
|
await _apiClient.put('/api/v1/announcements/read-all');
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[NotificationService] 全部标记已读失败: $e');
|
debugPrint('[NotificationService] 全部标记已读失败: $e');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../network/api_client.dart';
|
||||||
|
|
||||||
|
/// 核销服务
|
||||||
|
class RedemptionService {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
RedemptionService({ApiClient? apiClient})
|
||||||
|
: _apiClient = apiClient ?? ApiClient.instance;
|
||||||
|
|
||||||
|
/// 扫码核销
|
||||||
|
Future<RedemptionResult> scan(String qrCode) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.post('/api/v1/redemptions/scan', data: {
|
||||||
|
'qrCode': qrCode,
|
||||||
|
});
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return RedemptionResult.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[RedemptionService] scan 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手动输入核销
|
||||||
|
Future<RedemptionResult> manual(String couponCode) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.post('/api/v1/redemptions/manual', data: {
|
||||||
|
'couponCode': couponCode,
|
||||||
|
});
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return RedemptionResult.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[RedemptionService] manual 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量核销
|
||||||
|
Future<BatchRedemptionResult> batch(List<String> couponCodes) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.post('/api/v1/redemptions/batch', data: {
|
||||||
|
'couponCodes': couponCodes,
|
||||||
|
});
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return BatchRedemptionResult.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[RedemptionService] batch 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取核销历史
|
||||||
|
Future<({List<RedemptionRecord> items, int total})> getHistory({
|
||||||
|
int page = 1,
|
||||||
|
int limit = 20,
|
||||||
|
String? startDate,
|
||||||
|
String? endDate,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final params = <String, dynamic>{'page': page, 'limit': limit};
|
||||||
|
if (startDate != null) params['startDate'] = startDate;
|
||||||
|
if (endDate != null) params['endDate'] = endDate;
|
||||||
|
|
||||||
|
final response = await _apiClient.get(
|
||||||
|
'/api/v1/redemptions',
|
||||||
|
queryParameters: params,
|
||||||
|
);
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
final items = (inner['items'] as List?)
|
||||||
|
?.map((e) => RedemptionRecord.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
return (items: items, total: inner['total'] ?? items.length);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[RedemptionService] getHistory 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取今日统计
|
||||||
|
Future<TodayRedemptionStats> getTodayStats() async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get('/api/v1/redemptions/today-stats');
|
||||||
|
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||||
|
final inner = data['data'] ?? data;
|
||||||
|
return TodayRedemptionStats.fromJson(inner as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[RedemptionService] getTodayStats 失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RedemptionResult {
|
||||||
|
final String id;
|
||||||
|
final String couponName;
|
||||||
|
final double amount;
|
||||||
|
final String status;
|
||||||
|
|
||||||
|
const RedemptionResult({
|
||||||
|
required this.id,
|
||||||
|
required this.couponName,
|
||||||
|
required this.amount,
|
||||||
|
required this.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory RedemptionResult.fromJson(Map<String, dynamic> json) {
|
||||||
|
return RedemptionResult(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
couponName: json['couponName'] ?? '',
|
||||||
|
amount: _toDouble(json['amount']),
|
||||||
|
status: json['status'] ?? 'completed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _toDouble(dynamic v) {
|
||||||
|
if (v == null) return 0;
|
||||||
|
if (v is num) return v.toDouble();
|
||||||
|
return double.tryParse(v.toString()) ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BatchRedemptionResult {
|
||||||
|
final int successCount;
|
||||||
|
final int failCount;
|
||||||
|
final List<String> failedCodes;
|
||||||
|
|
||||||
|
const BatchRedemptionResult({
|
||||||
|
required this.successCount,
|
||||||
|
required this.failCount,
|
||||||
|
required this.failedCodes,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BatchRedemptionResult.fromJson(Map<String, dynamic> json) {
|
||||||
|
return BatchRedemptionResult(
|
||||||
|
successCount: json['successCount'] ?? 0,
|
||||||
|
failCount: json['failCount'] ?? 0,
|
||||||
|
failedCodes: (json['failedCodes'] as List?)?.map((e) => e.toString()).toList() ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RedemptionRecord {
|
||||||
|
final String id;
|
||||||
|
final String couponName;
|
||||||
|
final double amount;
|
||||||
|
final String method;
|
||||||
|
final String status;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const RedemptionRecord({
|
||||||
|
required this.id,
|
||||||
|
required this.couponName,
|
||||||
|
required this.amount,
|
||||||
|
required this.method,
|
||||||
|
required this.status,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory RedemptionRecord.fromJson(Map<String, dynamic> json) {
|
||||||
|
return RedemptionRecord(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
couponName: json['couponName'] ?? '',
|
||||||
|
amount: _toDouble(json['amount']),
|
||||||
|
method: json['method'] ?? 'scan',
|
||||||
|
status: json['status'] ?? 'completed',
|
||||||
|
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? '') ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _toDouble(dynamic v) {
|
||||||
|
if (v == null) return 0;
|
||||||
|
if (v is num) return v.toDouble();
|
||||||
|
return double.tryParse(v.toString()) ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TodayRedemptionStats {
|
||||||
|
final int count;
|
||||||
|
final double totalAmount;
|
||||||
|
|
||||||
|
const TodayRedemptionStats({
|
||||||
|
required this.count,
|
||||||
|
required this.totalAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TodayRedemptionStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return TodayRedemptionStats(
|
||||||
|
count: json['count'] ?? 0,
|
||||||
|
totalAmount: _toDouble(json['totalAmount']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _toDouble(dynamic v) {
|
||||||
|
if (v == null) return 0;
|
||||||
|
if (v is num) return v.toDouble();
|
||||||
|
return double.tryParse(v.toString()) ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../app/theme/app_colors.dart';
|
import '../../../../app/theme/app_colors.dart';
|
||||||
import '../../../../app/i18n/app_localizations.dart';
|
import '../../../../app/i18n/app_localizations.dart';
|
||||||
|
import '../../../../core/services/ai_chat_service.dart';
|
||||||
|
|
||||||
/// 发行方AI Agent对话页面
|
/// 发行方AI Agent对话页面
|
||||||
///
|
///
|
||||||
|
|
@ -15,9 +16,11 @@ class AiAgentPage extends StatefulWidget {
|
||||||
class _AiAgentPageState extends State<AiAgentPage> {
|
class _AiAgentPageState extends State<AiAgentPage> {
|
||||||
final _messageController = TextEditingController();
|
final _messageController = TextEditingController();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
|
final _aiChatService = AiChatService();
|
||||||
|
|
||||||
final List<_ChatMessage> _messages = [];
|
final List<_ChatMessage> _messages = [];
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
|
bool _isSending = false;
|
||||||
|
|
||||||
final _quickActionKeys = [
|
final _quickActionKeys = [
|
||||||
'ai_agent_action_sales',
|
'ai_agent_action_sales',
|
||||||
|
|
@ -26,6 +29,13 @@ class _AiAgentPageState extends State<AiAgentPage> {
|
||||||
'ai_agent_action_quota',
|
'ai_agent_action_quota',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_messageController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!_initialized) {
|
if (!_initialized) {
|
||||||
|
|
@ -53,8 +63,13 @@ class _AiAgentPageState extends State<AiAgentPage> {
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: _messages.length,
|
itemCount: _messages.length + (_isSending ? 1 : 0),
|
||||||
itemBuilder: (context, index) => _buildMessageBubble(_messages[index]),
|
itemBuilder: (context, index) {
|
||||||
|
if (index == _messages.length && _isSending) {
|
||||||
|
return _buildTypingIndicator();
|
||||||
|
}
|
||||||
|
return _buildMessageBubble(_messages[index]);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
@ -70,7 +85,7 @@ class _AiAgentPageState extends State<AiAgentPage> {
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: ActionChip(
|
child: ActionChip(
|
||||||
label: Text(action, style: const TextStyle(fontSize: 12)),
|
label: Text(action, style: const TextStyle(fontSize: 12)),
|
||||||
onPressed: () => _sendMessage(action),
|
onPressed: _isSending ? null : () => _sendMessage(action),
|
||||||
backgroundColor: AppColors.primarySurface,
|
backgroundColor: AppColors.primarySurface,
|
||||||
side: BorderSide.none,
|
side: BorderSide.none,
|
||||||
),
|
),
|
||||||
|
|
@ -91,6 +106,7 @@ class _AiAgentPageState extends State<AiAgentPage> {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _messageController,
|
controller: _messageController,
|
||||||
|
enabled: !_isSending,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: context.t('ai_agent_input_hint'),
|
hintText: context.t('ai_agent_input_hint'),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
|
|
@ -99,7 +115,7 @@ class _AiAgentPageState extends State<AiAgentPage> {
|
||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
),
|
),
|
||||||
onSubmitted: _sendMessage,
|
onSubmitted: _isSending ? null : _sendMessage,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
@ -109,8 +125,14 @@ class _AiAgentPageState extends State<AiAgentPage> {
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(Icons.send_rounded, color: Colors.white, size: 20),
|
icon: _isSending
|
||||||
onPressed: () => _sendMessage(_messageController.text),
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.send_rounded, color: Colors.white, size: 20),
|
||||||
|
onPressed: _isSending ? null : () => _sendMessage(_messageController.text),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -121,6 +143,47 @@ class _AiAgentPageState extends State<AiAgentPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildTypingIndicator() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: AppColors.primaryGradient,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.auto_awesome_rounded, color: Colors.white, size: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.gray50,
|
||||||
|
borderRadius: BorderRadius.circular(16).copyWith(
|
||||||
|
topLeft: const Radius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 20,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildMessageBubble(_ChatMessage msg) {
|
Widget _buildMessageBubble(_ChatMessage msg) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
|
@ -165,21 +228,47 @@ class _AiAgentPageState extends State<AiAgentPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _sendMessage(String text) {
|
Future<void> _sendMessage(String text) async {
|
||||||
if (text.trim().isEmpty) return;
|
if (text.trim().isEmpty) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_messages.add(_ChatMessage(isAi: false, text: text));
|
_messages.add(_ChatMessage(isAi: false, text: text));
|
||||||
_messageController.clear();
|
_messageController.clear();
|
||||||
|
_isSending = true;
|
||||||
});
|
});
|
||||||
// Simulate AI response
|
|
||||||
Future.delayed(const Duration(milliseconds: 800), () {
|
_scrollToBottom();
|
||||||
if (mounted) {
|
|
||||||
|
try {
|
||||||
|
final response = await _aiChatService.sendMessage(text.trim());
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_messages.add(_ChatMessage(isAi: true, text: response.message));
|
||||||
|
_isSending = false;
|
||||||
|
});
|
||||||
|
_scrollToBottom();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[AiAgentPage] sendMessage error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_messages.add(_ChatMessage(
|
_messages.add(_ChatMessage(
|
||||||
isAi: true,
|
isAi: true,
|
||||||
text: '正在分析您的数据...\n\n根据过去30天的销售数据,您的 ¥25 礼品卡表现最佳,建议在周五下午发布新券以获得最大曝光。当前定价 \$21.25 处于最优区间。',
|
text: '抱歉,暂时无法回复。请稍后重试。',
|
||||||
));
|
));
|
||||||
|
_isSending = false;
|
||||||
});
|
});
|
||||||
|
_scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToBottom() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||||
import '../../../../app/theme/app_colors.dart';
|
import '../../../../app/theme/app_colors.dart';
|
||||||
import '../../../../app/router.dart';
|
import '../../../../app/router.dart';
|
||||||
import '../../../../app/i18n/app_localizations.dart';
|
import '../../../../app/i18n/app_localizations.dart';
|
||||||
|
import '../../../../core/services/auth_service.dart';
|
||||||
|
import '../../../../core/network/api_client.dart';
|
||||||
|
|
||||||
/// 发行方登录页
|
/// 发行方登录页
|
||||||
///
|
///
|
||||||
|
|
@ -18,6 +20,80 @@ class _IssuerLoginPageState extends State<IssuerLoginPage> {
|
||||||
final _phoneController = TextEditingController();
|
final _phoneController = TextEditingController();
|
||||||
final _codeController = TextEditingController();
|
final _codeController = TextEditingController();
|
||||||
bool _agreedToTerms = false;
|
bool _agreedToTerms = false;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isSendingCode = false;
|
||||||
|
int _countdown = 0;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
final _authService = AuthService();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_phoneController.dispose();
|
||||||
|
_codeController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendSmsCode() async {
|
||||||
|
final phone = _phoneController.text.trim();
|
||||||
|
if (phone.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSendingCode = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _authService.sendSmsCode(phone);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isSendingCode = false;
|
||||||
|
_countdown = 60;
|
||||||
|
});
|
||||||
|
_startCountdown();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerLoginPage] sendSmsCode error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isSendingCode = false;
|
||||||
|
_errorMessage = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startCountdown() {
|
||||||
|
Future.doWhile(() async {
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
if (!mounted) return false;
|
||||||
|
setState(() => _countdown--);
|
||||||
|
return _countdown > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _login() async {
|
||||||
|
final phone = _phoneController.text.trim();
|
||||||
|
final code = _codeController.text.trim();
|
||||||
|
if (phone.isEmpty || code.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _authService.loginByPhone(phone, code);
|
||||||
|
ApiClient.instance.setToken(result.accessToken);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pushReplacementNamed(context, AppRouter.main);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerLoginPage] login error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -53,10 +129,35 @@ class _IssuerLoginPageState extends State<IssuerLoginPage> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
|
// Error Message
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.errorLight,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, color: AppColors.error, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_errorMessage!,
|
||||||
|
style: const TextStyle(fontSize: 13, color: AppColors.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
// Phone Input
|
// Phone Input
|
||||||
TextField(
|
TextField(
|
||||||
controller: _phoneController,
|
controller: _phoneController,
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
|
enabled: !_isLoading,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: context.t('login_phone'),
|
labelText: context.t('login_phone'),
|
||||||
prefixIcon: const Icon(Icons.phone_outlined),
|
prefixIcon: const Icon(Icons.phone_outlined),
|
||||||
|
|
@ -72,6 +173,7 @@ class _IssuerLoginPageState extends State<IssuerLoginPage> {
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _codeController,
|
controller: _codeController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
enabled: !_isLoading,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: context.t('login_code'),
|
labelText: context.t('login_code'),
|
||||||
prefixIcon: const Icon(Icons.lock_outline_rounded),
|
prefixIcon: const Icon(Icons.lock_outline_rounded),
|
||||||
|
|
@ -82,10 +184,20 @@ class _IssuerLoginPageState extends State<IssuerLoginPage> {
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 52,
|
height: 52,
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: () {
|
onPressed: (_isSendingCode || _countdown > 0 || _isLoading)
|
||||||
// TODO: Send verification code to phone number
|
? null
|
||||||
},
|
: _sendSmsCode,
|
||||||
child: Text(context.t('login_get_code')),
|
child: _isSendingCode
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_countdown > 0
|
||||||
|
? '${_countdown}s'
|
||||||
|
: context.t('login_get_code'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -97,7 +209,9 @@ class _IssuerLoginPageState extends State<IssuerLoginPage> {
|
||||||
children: [
|
children: [
|
||||||
Checkbox(
|
Checkbox(
|
||||||
value: _agreedToTerms,
|
value: _agreedToTerms,
|
||||||
onChanged: (v) => setState(() => _agreedToTerms = v ?? false),
|
onChanged: _isLoading
|
||||||
|
? null
|
||||||
|
: (v) => setState(() => _agreedToTerms = v ?? false),
|
||||||
activeColor: AppColors.primary,
|
activeColor: AppColors.primary,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -123,10 +237,17 @@ class _IssuerLoginPageState extends State<IssuerLoginPage> {
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 52,
|
height: 52,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _agreedToTerms
|
onPressed: (_agreedToTerms && !_isLoading) ? _login : null,
|
||||||
? () => Navigator.pushReplacementNamed(context, AppRouter.main)
|
child: _isLoading
|
||||||
: null,
|
? const SizedBox(
|
||||||
child: Text(context.t('login_button'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(context.t('login_button'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
@ -134,7 +255,9 @@ class _IssuerLoginPageState extends State<IssuerLoginPage> {
|
||||||
// Register
|
// Register
|
||||||
Center(
|
Center(
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
onPressed: () => Navigator.pushNamed(context, AppRouter.onboarding),
|
onPressed: _isLoading
|
||||||
|
? null
|
||||||
|
: () => Navigator.pushNamed(context, AppRouter.onboarding),
|
||||||
child: Text(context.t('login_register')),
|
child: Text(context.t('login_register')),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,65 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../app/theme/app_colors.dart';
|
import '../../../../app/theme/app_colors.dart';
|
||||||
import '../../../../app/i18n/app_localizations.dart';
|
import '../../../../app/i18n/app_localizations.dart';
|
||||||
|
import '../../../../core/services/issuer_coupon_service.dart';
|
||||||
|
|
||||||
/// 发行方券详情页
|
/// 发行方券详情页
|
||||||
///
|
///
|
||||||
/// 展示单批次券的详细数据:销量、核销率、二级市场分析
|
/// 展示单批次券的详细数据:销量、核销率、二级市场分析
|
||||||
/// 操作:下架、召回、退款、增发
|
/// 操作:下架、召回、退款、增发
|
||||||
class IssuerCouponDetailPage extends StatelessWidget {
|
class IssuerCouponDetailPage extends StatefulWidget {
|
||||||
const IssuerCouponDetailPage({super.key});
|
final String? couponId;
|
||||||
|
|
||||||
|
const IssuerCouponDetailPage({super.key, this.couponId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<IssuerCouponDetailPage> createState() => _IssuerCouponDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IssuerCouponDetailPageState extends State<IssuerCouponDetailPage> {
|
||||||
|
final _couponService = IssuerCouponService();
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
IssuerCouponModel? _coupon;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadDetail() async {
|
||||||
|
final id = widget.couponId;
|
||||||
|
if (id == null || id.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_error = 'No coupon ID provided';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final coupon = await _couponService.getDetail(id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_coupon = coupon;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerCouponDetailPage] loadDetail error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_error = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -29,7 +81,28 @@ class IssuerCouponDetailPage extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _error != null
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(_error!, style: const TextStyle(color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loadDetail,
|
||||||
|
child: Text(context.t('retry')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _loadDetail,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -55,10 +128,17 @@ class IssuerCouponDetailPage extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeaderCard(BuildContext context) {
|
Widget _buildHeaderCard(BuildContext context) {
|
||||||
|
final coupon = _coupon!;
|
||||||
|
final soldCount = coupon.soldCount;
|
||||||
|
final redemptionRate = coupon.totalSupply > 0
|
||||||
|
? ((soldCount / coupon.totalSupply) * 100).toStringAsFixed(1)
|
||||||
|
: '0.0';
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -70,10 +150,10 @@ class IssuerCouponDetailPage extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'¥25 星巴克礼品卡',
|
coupon.name,
|
||||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: Colors.white),
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: Colors.white),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
|
|
@ -82,23 +162,28 @@ class IssuerCouponDetailPage extends StatelessWidget {
|
||||||
color: Colors.white.withValues(alpha: 0.2),
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
child: Text(context.t('coupon_detail_status_on_sale'), style: const TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w600)),
|
child: Text(
|
||||||
|
coupon.status == 'active'
|
||||||
|
? context.t('coupon_detail_status_on_sale')
|
||||||
|
: coupon.status,
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
'礼品卡 · 面值 \$25 · 发行价 \$21.25',
|
'${coupon.couponType} · 面值 \$${coupon.faceValue.toStringAsFixed(0)} · 发行价 \$${coupon.currentPrice.toStringAsFixed(2)}',
|
||||||
style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.8)),
|
style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.8)),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
_buildHeaderStat(context.t('coupon_stat_issued'), '5,000'),
|
_buildHeaderStat(context.t('coupon_stat_issued'), '${coupon.totalSupply}'),
|
||||||
_buildHeaderStat(context.t('coupon_stat_sold'), '4,200'),
|
_buildHeaderStat(context.t('coupon_stat_sold'), '$soldCount'),
|
||||||
_buildHeaderStat(context.t('coupon_stat_redeemed'), '3,300'),
|
_buildHeaderStat(context.t('coupon_stat_redeemed'), '${coupon.remainingSupply}'),
|
||||||
_buildHeaderStat(context.t('coupon_stat_rate'), '78.5%'),
|
_buildHeaderStat(context.t('coupon_stat_rate'), '$redemptionRate%'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -117,6 +202,9 @@ class IssuerCouponDetailPage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSalesDataCard(BuildContext context) {
|
Widget _buildSalesDataCard(BuildContext context) {
|
||||||
|
final coupon = _coupon!;
|
||||||
|
final salesIncome = coupon.currentPrice * coupon.soldCount;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -129,11 +217,11 @@ class IssuerCouponDetailPage extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(context.t('coupon_detail_sales_data'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
Text(context.t('coupon_detail_sales_data'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildDataRow(context.t('coupon_detail_sales_income'), '\$89,250'),
|
_buildDataRow(context.t('coupon_detail_sales_income'), '\$${salesIncome.toStringAsFixed(2)}'),
|
||||||
_buildDataRow(context.t('coupon_detail_breakage_income'), '\$3,400'),
|
_buildDataRow(context.t('coupon_detail_breakage_income'), '--'),
|
||||||
_buildDataRow(context.t('coupon_detail_platform_fee'), '-\$1,070'),
|
_buildDataRow(context.t('coupon_detail_platform_fee'), '--'),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
_buildDataRow(context.t('coupon_detail_net_income'), '\$91,580', bold: true),
|
_buildDataRow(context.t('coupon_detail_net_income'), '\$${salesIncome.toStringAsFixed(2)}', bold: true),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// Chart placeholder
|
// Chart placeholder
|
||||||
Container(
|
Container(
|
||||||
|
|
@ -162,11 +250,11 @@ class IssuerCouponDetailPage extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(context.t('coupon_detail_secondary_market'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
Text(context.t('coupon_detail_secondary_market'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildDataRow(context.t('coupon_detail_listing_count'), '128'),
|
_buildDataRow(context.t('coupon_detail_listing_count'), '--'),
|
||||||
_buildDataRow(context.t('coupon_detail_avg_resale_price'), '\$22.80'),
|
_buildDataRow(context.t('coupon_detail_avg_resale_price'), '--'),
|
||||||
_buildDataRow(context.t('coupon_detail_avg_discount_rate'), '91.2%'),
|
_buildDataRow(context.t('coupon_detail_avg_discount_rate'), '--'),
|
||||||
_buildDataRow(context.t('coupon_detail_resale_volume'), '856'),
|
_buildDataRow(context.t('coupon_detail_resale_volume'), '--'),
|
||||||
_buildDataRow(context.t('coupon_detail_resale_amount'), '\$19,517'),
|
_buildDataRow(context.t('coupon_detail_resale_amount'), '--'),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Container(
|
Container(
|
||||||
height: 100,
|
height: 100,
|
||||||
|
|
@ -194,22 +282,18 @@ class IssuerCouponDetailPage extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(context.t('coupon_detail_financing_effect'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
Text(context.t('coupon_detail_financing_effect'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildDataRow(context.t('coupon_detail_cash_advance'), '\$89,250'),
|
_buildDataRow(context.t('coupon_detail_cash_advance'), '--'),
|
||||||
_buildDataRow(context.t('coupon_detail_avg_advance_days'), '45 天'),
|
_buildDataRow(context.t('coupon_detail_avg_advance_days'), '--'),
|
||||||
_buildDataRow(context.t('coupon_detail_financing_cost'), '\$4,463'),
|
_buildDataRow(context.t('coupon_detail_financing_cost'), '--'),
|
||||||
_buildDataRow(context.t('coupon_detail_equiv_annual_rate'), '3.6%'),
|
_buildDataRow(context.t('coupon_detail_equiv_annual_rate'), '--'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildRedemptionTimeline(BuildContext context) {
|
Widget _buildRedemptionTimeline(BuildContext context) {
|
||||||
final events = [
|
// Placeholder - in the future this could come from a dedicated API
|
||||||
('核销 5 张 · 门店A', '10分钟前', AppColors.success),
|
final events = <(String, String, Color)>[];
|
||||||
('核销 2 张 · 门店B', '25分钟前', AppColors.success),
|
|
||||||
('退款 1 张 · 自动退款', '1小时前', AppColors.warning),
|
|
||||||
('核销 8 张 · 门店A', '2小时前', AppColors.success),
|
|
||||||
];
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|
@ -223,6 +307,14 @@ class IssuerCouponDetailPage extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(context.t('coupon_detail_recent_redemptions'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
Text(context.t('coupon_detail_recent_redemptions'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
if (events.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Center(
|
||||||
|
child: Text('--', style: TextStyle(color: AppColors.textTertiary)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
...events.map((e) {
|
...events.map((e) {
|
||||||
final (desc, time, color) = e;
|
final (desc, time, color) = e;
|
||||||
return Padding(
|
return Padding(
|
||||||
|
|
@ -263,7 +355,20 @@ class IssuerCouponDetailPage extends StatelessWidget {
|
||||||
content: Text(context.t('coupon_detail_recall_desc')),
|
content: Text(context.t('coupon_detail_recall_desc')),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(context.t('cancel'))),
|
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(context.t('cancel'))),
|
||||||
ElevatedButton(onPressed: () => Navigator.pop(ctx), child: Text(context.t('coupon_detail_confirm_recall'))),
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
if (_coupon != null) {
|
||||||
|
try {
|
||||||
|
await _couponService.updateStatus(_coupon!.id, 'recalled');
|
||||||
|
if (mounted) _loadDetail();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerCouponDetailPage] recall error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(context.t('coupon_detail_confirm_recall')),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -285,7 +390,17 @@ class IssuerCouponDetailPage extends StatelessWidget {
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(context.t('cancel'))),
|
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(context.t('cancel'))),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
if (_coupon != null) {
|
||||||
|
try {
|
||||||
|
await _couponService.updateStatus(_coupon!.id, 'delisted');
|
||||||
|
if (mounted) _loadDetail();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerCouponDetailPage] delist error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: AppColors.error),
|
style: ElevatedButton.styleFrom(backgroundColor: AppColors.error),
|
||||||
child: Text(context.t('coupon_detail_confirm_delist')),
|
child: Text(context.t('coupon_detail_confirm_delist')),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,94 @@ import 'package:flutter/material.dart';
|
||||||
import '../../../../app/theme/app_colors.dart';
|
import '../../../../app/theme/app_colors.dart';
|
||||||
import '../../../../app/router.dart';
|
import '../../../../app/router.dart';
|
||||||
import '../../../../app/i18n/app_localizations.dart';
|
import '../../../../app/i18n/app_localizations.dart';
|
||||||
|
import '../../../../core/services/issuer_coupon_service.dart';
|
||||||
|
|
||||||
/// 券管理 - 列表页
|
/// 券管理 - 列表页
|
||||||
///
|
///
|
||||||
/// 展示发行方所有券批次,支持按状态筛选
|
/// 展示发行方所有券批次,支持按状态筛选
|
||||||
/// 顶部AI建议条 + FAB创建新券
|
/// 顶部AI建议条 + FAB创建新券
|
||||||
class CouponListPage extends StatelessWidget {
|
class CouponListPage extends StatefulWidget {
|
||||||
const CouponListPage({super.key});
|
const CouponListPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CouponListPage> createState() => _CouponListPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CouponListPageState extends State<CouponListPage> {
|
||||||
|
final _couponService = IssuerCouponService();
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
List<IssuerCouponModel> _coupons = [];
|
||||||
|
int _total = 0;
|
||||||
|
int _currentPage = 1;
|
||||||
|
String? _selectedStatus;
|
||||||
|
bool _hasMore = true;
|
||||||
|
|
||||||
|
final _filterStatusMap = <int, String?>{
|
||||||
|
0: null, // all
|
||||||
|
1: 'active', // on_sale
|
||||||
|
2: 'sold_out', // sold_out
|
||||||
|
3: 'pending', // pending
|
||||||
|
4: 'delisted', // delisted
|
||||||
|
};
|
||||||
|
int _selectedFilterIndex = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadCoupons();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCoupons({bool loadMore = false}) async {
|
||||||
|
if (!loadMore) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
_currentPage = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _couponService.list(
|
||||||
|
page: _currentPage,
|
||||||
|
status: _selectedStatus,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
if (loadMore) {
|
||||||
|
_coupons.addAll(result.items);
|
||||||
|
} else {
|
||||||
|
_coupons = result.items;
|
||||||
|
}
|
||||||
|
_total = result.total;
|
||||||
|
_hasMore = _coupons.length < _total;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[CouponListPage] loadCoupons error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_error = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFilterChanged(int index) {
|
||||||
|
setState(() {
|
||||||
|
_selectedFilterIndex = index;
|
||||||
|
_selectedStatus = _filterStatusMap[index];
|
||||||
|
});
|
||||||
|
_loadCoupons();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadMore() {
|
||||||
|
if (!_hasMore || _isLoading) return;
|
||||||
|
_currentPage++;
|
||||||
|
_loadCoupons(loadMore: true);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -40,15 +120,57 @@ class CouponListPage extends StatelessWidget {
|
||||||
|
|
||||||
// Coupon List
|
// Coupon List
|
||||||
Expanded(
|
Expanded(
|
||||||
|
child: _isLoading && _coupons.isEmpty
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _error != null && _coupons.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(_error!, style: const TextStyle(color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loadCoupons,
|
||||||
|
child: Text(context.t('retry')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _coupons.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
context.t('coupon_list_empty'),
|
||||||
|
style: const TextStyle(color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _loadCoupons,
|
||||||
|
child: NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: (notification) {
|
||||||
|
if (notification is ScrollEndNotification &&
|
||||||
|
notification.metrics.extentAfter < 200) {
|
||||||
|
_loadMore();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
itemCount: _mockCoupons.length,
|
itemCount: _coupons.length + (_hasMore ? 1 : 0),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final coupon = _mockCoupons[index];
|
if (index == _coupons.length) {
|
||||||
return _buildCouponItem(context, coupon);
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _buildCouponItem(context, _coupons[index]);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
|
@ -102,16 +224,14 @@ class CouponListPage extends StatelessWidget {
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: filters.map((f) {
|
children: filters.asMap().entries.map((entry) {
|
||||||
final isSelected = f == context.t('all');
|
final isSelected = entry.key == _selectedFilterIndex;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: FilterChip(
|
child: FilterChip(
|
||||||
label: Text(f),
|
label: Text(entry.value),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
onSelected: (_) {
|
onSelected: (_) => _onFilterChanged(entry.key),
|
||||||
// TODO: Apply coupon status filter
|
|
||||||
},
|
|
||||||
selectedColor: AppColors.primaryContainer,
|
selectedColor: AppColors.primaryContainer,
|
||||||
checkmarkColor: AppColors.primary,
|
checkmarkColor: AppColors.primary,
|
||||||
),
|
),
|
||||||
|
|
@ -121,9 +241,9 @@ class CouponListPage extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCouponItem(BuildContext context, _MockCoupon coupon) {
|
Widget _buildCouponItem(BuildContext context, IssuerCouponModel coupon) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => Navigator.pushNamed(context, AppRouter.couponDetail),
|
onTap: () => Navigator.pushNamed(context, AppRouter.couponDetail, arguments: coupon.id),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|
@ -144,7 +264,17 @@ class CouponListPage extends StatelessWidget {
|
||||||
color: AppColors.primarySurface,
|
color: AppColors.primarySurface,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.confirmation_number_rounded, color: AppColors.primary, size: 22),
|
child: coupon.imageUrl != null
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: Image.network(
|
||||||
|
coupon.imageUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) =>
|
||||||
|
const Icon(Icons.confirmation_number_rounded, color: AppColors.primary, size: 22),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.confirmation_number_rounded, color: AppColors.primary, size: 22),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -157,7 +287,7 @@ class CouponListPage extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'${coupon.template} · ${context.t('coupon_face_value')} \$${coupon.faceValue}',
|
'${coupon.couponType} · ${context.t('coupon_face_value')} \$${coupon.faceValue.toStringAsFixed(0)}',
|
||||||
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -170,10 +300,15 @@ class CouponListPage extends StatelessWidget {
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
_buildMiniStat(context.t('coupon_stat_issued'), '${coupon.issued}'),
|
_buildMiniStat(context.t('coupon_stat_issued'), '${coupon.totalSupply}'),
|
||||||
_buildMiniStat(context.t('coupon_stat_sold'), '${coupon.sold}'),
|
_buildMiniStat(context.t('coupon_stat_sold'), '${coupon.soldCount}'),
|
||||||
_buildMiniStat(context.t('coupon_stat_redeemed'), '${coupon.redeemed}'),
|
_buildMiniStat(context.t('coupon_stat_redeemed'), '${coupon.remainingSupply}'),
|
||||||
_buildMiniStat(context.t('coupon_stat_rate'), '${coupon.redemptionRate}%'),
|
_buildMiniStat(
|
||||||
|
context.t('coupon_stat_rate'),
|
||||||
|
coupon.totalSupply > 0
|
||||||
|
? '${((coupon.soldCount / coupon.totalSupply) * 100).toStringAsFixed(1)}%'
|
||||||
|
: '0%',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -184,15 +319,23 @@ class CouponListPage extends StatelessWidget {
|
||||||
|
|
||||||
Widget _buildStatusBadge(String status) {
|
Widget _buildStatusBadge(String status) {
|
||||||
Color color;
|
Color color;
|
||||||
|
String label = status;
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case '在售中':
|
case 'active':
|
||||||
color = AppColors.success;
|
color = AppColors.success;
|
||||||
|
label = '在售中';
|
||||||
break;
|
break;
|
||||||
case '待审核':
|
case 'pending':
|
||||||
color = AppColors.warning;
|
color = AppColors.warning;
|
||||||
|
label = '待审核';
|
||||||
break;
|
break;
|
||||||
case '已售罄':
|
case 'sold_out':
|
||||||
color = AppColors.info;
|
color = AppColors.info;
|
||||||
|
label = '已售罄';
|
||||||
|
break;
|
||||||
|
case 'delisted':
|
||||||
|
color = AppColors.textTertiary;
|
||||||
|
label = '已下架';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
color = AppColors.textTertiary;
|
color = AppColors.textTertiary;
|
||||||
|
|
@ -203,7 +346,7 @@ class CouponListPage extends StatelessWidget {
|
||||||
color: color.withValues(alpha: 0.1),
|
color: color.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
child: Text(status, style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.w600)),
|
child: Text(label, style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.w600)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,32 +360,3 @@ class CouponListPage extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MockCoupon {
|
|
||||||
final String name;
|
|
||||||
final String template;
|
|
||||||
final double faceValue;
|
|
||||||
final String status;
|
|
||||||
final int issued;
|
|
||||||
final int sold;
|
|
||||||
final int redeemed;
|
|
||||||
final double redemptionRate;
|
|
||||||
|
|
||||||
const _MockCoupon({
|
|
||||||
required this.name,
|
|
||||||
required this.template,
|
|
||||||
required this.faceValue,
|
|
||||||
required this.status,
|
|
||||||
required this.issued,
|
|
||||||
required this.sold,
|
|
||||||
required this.redeemed,
|
|
||||||
required this.redemptionRate,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const _mockCoupons = [
|
|
||||||
_MockCoupon(name: '¥25 星巴克礼品卡', template: '礼品卡', faceValue: 25, status: '在售中', issued: 5000, sold: 4200, redeemed: 3300, redemptionRate: 78.5),
|
|
||||||
_MockCoupon(name: '¥100 购物代金券', template: '代金券', faceValue: 100, status: '在售中', issued: 2000, sold: 1580, redeemed: 980, redemptionRate: 62.0),
|
|
||||||
_MockCoupon(name: '8折餐饮折扣券', template: '折扣券', faceValue: 50, status: '待审核', issued: 1000, sold: 0, redeemed: 0, redemptionRate: 0),
|
|
||||||
_MockCoupon(name: '¥200 储值卡', template: '储值券', faceValue: 200, status: '已售罄', issued: 500, sold: 500, redeemed: 420, redemptionRate: 84.0),
|
|
||||||
];
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../app/theme/app_colors.dart';
|
import '../../../../app/theme/app_colors.dart';
|
||||||
import '../../../../app/i18n/app_localizations.dart';
|
import '../../../../app/i18n/app_localizations.dart';
|
||||||
|
import '../../../../core/services/issuer_coupon_service.dart';
|
||||||
|
|
||||||
/// 模板化发券页面
|
/// 模板化发券页面
|
||||||
///
|
///
|
||||||
|
|
@ -21,10 +22,25 @@ class _CreateCouponPageState extends State<CreateCouponPage> {
|
||||||
final _faceValueController = TextEditingController();
|
final _faceValueController = TextEditingController();
|
||||||
final _quantityController = TextEditingController();
|
final _quantityController = TextEditingController();
|
||||||
final _issuePriceController = TextEditingController();
|
final _issuePriceController = TextEditingController();
|
||||||
|
final _descriptionController = TextEditingController();
|
||||||
bool _transferable = true;
|
bool _transferable = true;
|
||||||
int _maxResaleCount = 2;
|
int _maxResaleCount = 2;
|
||||||
int _refundWindowDays = 7;
|
int _refundWindowDays = 7;
|
||||||
bool _autoRefund = true;
|
bool _autoRefund = true;
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
DateTime? _expiryDate;
|
||||||
|
|
||||||
|
final _couponService = IssuerCouponService();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_faceValueController.dispose();
|
||||||
|
_quantityController.dispose();
|
||||||
|
_issuePriceController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -230,9 +246,11 @@ class _CreateCouponPageState extends State<CreateCouponPage> {
|
||||||
firstDate: DateTime.now(),
|
firstDate: DateTime.now(),
|
||||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||||
fieldLabelText: context.t('create_coupon_expiry'),
|
fieldLabelText: context.t('create_coupon_expiry'),
|
||||||
|
onDateSaved: (date) => _expiryDate = date,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
|
controller: _descriptionController,
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
decoration: InputDecoration(labelText: context.t('create_coupon_description'), hintText: context.t('create_coupon_description_hint')),
|
decoration: InputDecoration(labelText: context.t('create_coupon_description'), hintText: context.t('create_coupon_description_hint')),
|
||||||
),
|
),
|
||||||
|
|
@ -432,21 +450,29 @@ class _CreateCouponPageState extends State<CreateCouponPage> {
|
||||||
if (_currentStep > 0)
|
if (_currentStep > 0)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: () => setState(() => _currentStep--),
|
onPressed: _isSubmitting ? null : () => setState(() => _currentStep--),
|
||||||
child: Text(context.t('prev_step')),
|
child: Text(context.t('prev_step')),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_currentStep > 0) const SizedBox(width: 12),
|
if (_currentStep > 0) const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: _isSubmitting
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
if (_currentStep < 3) {
|
if (_currentStep < 3) {
|
||||||
setState(() => _currentStep++);
|
setState(() => _currentStep++);
|
||||||
} else {
|
} else {
|
||||||
_submitForReview();
|
_submitForReview();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Text(_currentStep < 3 ? context.t('next') : context.t('onboarding_submit_review')),
|
child: _isSubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: Text(_currentStep < 3 ? context.t('next') : context.t('onboarding_submit_review')),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -454,7 +480,29 @@ class _CreateCouponPageState extends State<CreateCouponPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _submitForReview() {
|
Future<void> _submitForReview() async {
|
||||||
|
setState(() => _isSubmitting = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'name': _nameController.text.trim(),
|
||||||
|
'couponType': _selectedTemplate ?? 'gift',
|
||||||
|
'faceValue': double.tryParse(_faceValueController.text) ?? 0,
|
||||||
|
'currentPrice': double.tryParse(_issuePriceController.text) ?? 0,
|
||||||
|
'totalSupply': int.tryParse(_quantityController.text) ?? 0,
|
||||||
|
'description': _descriptionController.text.trim(),
|
||||||
|
'isTransferable': _transferable,
|
||||||
|
'maxResaleCount': _maxResaleCount,
|
||||||
|
'refundWindowDays': _refundWindowDays,
|
||||||
|
'autoRefund': _autoRefund,
|
||||||
|
if (_expiryDate != null) 'expiryDate': _expiryDate!.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await _couponService.create(body);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
|
|
@ -471,5 +519,17 @@ class _CreateCouponPageState extends State<CreateCouponPage> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[CreateCouponPage] submit error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('${context.t('create_coupon_submit_error')}: $e'),
|
||||||
|
backgroundColor: AppColors.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,78 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../app/theme/app_colors.dart';
|
import '../../../../app/theme/app_colors.dart';
|
||||||
import '../../../../app/i18n/app_localizations.dart';
|
import '../../../../app/i18n/app_localizations.dart';
|
||||||
|
import '../../../../core/services/issuer_credit_service.dart';
|
||||||
|
|
||||||
/// 信用评级页面
|
/// 信用评级页面
|
||||||
///
|
///
|
||||||
/// 四因子信用评分:核销率35% + (1-Breakage率)25% + 市场存续20% + 用户满意度20%
|
/// 四因子信用评分:核销率35% + (1-Breakage率)25% + 市场存续20% + 用户满意度20%
|
||||||
/// AI建议列表:信用提升建议
|
/// AI建议列表:信用提升建议
|
||||||
class CreditPage extends StatelessWidget {
|
class CreditPage extends StatefulWidget {
|
||||||
const CreditPage({super.key});
|
const CreditPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CreditPage> createState() => _CreditPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreditPageState extends State<CreditPage> {
|
||||||
|
final _creditService = IssuerCreditService();
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
CreditModel? _credit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadCredit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCredit() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final credit = await _creditService.getCredit();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_credit = credit;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[CreditPage] loadCredit error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_error = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(context.t('credit_title'))),
|
appBar: AppBar(title: Text(context.t('credit_title'))),
|
||||||
body: SingleChildScrollView(
|
body: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _error != null
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(_error!, style: const TextStyle(color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(onPressed: _loadCredit, child: Text(context.t('retry'))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _loadCredit,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -38,10 +97,12 @@ class CreditPage extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildScoreGauge(BuildContext context) {
|
Widget _buildScoreGauge(BuildContext context) {
|
||||||
|
final credit = _credit!;
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -62,12 +123,12 @@ class CreditPage extends StatelessWidget {
|
||||||
colors: [AppColors.creditAA, AppColors.creditAA.withValues(alpha: 0.3)],
|
colors: [AppColors.creditAA, AppColors.creditAA.withValues(alpha: 0.3)],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text('AA', style: TextStyle(fontSize: 32, fontWeight: FontWeight.w700, color: Colors.white)),
|
Text(credit.grade, style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||||
Text('82分', style: TextStyle(fontSize: 14, color: Colors.white70)),
|
Text('${credit.score}分', style: const TextStyle(fontSize: 14, color: Colors.white70)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -82,12 +143,8 @@ class CreditPage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFactorsCard(BuildContext context) {
|
Widget _buildFactorsCard(BuildContext context) {
|
||||||
final factors = [
|
final factors = _credit!.factors;
|
||||||
(context.t('credit_factor_redemption'), 0.85, 0.35, AppColors.success),
|
final factorColors = [AppColors.success, AppColors.info, AppColors.primary, AppColors.warning];
|
||||||
(context.t('credit_factor_breakage'), 0.72, 0.25, AppColors.info),
|
|
||||||
(context.t('credit_factor_market'), 0.90, 0.20, AppColors.primary),
|
|
||||||
(context.t('credit_factor_satisfaction'), 0.78, 0.20, AppColors.warning),
|
|
||||||
];
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|
@ -101,8 +158,10 @@ class CreditPage extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(context.t('credit_factors'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
Text(context.t('credit_factors'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
...factors.map((f) {
|
...factors.asMap().entries.map((entry) {
|
||||||
final (label, score, weight, color) = f;
|
final f = entry.value;
|
||||||
|
final color = factorColors[entry.key % factorColors.length];
|
||||||
|
final score = f.score / 100;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -110,9 +169,9 @@ class CreditPage extends StatelessWidget {
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(fontSize: 13)),
|
Text(f.name, style: const TextStyle(fontSize: 13)),
|
||||||
Text(
|
Text(
|
||||||
'${(score * 100).toInt()}分 (权重${(weight * 100).toInt()}%)',
|
'${f.score.toInt()}分 (权重${(f.weight * 100).toInt()}%)',
|
||||||
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -121,7 +180,7 @@ class CreditPage extends StatelessWidget {
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: score,
|
value: score.clamp(0.0, 1.0),
|
||||||
backgroundColor: AppColors.gray100,
|
backgroundColor: AppColors.gray100,
|
||||||
valueColor: AlwaysStoppedAnimation(color),
|
valueColor: AlwaysStoppedAnimation(color),
|
||||||
minHeight: 8,
|
minHeight: 8,
|
||||||
|
|
@ -131,18 +190,20 @@ class CreditPage extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
if (factors.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Center(child: Text('暂无评分因子', style: TextStyle(color: AppColors.textTertiary))),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTierProgress(BuildContext context) {
|
Widget _buildTierProgress(BuildContext context) {
|
||||||
final tiers = [
|
final credit = _credit!;
|
||||||
(context.t('credit_tier_silver'), AppColors.tierSilver, true),
|
final tierColors = [AppColors.tierSilver, AppColors.tierGold, AppColors.tierPlatinum, AppColors.tierDiamond];
|
||||||
(context.t('credit_tier_gold'), AppColors.tierGold, true),
|
final tiers = credit.tiers;
|
||||||
(context.t('credit_tier_platinum'), AppColors.tierPlatinum, false),
|
|
||||||
(context.t('credit_tier_diamond'), AppColors.tierDiamond, false),
|
|
||||||
];
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|
@ -158,8 +219,11 @@ class CreditPage extends StatelessWidget {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: tiers.map((t) {
|
children: tiers.asMap().entries.map((entry) {
|
||||||
final (name, color, isReached) = t;
|
final index = entry.key;
|
||||||
|
final name = entry.value;
|
||||||
|
final isReached = index <= credit.currentTierIndex;
|
||||||
|
final color = tierColors[index % tierColors.length];
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
|
|
@ -200,11 +264,8 @@ class CreditPage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAiSuggestions(BuildContext context) {
|
Widget _buildAiSuggestions(BuildContext context) {
|
||||||
final suggestions = [
|
final suggestions = _credit!.suggestions;
|
||||||
('提升核销率', '建议在周末推出限时核销活动,预计可提升核销率5%', Icons.trending_up_rounded),
|
final icons = [Icons.trending_up_rounded, Icons.notification_important_rounded, Icons.rate_review_rounded];
|
||||||
('降低Breakage', '当前有12%的券过期未用,建议到期前7天推送提醒', Icons.notification_important_rounded),
|
|
||||||
('增加用户满意度', '回复消费者评价可提升满意度评分', Icons.rate_review_rounded),
|
|
||||||
];
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -217,8 +278,21 @@ class CreditPage extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
...suggestions.map((s) {
|
if (suggestions.isEmpty)
|
||||||
final (title, desc, icon) = s;
|
Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primarySurface,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Text('暂无建议', style: TextStyle(color: AppColors.textSecondary)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...suggestions.asMap().entries.map((entry) {
|
||||||
|
final icon = icons[entry.key % icons.length];
|
||||||
|
final text = entry.value;
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
|
|
@ -231,14 +305,7 @@ class CreditPage extends StatelessWidget {
|
||||||
Icon(icon, color: AppColors.primary, size: 20),
|
Icon(icon, color: AppColors.primary, size: 20),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Text(text, style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(desc, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -249,6 +316,7 @@ class CreditPage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCreditHistory(BuildContext context) {
|
Widget _buildCreditHistory(BuildContext context) {
|
||||||
|
// Credit history not yet provided by API, keeping placeholder
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -261,32 +329,10 @@ class CreditPage extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(context.t('credit_history_title'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
Text(context.t('credit_history_title'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildHistoryItem('信用分 +3', '核销率提升至85%', '2天前', AppColors.success),
|
const Padding(
|
||||||
_buildHistoryItem('信用分 -1', 'Breakage率微升', '1周前', AppColors.error),
|
padding: EdgeInsets.all(16),
|
||||||
_buildHistoryItem('升级至黄金', '月发行量达100万', '2周前', AppColors.tierGold),
|
child: Center(child: Text('--', style: TextStyle(color: AppColors.textTertiary))),
|
||||||
_buildHistoryItem('信用分 +5', '完成首月营业', '1月前', AppColors.success),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHistoryItem(String title, String desc, String time, Color color) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: color)),
|
|
||||||
Text(desc, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(time, style: const TextStyle(fontSize: 11, color: AppColors.textTertiary)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,60 @@ import 'package:flutter/material.dart';
|
||||||
import '../../../../app/theme/app_colors.dart';
|
import '../../../../app/theme/app_colors.dart';
|
||||||
import '../../../../app/router.dart';
|
import '../../../../app/router.dart';
|
||||||
import '../../../../app/i18n/app_localizations.dart';
|
import '../../../../app/i18n/app_localizations.dart';
|
||||||
|
import '../../../../core/services/issuer_service.dart';
|
||||||
|
|
||||||
/// 发行方数据仪表盘
|
/// 发行方数据仪表盘
|
||||||
///
|
///
|
||||||
/// 展示:总发行量、核销率、销售收入、信用等级、额度使用
|
/// 展示:总发行量、核销率、销售收入、信用等级、额度使用
|
||||||
/// AI洞察卡片:智能解读销售数据
|
/// AI洞察卡片:智能解读销售数据
|
||||||
class IssuerDashboardPage extends StatelessWidget {
|
class IssuerDashboardPage extends StatefulWidget {
|
||||||
const IssuerDashboardPage({super.key});
|
const IssuerDashboardPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<IssuerDashboardPage> createState() => _IssuerDashboardPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IssuerDashboardPageState extends State<IssuerDashboardPage> {
|
||||||
|
final _issuerService = IssuerService();
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
IssuerStats? _stats;
|
||||||
|
IssuerProfile? _profile;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadData() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final results = await Future.wait([
|
||||||
|
_issuerService.getStats(),
|
||||||
|
_issuerService.getProfile(),
|
||||||
|
]);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_stats = results[0] as IssuerStats;
|
||||||
|
_profile = results[1] as IssuerProfile;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IssuerDashboardPage] loadData error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_error = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -24,7 +70,28 @@ class IssuerDashboardPage extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _error != null
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(_error!, style: const TextStyle(color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loadData,
|
||||||
|
child: Text(context.t('retry')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _loadData,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -54,6 +121,7 @@ class IssuerDashboardPage extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,7 +141,21 @@ class IssuerDashboardPage extends StatelessWidget {
|
||||||
color: Colors.white.withValues(alpha: 0.2),
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: _profile?.logoUrl != null
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: Image.network(
|
||||||
|
_profile!.logoUrl!,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: Image.asset('assets/images/logo_icon.png', width: 48, height: 48),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: Image.asset('assets/images/logo_icon.png', width: 48, height: 48),
|
child: Image.asset('assets/images/logo_icon.png', width: 48, height: 48),
|
||||||
),
|
),
|
||||||
|
|
@ -83,9 +165,9 @@ class IssuerDashboardPage extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Starbucks China',
|
_profile?.companyName ?? '--',
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: Colors.white),
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: Colors.white),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Container(
|
Container(
|
||||||
|
|
@ -95,7 +177,9 @@ class IssuerDashboardPage extends StatelessWidget {
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
context.t('dashboard_gold_issuer'),
|
_stats?.creditGrade != null
|
||||||
|
? '${_stats!.creditGrade} ${context.t('dashboard_gold_issuer')}'
|
||||||
|
: context.t('dashboard_gold_issuer'),
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.white, fontWeight: FontWeight.w600),
|
style: const TextStyle(fontSize: 11, color: Colors.white, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -109,11 +193,32 @@ class IssuerDashboardPage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStatsGrid(BuildContext context) {
|
Widget _buildStatsGrid(BuildContext context) {
|
||||||
final stats = [
|
final stats = _stats;
|
||||||
(context.t('dashboard_total_issued'), '12,580', Icons.confirmation_number_rounded, AppColors.primary),
|
final statItems = [
|
||||||
(context.t('dashboard_redemption_rate'), '78.5%', Icons.check_circle_rounded, AppColors.success),
|
(
|
||||||
(context.t('dashboard_sales_revenue'), '\$125,800', Icons.attach_money_rounded, AppColors.info),
|
context.t('dashboard_total_issued'),
|
||||||
(context.t('dashboard_withdrawable'), '\$42,300', Icons.account_balance_wallet_rounded, AppColors.warning),
|
stats != null ? '${stats.issuedCount}' : '--',
|
||||||
|
Icons.confirmation_number_rounded,
|
||||||
|
AppColors.primary,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
context.t('dashboard_redemption_rate'),
|
||||||
|
stats != null ? '${stats.redemptionRate.toStringAsFixed(1)}%' : '--',
|
||||||
|
Icons.check_circle_rounded,
|
||||||
|
AppColors.success,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
context.t('dashboard_sales_revenue'),
|
||||||
|
stats != null ? '\$${stats.totalRevenue.toStringAsFixed(0)}' : '--',
|
||||||
|
Icons.attach_money_rounded,
|
||||||
|
AppColors.info,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
context.t('dashboard_withdrawable'),
|
||||||
|
stats != null ? '\$${(stats.quotaTotal - stats.quotaUsed).toStringAsFixed(0)}' : '--',
|
||||||
|
Icons.account_balance_wallet_rounded,
|
||||||
|
AppColors.warning,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
return GridView.builder(
|
return GridView.builder(
|
||||||
|
|
@ -125,9 +230,9 @@ class IssuerDashboardPage extends StatelessWidget {
|
||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: 12,
|
||||||
childAspectRatio: 1.6,
|
childAspectRatio: 1.6,
|
||||||
),
|
),
|
||||||
itemCount: stats.length,
|
itemCount: statItems.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final (label, value, icon, color) = stats[index];
|
final (label, value, icon, color) = statItems[index];
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -212,6 +317,11 @@ class IssuerDashboardPage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCreditQuotaCard(BuildContext context) {
|
Widget _buildCreditQuotaCard(BuildContext context) {
|
||||||
|
final stats = _stats;
|
||||||
|
final quotaUsed = stats?.quotaUsed ?? 0;
|
||||||
|
final quotaTotal = stats?.quotaTotal ?? 1;
|
||||||
|
final quotaPercent = quotaTotal > 0 ? (quotaUsed / quotaTotal) : 0.0;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -231,8 +341,11 @@ class IssuerDashboardPage extends StatelessWidget {
|
||||||
color: AppColors.creditAA.withValues(alpha: 0.1),
|
color: AppColors.creditAA.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: Text('AA', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.creditAA)),
|
child: Text(
|
||||||
|
stats?.creditGrade ?? '--',
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.creditAA),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
@ -241,7 +354,10 @@ class IssuerDashboardPage extends StatelessWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(context.t('dashboard_credit_rating'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
Text(context.t('dashboard_credit_rating'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
Text(context.t('dashboard_credit_gap'), style: const TextStyle(fontSize: 12, color: AppColors.textTertiary)),
|
Text(
|
||||||
|
stats != null ? '${context.t('dashboard_credit_gap')} (${stats.creditScore}分)' : context.t('dashboard_credit_gap'),
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.textTertiary),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -262,14 +378,17 @@ class IssuerDashboardPage extends StatelessWidget {
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(context.t('dashboard_issue_quota'), style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
Text(context.t('dashboard_issue_quota'), style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
||||||
const Text('\$380,000 / \$500,000', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
Text(
|
||||||
|
'\$${quotaUsed.toStringAsFixed(0)} / \$${quotaTotal.toStringAsFixed(0)}',
|
||||||
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: 0.76,
|
value: quotaPercent.clamp(0.0, 1.0),
|
||||||
backgroundColor: AppColors.gray100,
|
backgroundColor: AppColors.gray100,
|
||||||
valueColor: const AlwaysStoppedAnimation(AppColors.primary),
|
valueColor: const AlwaysStoppedAnimation(AppColors.primary),
|
||||||
minHeight: 8,
|
minHeight: 8,
|
||||||
|
|
@ -278,7 +397,10 @@ class IssuerDashboardPage extends StatelessWidget {
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: Text(context.t('dashboard_used_percent'), style: const TextStyle(fontSize: 11, color: AppColors.textTertiary)),
|
child: Text(
|
||||||
|
'${(quotaPercent * 100).toStringAsFixed(0)}%',
|
||||||
|
style: const TextStyle(fontSize: 11, color: AppColors.textTertiary),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../app/theme/app_colors.dart';
|
import '../../../../app/theme/app_colors.dart';
|
||||||
import '../../../../app/router.dart';
|
|
||||||
import '../../../../app/i18n/app_localizations.dart';
|
import '../../../../app/i18n/app_localizations.dart';
|
||||||
|
import '../../../../core/services/issuer_finance_service.dart';
|
||||||
|
|
||||||
/// 财务管理页面
|
/// 财务管理页面
|
||||||
///
|
///
|
||||||
|
|
@ -57,12 +57,112 @@ class FinancePage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OverviewTab extends StatelessWidget {
|
class _OverviewTab extends StatefulWidget {
|
||||||
const _OverviewTab();
|
const _OverviewTab();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_OverviewTab> createState() => _OverviewTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OverviewTabState extends State<_OverviewTab> {
|
||||||
|
final _financeService = IssuerFinanceService();
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
FinanceBalance? _balance;
|
||||||
|
FinanceStats? _stats;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadData() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final results = await Future.wait([
|
||||||
|
_financeService.getBalance(),
|
||||||
|
_financeService.getStats(),
|
||||||
|
]);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_balance = results[0] as FinanceBalance;
|
||||||
|
_stats = results[1] as FinanceStats;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[FinancePage] loadData error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_error = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleWithdraw() async {
|
||||||
|
if (_balance == null || _balance!.withdrawable <= 0) return;
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('确认提现'),
|
||||||
|
content: Text('提现金额: \$${_balance!.withdrawable.toStringAsFixed(2)}'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(context.t('cancel'))),
|
||||||
|
ElevatedButton(onPressed: () => Navigator.pop(ctx, true), child: Text(context.t('confirm'))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _financeService.withdraw(_balance!.withdrawable);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('提现申请已提交'), backgroundColor: AppColors.success),
|
||||||
|
);
|
||||||
|
_loadData();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[FinancePage] withdraw error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('提现失败: $e'), backgroundColor: AppColors.error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SingleChildScrollView(
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(_error!, style: const TextStyle(color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(onPressed: _loadData, child: Text(context.t('retry'))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _loadData,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -78,14 +178,15 @@ class _OverviewTab extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(context.t('finance_withdrawable'), style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.7))),
|
Text(context.t('finance_withdrawable'), style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.7))),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
const Text('\$42,300.00', style: TextStyle(fontSize: 32, fontWeight: FontWeight.w700, color: Colors.white)),
|
Text(
|
||||||
|
'\$${_balance?.withdrawable.toStringAsFixed(2) ?? '0.00'}',
|
||||||
|
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w700, color: Colors.white),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: _handleWithdraw,
|
||||||
// TODO: Show withdrawal dialog or navigate to withdrawal flow
|
|
||||||
},
|
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
foregroundColor: AppColors.primary,
|
foregroundColor: AppColors.primary,
|
||||||
|
|
@ -132,17 +233,19 @@ class _OverviewTab extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFinanceStatsGrid(BuildContext context) {
|
Widget _buildFinanceStatsGrid(BuildContext context) {
|
||||||
final stats = [
|
final s = _stats;
|
||||||
(context.t('finance_sales_income'), '\$125,800', AppColors.success),
|
final statItems = [
|
||||||
(context.t('finance_breakage_income'), '\$8,200', AppColors.info),
|
(context.t('finance_sales_income'), '\$${s?.salesAmount.toStringAsFixed(0) ?? '0'}', AppColors.success),
|
||||||
(context.t('finance_platform_fee'), '-\$1,510', AppColors.error),
|
(context.t('finance_breakage_income'), '\$${s?.breakageIncome.toStringAsFixed(0) ?? '0'}', AppColors.info),
|
||||||
(context.t('finance_pending_settlement'), '\$15,400', AppColors.warning),
|
(context.t('finance_platform_fee'), '-\$${s?.platformFee.toStringAsFixed(0) ?? '0'}', AppColors.error),
|
||||||
(context.t('finance_withdrawn'), '\$66,790', AppColors.textSecondary),
|
(context.t('finance_pending_settlement'), '\$${s?.pendingSettlement.toStringAsFixed(0) ?? '0'}', AppColors.warning),
|
||||||
(context.t('finance_total_income'), '\$132,490', AppColors.primary),
|
(context.t('finance_withdrawn'), '\$${s?.withdrawnAmount.toStringAsFixed(0) ?? '0'}', AppColors.textSecondary),
|
||||||
|
(context.t('finance_total_income'), '\$${s?.totalRevenue.toStringAsFixed(0) ?? '0'}', AppColors.primary),
|
||||||
];
|
];
|
||||||
|
|
||||||
return GridView.builder(
|
return GridView.builder(
|
||||||
|
|
@ -154,9 +257,9 @@ class _OverviewTab extends StatelessWidget {
|
||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: 12,
|
||||||
childAspectRatio: 2,
|
childAspectRatio: 2,
|
||||||
),
|
),
|
||||||
itemCount: stats.length,
|
itemCount: statItems.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final (label, value, color) = stats[index];
|
final (label, value, color) = statItems[index];
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -196,9 +299,9 @@ class _OverviewTab extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildRow(context.t('finance_guarantee_deposit'), '\$10,000'),
|
_buildRow(context.t('finance_guarantee_deposit'), '\$${_balance?.pending.toStringAsFixed(0) ?? '0'}'),
|
||||||
_buildRow(context.t('finance_frozen_sales'), '\$5,200'),
|
_buildRow(context.t('finance_frozen_sales'), '--'),
|
||||||
_buildRow(context.t('finance_frozen_ratio'), '20%'),
|
_buildRow(context.t('finance_frozen_ratio'), '--'),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
title: Text(context.t('finance_auto_freeze'), style: const TextStyle(fontSize: 14)),
|
title: Text(context.t('finance_auto_freeze'), style: const TextStyle(fontSize: 14)),
|
||||||
|
|
@ -229,52 +332,185 @@ class _OverviewTab extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TransactionDetailTab extends StatelessWidget {
|
class _TransactionDetailTab extends StatefulWidget {
|
||||||
const _TransactionDetailTab();
|
const _TransactionDetailTab();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<_TransactionDetailTab> createState() => _TransactionDetailTabState();
|
||||||
final transactions = [
|
}
|
||||||
('售出 ¥25 礼品卡 x5', '+\$106.25', '今天 14:32', AppColors.success),
|
|
||||||
('核销结算 ¥100 购物券 x2', '+\$200.00', '今天 12:15', AppColors.success),
|
|
||||||
('平台手续费', '-\$3.19', '今天 14:32', AppColors.error),
|
|
||||||
('退款 ¥25 礼品卡', '-\$21.25', '今天 10:08', AppColors.warning),
|
|
||||||
('售出 ¥50 生活券 x3', '+\$127.50', '昨天 18:45', AppColors.success),
|
|
||||||
('提现至银行账户', '-\$5,000.00', '昨天 16:00', AppColors.info),
|
|
||||||
];
|
|
||||||
|
|
||||||
return ListView.separated(
|
class _TransactionDetailTabState extends State<_TransactionDetailTab> {
|
||||||
|
final _financeService = IssuerFinanceService();
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
List<TransactionModel> _transactions = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadTransactions();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadTransactions() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _financeService.getTransactions();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_transactions = result.items;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[FinancePage] loadTransactions error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_error = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(_error!, style: const TextStyle(color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(onPressed: _loadTransactions, child: Text(context.t('retry'))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_transactions.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Text('暂无交易记录', style: TextStyle(color: AppColors.textSecondary)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _loadTransactions,
|
||||||
|
child: ListView.separated(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
itemCount: transactions.length,
|
itemCount: _transactions.length,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final (desc, amount, time, color) = transactions[index];
|
final tx = _transactions[index];
|
||||||
|
final isPositive = tx.amount >= 0;
|
||||||
|
final color = isPositive ? AppColors.success : AppColors.error;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(vertical: 6),
|
contentPadding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
title: Text(desc, style: const TextStyle(fontSize: 14)),
|
title: Text(tx.description, style: const TextStyle(fontSize: 14)),
|
||||||
subtitle: Text(time, style: const TextStyle(fontSize: 12, color: AppColors.textTertiary)),
|
subtitle: Text(
|
||||||
|
_formatTime(tx.createdAt),
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.textTertiary),
|
||||||
|
),
|
||||||
trailing: Text(
|
trailing: Text(
|
||||||
amount,
|
'${isPositive ? '+' : ''}\$${tx.amount.toStringAsFixed(2)}',
|
||||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: color),
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: color),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatTime(DateTime dt) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final isToday = dt.year == now.year && dt.month == now.month && dt.day == now.day;
|
||||||
|
final timeStr = '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||||
|
if (isToday) return '今天 $timeStr';
|
||||||
|
final yesterday = now.subtract(const Duration(days: 1));
|
||||||
|
final isYesterday = dt.year == yesterday.year && dt.month == yesterday.month && dt.day == yesterday.day;
|
||||||
|
if (isYesterday) return '昨天 $timeStr';
|
||||||
|
return '${dt.month}/${dt.day} $timeStr';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ReconciliationTab extends StatelessWidget {
|
class _ReconciliationTab extends StatefulWidget {
|
||||||
const _ReconciliationTab();
|
const _ReconciliationTab();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<_ReconciliationTab> createState() => _ReconciliationTabState();
|
||||||
final reports = [
|
}
|
||||||
('2026年1月对账单', '总收入: \$28,450 | 总支出: \$3,210', '已生成'),
|
|
||||||
('2025年12月对账单', '总收入: \$32,100 | 总支出: \$4,080', '已生成'),
|
|
||||||
('2025年11月对账单', '总收入: \$25,800 | 总支出: \$2,900', '已生成'),
|
|
||||||
];
|
|
||||||
|
|
||||||
return ListView(
|
class _ReconciliationTabState extends State<_ReconciliationTab> {
|
||||||
|
final _financeService = IssuerFinanceService();
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
Map<String, dynamic> _reconciliation = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadReconciliation();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadReconciliation() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final data = await _financeService.getReconciliation();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_reconciliation = data;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[FinancePage] loadReconciliation error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_error = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(_error!, style: const TextStyle(color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(onPressed: _loadReconciliation, child: Text(context.t('retry'))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final reports = (_reconciliation['reports'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _loadReconciliation,
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
children: [
|
children: [
|
||||||
// Generate New
|
// Generate New
|
||||||
|
|
@ -290,8 +526,18 @@ class _ReconciliationTab extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
if (reports.isEmpty)
|
||||||
|
const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(32),
|
||||||
|
child: Text('暂无对账报表', style: TextStyle(color: AppColors.textSecondary)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
...reports.map((r) {
|
...reports.map((r) {
|
||||||
final (title, summary, status) = r;
|
final title = r['title'] ?? '';
|
||||||
|
final summary = r['summary'] ?? '';
|
||||||
|
final status = r['status'] ?? '已生成';
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|
@ -306,7 +552,9 @@ class _ReconciliationTab extends StatelessWidget {
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
Expanded(
|
||||||
|
child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -343,6 +591,7 @@ class _ReconciliationTab extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../app/theme/app_colors.dart';
|
import '../../../../app/theme/app_colors.dart';
|
||||||
import '../../../../app/i18n/app_localizations.dart';
|
import '../../../../app/i18n/app_localizations.dart';
|
||||||
|
import '../../../../core/services/redemption_service.dart';
|
||||||
|
|
||||||
/// 核销管理页面
|
/// 核销管理页面
|
||||||
///
|
///
|
||||||
|
|
@ -33,9 +34,200 @@ class RedemptionPage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ScanRedeemTab extends StatelessWidget {
|
class _ScanRedeemTab extends StatefulWidget {
|
||||||
const _ScanRedeemTab();
|
const _ScanRedeemTab();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ScanRedeemTab> createState() => _ScanRedeemTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScanRedeemTabState extends State<_ScanRedeemTab> {
|
||||||
|
final _manualCodeController = TextEditingController();
|
||||||
|
final _redemptionService = RedemptionService();
|
||||||
|
|
||||||
|
bool _isRedeeming = false;
|
||||||
|
bool _isLoadingStats = true;
|
||||||
|
TodayRedemptionStats? _todayStats;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadTodayStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_manualCodeController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadTodayStats() async {
|
||||||
|
try {
|
||||||
|
final stats = await _redemptionService.getTodayStats();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_todayStats = stats;
|
||||||
|
_isLoadingStats = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[RedemptionPage] loadTodayStats error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isLoadingStats = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _manualRedeem() async {
|
||||||
|
final code = _manualCodeController.text.trim();
|
||||||
|
if (code.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() => _isRedeeming = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _redemptionService.manual(code);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isRedeeming = false);
|
||||||
|
_manualCodeController.clear();
|
||||||
|
_loadTodayStats();
|
||||||
|
_showRedeemResult(context, result);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[RedemptionPage] manualRedeem error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isRedeeming = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('核销失败: $e'), backgroundColor: AppColors.error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showRedeemResult(BuildContext context, RedemptionResult result) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
result.status == 'completed' ? Icons.check_circle_rounded : Icons.error_rounded,
|
||||||
|
color: result.status == 'completed' ? AppColors.success : AppColors.error,
|
||||||
|
size: 56,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(context.t('redemption_confirm_title'), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(result.couponName, style: const TextStyle(color: AppColors.textSecondary)),
|
||||||
|
if (result.amount > 0) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('\$${result.amount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: Text(context.t('redemption_confirm_button')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showBatchRedeem(BuildContext context) async {
|
||||||
|
final batchController = TextEditingController();
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (ctx) => DraggableScrollableSheet(
|
||||||
|
expand: false,
|
||||||
|
initialChildSize: 0.6,
|
||||||
|
builder: (_, controller) => StatefulBuilder(
|
||||||
|
builder: (ctx, setLocalState) {
|
||||||
|
bool isBatching = false;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(context.t('redemption_batch'), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(context.t('redemption_batch_desc'), style: const TextStyle(color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: batchController,
|
||||||
|
maxLines: null,
|
||||||
|
expands: true,
|
||||||
|
textAlignVertical: TextAlignVertical.top,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: context.t('redemption_batch_hint'),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: isBatching
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final text = batchController.text.trim();
|
||||||
|
if (text.isEmpty) return;
|
||||||
|
|
||||||
|
final codes = text
|
||||||
|
.split(RegExp(r'[\n,;]+'))
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.where((c) => c.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (codes.isEmpty) return;
|
||||||
|
|
||||||
|
setLocalState(() => isBatching = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _redemptionService.batch(codes);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_loadTodayStats();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('成功: ${result.successCount}, 失败: ${result.failCount}'),
|
||||||
|
backgroundColor: result.failCount == 0 ? AppColors.success : AppColors.warning,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[RedemptionPage] batch error: $e');
|
||||||
|
setLocalState(() => isBatching = false);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('批量核销失败: $e'), backgroundColor: AppColors.error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: isBatching
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: Text(context.t('redemption_batch')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
|
@ -75,6 +267,8 @@ class _ScanRedeemTab extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
|
controller: _manualCodeController,
|
||||||
|
enabled: !_isRedeeming,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: context.t('redemption_manual_hint'),
|
hintText: context.t('redemption_manual_hint'),
|
||||||
prefixIcon: const Icon(Icons.keyboard_rounded),
|
prefixIcon: const Icon(Icons.keyboard_rounded),
|
||||||
|
|
@ -85,8 +279,14 @@ class _ScanRedeemTab extends StatelessWidget {
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 52,
|
height: 52,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () => _showRedeemConfirm(context),
|
onPressed: _isRedeeming ? null : _manualRedeem,
|
||||||
child: Text(context.t('redemption_redeem')),
|
child: _isRedeeming
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: Text(context.t('redemption_redeem')),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -112,17 +312,28 @@ class _ScanRedeemTab extends StatelessWidget {
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: AppColors.borderLight),
|
border: Border.all(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
child: const Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(context.t('redemption_today_title'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
Text(context.t('redemption_today_title'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
_isLoadingStats
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: [
|
children: [
|
||||||
_StatItem(label: context.t('redemption_today_count'), value: '45'),
|
_StatItem(
|
||||||
_StatItem(label: context.t('redemption_today_amount'), value: '\$1,125'),
|
label: context.t('redemption_today_count'),
|
||||||
_StatItem(label: context.t('redemption_today_stores'), value: '3'),
|
value: '${_todayStats?.count ?? 0}',
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: context.t('redemption_today_amount'),
|
||||||
|
value: '\$${_todayStats?.totalAmount.toStringAsFixed(0) ?? '0'}',
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: context.t('redemption_today_stores'),
|
||||||
|
value: '--',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -132,96 +343,90 @@ class _ScanRedeemTab extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showRedeemConfirm(BuildContext context) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => Container(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.check_circle_rounded, color: AppColors.success, size: 56),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(context.t('redemption_confirm_title'), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Text('¥25 星巴克礼品卡', style: TextStyle(color: AppColors.textSecondary)),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: Text(context.t('redemption_confirm_button')),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showBatchRedeem(BuildContext context) {
|
class _RedeemHistoryTab extends StatefulWidget {
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (ctx) => DraggableScrollableSheet(
|
|
||||||
expand: false,
|
|
||||||
initialChildSize: 0.6,
|
|
||||||
builder: (_, controller) => Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(context.t('redemption_batch'), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(context.t('redemption_batch_desc'), style: const TextStyle(color: AppColors.textSecondary)),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
maxLines: null,
|
|
||||||
expands: true,
|
|
||||||
textAlignVertical: TextAlignVertical.top,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: context.t('redemption_batch_hint'),
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: Text(context.t('redemption_batch')),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RedeemHistoryTab extends StatelessWidget {
|
|
||||||
const _RedeemHistoryTab();
|
const _RedeemHistoryTab();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<_RedeemHistoryTab> createState() => _RedeemHistoryTabState();
|
||||||
final records = [
|
}
|
||||||
('¥25 礼品卡', '门店A · 收银员张三', '10分钟前', true),
|
|
||||||
('¥100 购物券', '门店B · 收银员李四', '25分钟前', true),
|
|
||||||
('¥50 生活券', '手动输入', '1小时前', true),
|
|
||||||
('¥25 礼品卡', '门店A · 扫码', '2小时前', false),
|
|
||||||
];
|
|
||||||
|
|
||||||
return ListView.separated(
|
class _RedeemHistoryTabState extends State<_RedeemHistoryTab> {
|
||||||
|
final _redemptionService = RedemptionService();
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
List<RedemptionRecord> _records = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadHistory() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _redemptionService.getHistory();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_records = result.items;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[RedemptionPage] loadHistory error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_error = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(_error!, style: const TextStyle(color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loadHistory,
|
||||||
|
child: Text(context.t('retry')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_records.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Text('暂无核销记录', style: TextStyle(color: AppColors.textSecondary)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _loadHistory,
|
||||||
|
child: ListView.separated(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
itemCount: records.length,
|
itemCount: _records.length,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final (name, source, time, success) = records[index];
|
final record = _records[index];
|
||||||
|
final success = record.status == 'completed';
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
leading: Container(
|
leading: Container(
|
||||||
|
|
@ -237,13 +442,29 @@ class _RedeemHistoryTab extends StatelessWidget {
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
title: Text(record.couponName, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
subtitle: Text(source, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
subtitle: Text(
|
||||||
trailing: Text(time, style: const TextStyle(fontSize: 11, color: AppColors.textTertiary)),
|
'${record.method} · \$${record.amount.toStringAsFixed(2)}',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
trailing: Text(
|
||||||
|
_formatTime(record.createdAt),
|
||||||
|
style: const TextStyle(fontSize: 11, color: AppColors.textTertiary),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatTime(DateTime dt) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = now.difference(dt);
|
||||||
|
if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前';
|
||||||
|
if (diff.inHours < 24) return '${diff.inHours}小时前';
|
||||||
|
if (diff.inDays < 7) return '${diff.inDays}天前';
|
||||||
|
return '${dt.month}/${dt.day}';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StatItem extends StatelessWidget {
|
class _StatItem extends StatelessWidget {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import '../../../../app/theme/app_colors.dart';
|
||||||
import '../../../../app/router.dart';
|
import '../../../../app/router.dart';
|
||||||
import '../../../../app/i18n/app_localizations.dart';
|
import '../../../../app/i18n/app_localizations.dart';
|
||||||
import '../../../../core/updater/update_service.dart';
|
import '../../../../core/updater/update_service.dart';
|
||||||
|
import '../../../../core/services/issuer_service.dart';
|
||||||
|
import '../../../../core/services/auth_service.dart';
|
||||||
|
import '../../../../core/network/api_client.dart';
|
||||||
|
|
||||||
/// 发行方设置页面(我的)
|
/// 发行方设置页面(我的)
|
||||||
///
|
///
|
||||||
|
|
@ -17,11 +20,18 @@ class SettingsPage extends StatefulWidget {
|
||||||
|
|
||||||
class _SettingsPageState extends State<SettingsPage> {
|
class _SettingsPageState extends State<SettingsPage> {
|
||||||
String _appVersion = '';
|
String _appVersion = '';
|
||||||
|
bool _isLoadingProfile = true;
|
||||||
|
IssuerProfile? _profile;
|
||||||
|
bool _isLoggingOut = false;
|
||||||
|
|
||||||
|
final _issuerService = IssuerService();
|
||||||
|
final _authService = AuthService();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadVersion();
|
_loadVersion();
|
||||||
|
_loadProfile();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadVersion() async {
|
Future<void> _loadVersion() async {
|
||||||
|
|
@ -31,6 +41,54 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadProfile() async {
|
||||||
|
try {
|
||||||
|
final profile = await _issuerService.getProfile();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_profile = profile;
|
||||||
|
_isLoadingProfile = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SettingsPage] loadProfile error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isLoadingProfile = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleLogout() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('确认退出'),
|
||||||
|
content: const Text('确定要退出登录吗?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(context.t('cancel'))),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: AppColors.error),
|
||||||
|
child: Text(context.t('confirm')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
setState(() => _isLoggingOut = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _authService.logout();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SettingsPage] logout error: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiClient.instance.setToken(null);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pushNamedAndRemoveUntil(context, AppRouter.login, (_) => false);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -97,14 +155,18 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: () {
|
onPressed: _isLoggingOut ? null : _handleLogout,
|
||||||
Navigator.pushNamedAndRemoveUntil(context, AppRouter.login, (_) => false);
|
|
||||||
},
|
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: AppColors.error,
|
foregroundColor: AppColors.error,
|
||||||
side: const BorderSide(color: AppColors.error),
|
side: const BorderSide(color: AppColors.error),
|
||||||
),
|
),
|
||||||
child: Text(context.t('settings_logout')),
|
child: _isLoggingOut
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Text(context.t('settings_logout')),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -120,7 +182,21 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
_profile?.logoUrl != null
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Image.network(
|
||||||
|
_profile!.logoUrl!,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Image.asset('assets/images/logo_icon.png', width: 56, height: 56),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/images/logo_icon.png',
|
'assets/images/logo_icon.png',
|
||||||
|
|
@ -130,12 +206,39 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: _isLoadingProfile
|
||||||
|
? const Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text('Starbucks China', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
SizedBox(
|
||||||
|
width: 120,
|
||||||
|
height: 16,
|
||||||
|
child: LinearProgressIndicator(),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: 80,
|
||||||
|
height: 12,
|
||||||
|
child: LinearProgressIndicator(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_profile?.companyName ?? '--',
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text('${context.t('settings_admin')}:张经理', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
Text(
|
||||||
|
_profile?.contactEmail != null
|
||||||
|
? '${context.t('settings_admin')}:${_profile!.contactEmail}'
|
||||||
|
: _profile?.contactPhone != null
|
||||||
|
? '${context.t('settings_admin')}:${_profile!.contactPhone}'
|
||||||
|
: context.t('settings_admin'),
|
||||||
|
style: const TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -163,7 +266,12 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(context.t('settings_gold_issuer'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.tierGold)),
|
Text(
|
||||||
|
_profile?.creditRating != null
|
||||||
|
? '${_profile!.creditRating} ${context.t('settings_gold_issuer')}'
|
||||||
|
: context.t('settings_gold_issuer'),
|
||||||
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.tierGold),
|
||||||
|
),
|
||||||
const Text('手续费率 1.2% · 高级数据分析', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
const Text('手续费率 1.2% · 高级数据分析', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../app/theme/app_colors.dart';
|
import '../../../../app/theme/app_colors.dart';
|
||||||
import '../../../../app/i18n/app_localizations.dart';
|
import '../../../../app/i18n/app_localizations.dart';
|
||||||
|
import '../../../../core/services/issuer_store_service.dart';
|
||||||
|
|
||||||
/// 多门店管理页面
|
/// 多门店管理页面
|
||||||
///
|
///
|
||||||
|
|
@ -41,24 +42,83 @@ class StoreManagementPage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StoreListTab extends StatelessWidget {
|
class _StoreListTab extends StatefulWidget {
|
||||||
const _StoreListTab();
|
const _StoreListTab();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<_StoreListTab> createState() => _StoreListTabState();
|
||||||
final stores = [
|
}
|
||||||
_Store('总部', 'headquarters', '上海市黄浦区', 15, true),
|
|
||||||
_Store('华东区', 'regional', '上海/杭州/南京', 8, true),
|
|
||||||
_Store('徐汇门店', 'store', '上海市徐汇区xxx路', 3, true),
|
|
||||||
_Store('静安门店', 'store', '上海市静安区xxx路', 2, true),
|
|
||||||
_Store('杭州西湖店', 'store', '杭州市西湖区xxx路', 2, false),
|
|
||||||
];
|
|
||||||
|
|
||||||
return ListView.builder(
|
class _StoreListTabState extends State<_StoreListTab> {
|
||||||
|
final _storeService = IssuerStoreService();
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
List<StoreModel> _stores = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadStores();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadStores() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final stores = await _storeService.listStores();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_stores = stores;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[StoreManagementPage] loadStores error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_error = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(_error!, style: const TextStyle(color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(onPressed: _loadStores, child: Text(context.t('retry'))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_stores.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Text('暂无门店', style: TextStyle(color: AppColors.textSecondary)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _loadStores,
|
||||||
|
child: ListView.builder(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
itemCount: stores.length,
|
itemCount: _stores.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final store = stores[index];
|
final store = _stores[index];
|
||||||
return Container(
|
return Container(
|
||||||
margin: EdgeInsets.only(
|
margin: EdgeInsets.only(
|
||||||
left: store.level == 'regional' ? 20 : store.level == 'store' ? 40 : 0,
|
left: store.level == 'regional' ? 20 : store.level == 'store' ? 40 : 0,
|
||||||
|
|
@ -93,28 +153,33 @@ class _StoreListTab extends StatelessWidget {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: store.isActive ? AppColors.successLight : AppColors.gray100,
|
color: store.status == 'active' ? AppColors.successLight : AppColors.gray100,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
store.isActive ? context.t('store_status_open') : context.t('store_status_closed'),
|
store.status == 'active' ? context.t('store_status_open') : context.t('store_status_closed'),
|
||||||
style: TextStyle(fontSize: 10, color: store.isActive ? AppColors.success : AppColors.textTertiary),
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: store.status == 'active' ? AppColors.success : AppColors.textTertiary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(store.address, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
Text(
|
||||||
|
store.address ?? store.phone ?? '--',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text('${store.staffCount}${context.t('store_people_unit')}', style: const TextStyle(fontSize: 12, color: AppColors.textTertiary)),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
const Icon(Icons.chevron_right_rounded, size: 18, color: AppColors.textTertiary),
|
const Icon(Icons.chevron_right_rounded, size: 18, color: AppColors.textTertiary),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,26 +200,85 @@ class _StoreListTab extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EmployeeListTab extends StatelessWidget {
|
class _EmployeeListTab extends StatefulWidget {
|
||||||
const _EmployeeListTab();
|
const _EmployeeListTab();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<_EmployeeListTab> createState() => _EmployeeListTabState();
|
||||||
final employees = [
|
}
|
||||||
('张经理', '管理员', '总部', Icons.admin_panel_settings_rounded, AppColors.primary),
|
|
||||||
('李店长', '店长', '徐汇门店', Icons.manage_accounts_rounded, AppColors.info),
|
|
||||||
('王店长', '店长', '静安门店', Icons.manage_accounts_rounded, AppColors.info),
|
|
||||||
('赵收银', '收银员', '徐汇门店', Icons.point_of_sale_rounded, AppColors.success),
|
|
||||||
('钱收银', '收银员', '徐汇门店', Icons.point_of_sale_rounded, AppColors.success),
|
|
||||||
('孙收银', '收银员', '静安门店', Icons.point_of_sale_rounded, AppColors.success),
|
|
||||||
];
|
|
||||||
|
|
||||||
return ListView.separated(
|
class _EmployeeListTabState extends State<_EmployeeListTab> {
|
||||||
|
final _storeService = IssuerStoreService();
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
List<EmployeeModel> _employees = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadEmployees();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadEmployees() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final employees = await _storeService.listEmployees();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_employees = employees;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[StoreManagementPage] loadEmployees error: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_error = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(_error!, style: const TextStyle(color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(onPressed: _loadEmployees, child: Text(context.t('retry'))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_employees.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Text('暂无员工', style: TextStyle(color: AppColors.textSecondary)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _loadEmployees,
|
||||||
|
child: ListView.separated(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
itemCount: employees.length,
|
itemCount: _employees.length,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final (name, role, store, icon, color) = employees[index];
|
final emp = _employees[index];
|
||||||
|
final (icon, color) = _roleIconColor(emp.role);
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(vertical: 4),
|
contentPadding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
leading: Container(
|
leading: Container(
|
||||||
|
|
@ -166,9 +290,22 @@ class _EmployeeListTab extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: Icon(icon, color: color, size: 20),
|
child: Icon(icon, color: color, size: 20),
|
||||||
),
|
),
|
||||||
title: Text(name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
title: Text(emp.name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
subtitle: Text('$role · $store', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
subtitle: Text(
|
||||||
|
'${emp.role} · ${emp.storeId ?? '--'}',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
trailing: PopupMenuButton<String>(
|
trailing: PopupMenuButton<String>(
|
||||||
|
onSelected: (value) async {
|
||||||
|
if (value == 'delete') {
|
||||||
|
try {
|
||||||
|
await _storeService.deleteEmployee(emp.id);
|
||||||
|
if (mounted) _loadEmployees();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[StoreManagementPage] deleteEmployee error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
itemBuilder: (ctx) => [
|
itemBuilder: (ctx) => [
|
||||||
PopupMenuItem(value: 'edit', child: Text(context.t('store_emp_edit'))),
|
PopupMenuItem(value: 'edit', child: Text(context.t('store_emp_edit'))),
|
||||||
PopupMenuItem(value: 'delete', child: Text(context.t('store_emp_remove'))),
|
PopupMenuItem(value: 'delete', child: Text(context.t('store_emp_remove'))),
|
||||||
|
|
@ -176,16 +313,18 @@ class _EmployeeListTab extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class _Store {
|
(IconData, Color) _roleIconColor(String role) {
|
||||||
final String name;
|
switch (role) {
|
||||||
final String level;
|
case 'admin':
|
||||||
final String address;
|
return (Icons.admin_panel_settings_rounded, AppColors.primary);
|
||||||
final int staffCount;
|
case 'manager':
|
||||||
final bool isActive;
|
return (Icons.manage_accounts_rounded, AppColors.info);
|
||||||
|
default:
|
||||||
_Store(this.name, this.level, this.address, this.staffCount, this.isActive);
|
return (Icons.point_of_sale_rounded, AppColors.success);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,26 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { AuthProvider } from '@/lib/auth-context';
|
||||||
|
|
||||||
/**
|
|
||||||
* 全局 Providers 包装
|
|
||||||
*
|
|
||||||
* 后续在此添加:
|
|
||||||
* - Redux Provider (RTK)
|
|
||||||
* - React Query Provider
|
|
||||||
* - Theme Provider
|
|
||||||
* - Auth Provider
|
|
||||||
*/
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return <>{children}</>;
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private client: AxiosInstance;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 30000,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.interceptors.request.use((config) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const token = localStorage.getItem('admin_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401 && typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('admin_token');
|
||||||
|
localStorage.removeItem('admin_user');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
const response: AxiosResponse = await this.client.get(url, config);
|
||||||
|
return response.data?.data ?? response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
const response: AxiosResponse = await this.client.post(url, data, config);
|
||||||
|
return response.data?.data ?? response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
const response: AxiosResponse = await this.client.put(url, data, config);
|
||||||
|
return response.data?.data ?? response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async patch<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
const response: AxiosResponse = await this.client.patch(url, data, config);
|
||||||
|
return response.data?.data ?? response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
const response: AxiosResponse = await this.client.delete(url, config);
|
||||||
|
return response.data?.data ?? response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = new ApiClient();
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { apiClient } from './api-client';
|
||||||
|
|
||||||
|
interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: AdminUser | null;
|
||||||
|
token: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<AdminUser | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedToken = localStorage.getItem('admin_token');
|
||||||
|
const savedUser = localStorage.getItem('admin_user');
|
||||||
|
if (savedToken && savedUser) {
|
||||||
|
setToken(savedToken);
|
||||||
|
try {
|
||||||
|
setUser(JSON.parse(savedUser));
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('admin_user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
const result = await apiClient.post<{ accessToken: string; user: AdminUser }>(
|
||||||
|
'/api/v1/auth/login',
|
||||||
|
{ email, password },
|
||||||
|
);
|
||||||
|
const { accessToken, user: adminUser } = result;
|
||||||
|
localStorage.setItem('admin_token', accessToken);
|
||||||
|
localStorage.setItem('admin_user', JSON.stringify(adminUser));
|
||||||
|
setToken(accessToken);
|
||||||
|
setUser(adminUser);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
localStorage.removeItem('admin_token');
|
||||||
|
localStorage.removeItem('admin_user');
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
apiClient.post('/api/v1/auth/logout').catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isAuthenticated: !!token,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient, UseQueryOptions } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from './api-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET 请求 hook — 基于 @tanstack/react-query
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { data, isLoading, error } = useApi<DashboardStats>('/api/v1/admin/dashboard/stats');
|
||||||
|
*/
|
||||||
|
export function useApi<T = unknown>(
|
||||||
|
url: string | null,
|
||||||
|
options?: Omit<UseQueryOptions<T, Error>, 'queryKey' | 'queryFn'> & {
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { params, ...queryOptions } = options ?? {};
|
||||||
|
|
||||||
|
return useQuery<T, Error>({
|
||||||
|
queryKey: [url, params],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!url) throw new Error('URL is required');
|
||||||
|
return apiClient.get<T>(url, { params });
|
||||||
|
},
|
||||||
|
enabled: !!url,
|
||||||
|
staleTime: 30_000,
|
||||||
|
...queryOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST/PUT/DELETE mutation hook
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { mutateAsync } = useApiMutation<void>('POST', '/api/v1/admin/coupons/123/approve');
|
||||||
|
* await mutateAsync({ reason: 'Approved' });
|
||||||
|
*/
|
||||||
|
export function useApiMutation<T = unknown>(
|
||||||
|
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
||||||
|
url: string,
|
||||||
|
options?: { invalidateKeys?: string[] },
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<T, Error, unknown>({
|
||||||
|
mutationFn: async (data?: unknown) => {
|
||||||
|
switch (method) {
|
||||||
|
case 'POST':
|
||||||
|
return apiClient.post<T>(url, data);
|
||||||
|
case 'PUT':
|
||||||
|
return apiClient.put<T>(url, data);
|
||||||
|
case 'PATCH':
|
||||||
|
return apiClient.patch<T>(url, data);
|
||||||
|
case 'DELETE':
|
||||||
|
return apiClient.delete<T>(url);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
options?.invalidateKeys?.forEach((key) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [key] });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D7. AI Agent管理面板 - 平台管理员的AI Agent运营监控
|
* D7. AI Agent管理面板 - 平台管理员的AI Agent运营监控
|
||||||
|
|
@ -7,31 +10,27 @@ import { t } from '@/i18n/locales';
|
||||||
* Agent会话统计、常见问题Top10、响应质量监控、模型配置
|
* Agent会话统计、常见问题Top10、响应质量监控、模型配置
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const agentStats = [
|
interface AgentPanelData {
|
||||||
{ label: t('agent_today_sessions'), value: '3,456', change: '+18%', color: 'var(--color-primary)' },
|
stats: { label: string; value: string; change: string; color: string }[];
|
||||||
{ label: t('agent_avg_response'), value: '1.2s', change: '-0.3s', color: 'var(--color-success)' },
|
topQuestions: { question: string; count: number; category: string }[];
|
||||||
{ label: t('agent_satisfaction'), value: '94.5%', change: '+2.1%', color: 'var(--color-info)' },
|
modules: { name: string; status: string; accuracy: string; desc: string }[];
|
||||||
{ label: t('agent_human_takeover'), value: '3.2%', change: '-0.5%', color: 'var(--color-warning)' },
|
}
|
||||||
];
|
|
||||||
|
|
||||||
const topQuestions = [
|
const loadingBox: React.CSSProperties = {
|
||||||
{ question: '如何购买券?', count: 234, category: '使用指引' },
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
{ question: '推荐高折扣券', count: 189, category: '智能推券' },
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
{ question: '我的券快过期了', count: 156, category: '到期管理' },
|
};
|
||||||
{ question: '如何出售我的券?', count: 134, category: '交易指引' },
|
|
||||||
{ question: '退款怎么操作?', count: 98, category: '售后服务' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const agentModules = [
|
|
||||||
{ name: '智能推券', status: 'active', accuracy: '92%', desc: '根据用户画像推荐券' },
|
|
||||||
{ name: '比价分析', status: 'active', accuracy: '96%', desc: '三因子定价模型分析' },
|
|
||||||
{ name: '投资教育', status: 'active', accuracy: '89%', desc: '券投资知识科普' },
|
|
||||||
{ name: '客服对话', status: 'active', accuracy: '91%', desc: '常见问题自动应答' },
|
|
||||||
{ name: '发行方助手', status: 'active', accuracy: '94%', desc: '发券建议/定价优化' },
|
|
||||||
{ name: '风险预警', status: 'beta', accuracy: '87%', desc: '异常交易智能预警' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const AgentPanelPage: React.FC = () => {
|
export const AgentPanelPage: React.FC = () => {
|
||||||
|
const { data, isLoading, error } = useApi<AgentPanelData>('/api/v1/ai/admin/agent/stats');
|
||||||
|
|
||||||
|
if (error) return <div style={loadingBox}>Error: {error.message}</div>;
|
||||||
|
if (isLoading) return <div style={loadingBox}>Loading...</div>;
|
||||||
|
|
||||||
|
const agentStats = data?.stats ?? [];
|
||||||
|
const topQuestions = data?.topQuestions ?? [];
|
||||||
|
const agentModules = data?.modules ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 24 }}>{t('agent_title')}</h1>
|
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 24 }}>{t('agent_title')}</h1>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 消费者保护分析仪表盘
|
* 消费者保护分析仪表盘
|
||||||
|
|
@ -7,61 +10,67 @@ import { t } from '@/i18n/locales';
|
||||||
* 投诉统计、分类分析、满意度趋势、保障基金、退款合规
|
* 投诉统计、分类分析、满意度趋势、保障基金、退款合规
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const stats = [
|
interface ConsumerProtectionData {
|
||||||
{ label: t('cp_total_complaints'), value: '234', change: '-5.2%', trend: 'down' as const, color: 'var(--color-error)' },
|
stats: { label: string; value: string; change: string; trend: 'up' | 'down'; color: string; invertTrend?: boolean }[];
|
||||||
{ label: t('cp_resolved'), value: '198', change: '+12.3%', trend: 'up' as const, color: 'var(--color-success)' },
|
complaintCategories: { name: string; count: number; percent: number; color: string }[];
|
||||||
{ label: t('cp_processing'), value: '28', change: '-8.1%', trend: 'down' as const, color: 'var(--color-warning)' },
|
csatTrend: { month: string; score: number }[];
|
||||||
{ label: t('cp_avg_resolution_time'), value: '2.3d', change: '-0.4d', trend: 'down' as const, color: 'var(--color-info)' },
|
recentComplaints: { id: string; severity: string; category: string; title: string; status: string; assignee: string; created: string }[];
|
||||||
];
|
nonCompliantIssuers: { rank: number; issuer: string; violations: number; refundRate: string; avgDelay: string; riskLevel: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
const complaintCategories = [
|
const loadingBox: React.CSSProperties = {
|
||||||
{ name: t('cp_cat_redeem_fail'), count: 82, percent: 35, color: 'var(--color-error)' },
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
{ name: t('cp_cat_refund_dispute'), count: 68, percent: 29, color: 'var(--color-warning)' },
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
{ name: t('cp_cat_fake_coupon'), count: 49, percent: 21, color: 'var(--color-primary)' },
|
|
||||||
{ name: t('cp_cat_other'), count: 35, percent: 15, color: 'var(--color-gray-400)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const csatTrend = [
|
|
||||||
{ month: '9月', score: 4.1 },
|
|
||||||
{ month: '10月', score: 4.2 },
|
|
||||||
{ month: '11月', score: 4.0 },
|
|
||||||
{ month: '12月', score: 4.3 },
|
|
||||||
{ month: '1月', score: 4.4 },
|
|
||||||
{ month: '2月', score: 4.5 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const recentComplaints = [
|
|
||||||
{ id: 'CMP-0234', severity: t('severity_high'), category: t('cp_cat_fake_coupon'), title: 'Brand denies issuing this coupon', status: t('cp_processing'), assignee: 'Zhang Ming', created: '2026-02-10' },
|
|
||||||
{ id: 'CMP-0233', severity: t('severity_high'), category: t('cp_cat_refund_dispute'), title: 'Merchant refused service after redemption', status: t('cp_processing'), assignee: 'Li Hua', created: '2026-02-10' },
|
|
||||||
{ id: 'CMP-0232', severity: t('severity_medium'), category: t('cp_cat_redeem_fail'), title: 'QR code scan no response - store system failure', status: t('cp_resolved'), assignee: 'Wang Fang', created: '2026-02-09' },
|
|
||||||
{ id: 'CMP-0231', severity: t('severity_low'), category: t('cp_cat_other'), title: 'Coupon info mismatch with actual service', status: t('cp_resolved'), assignee: 'Zhao Li', created: '2026-02-09' },
|
|
||||||
{ id: 'CMP-0230', severity: t('severity_high'), category: t('cp_cat_refund_dispute'), title: 'Expired coupon refund denied, user claims no reminder', status: t('cp_processing'), assignee: 'Zhang Ming', created: '2026-02-09' },
|
|
||||||
{ id: 'CMP-0229', severity: t('severity_medium'), category: t('cp_cat_redeem_fail'), title: 'Cross-region redemption failed, no restriction noted', status: t('cp_resolved'), assignee: 'Li Hua', created: '2026-02-08' },
|
|
||||||
{ id: 'CMP-0228', severity: t('severity_low'), category: t('cp_cat_other'), title: 'Gifted coupon not received by recipient', status: t('cp_resolved'), assignee: 'Wang Fang', created: '2026-02-08' },
|
|
||||||
{ id: 'CMP-0227', severity: t('severity_medium'), category: t('cp_cat_fake_coupon'), title: 'Discount amount differs from advertisement', status: t('cp_processing'), assignee: 'Zhao Li', created: '2026-02-07' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const severityConfig: Record<string, { bg: string; color: string }> = {
|
|
||||||
[t('severity_high')]: { bg: 'var(--color-error-light)', color: 'var(--color-error)' },
|
|
||||||
[t('severity_medium')]: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)' },
|
|
||||||
[t('severity_low')]: { bg: 'var(--color-info-light)', color: 'var(--color-info)' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusConfig: Record<string, { bg: string; color: string }> = {
|
const getSeverityStyle = (severity: string) => {
|
||||||
[t('cp_processing')]: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)' },
|
switch (severity) {
|
||||||
[t('cp_resolved')]: { bg: 'var(--color-success-light)', color: 'var(--color-success)' },
|
case 'high': return { bg: 'var(--color-error-light)', color: 'var(--color-error)' };
|
||||||
[t('completed')]: { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' },
|
case 'medium': return { bg: 'var(--color-warning-light)', color: 'var(--color-warning)' };
|
||||||
|
case 'low': return { bg: 'var(--color-info-light)', color: 'var(--color-info)' };
|
||||||
|
default: return { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const nonCompliantIssuers = [
|
const getSeverityLabel = (severity: string) => {
|
||||||
{ rank: 1, issuer: 'Happy Life E-commerce', violations: 12, refundRate: '45%', avgDelay: '5.2d', riskLevel: t('severity_high') },
|
const map: Record<string, () => string> = {
|
||||||
{ rank: 2, issuer: 'Premium Travel Services', violations: 9, refundRate: '52%', avgDelay: '4.8d', riskLevel: t('severity_high') },
|
high: () => t('severity_high'),
|
||||||
{ rank: 3, issuer: 'Star Digital Official', violations: 7, refundRate: '61%', avgDelay: '3.5d', riskLevel: t('severity_medium') },
|
medium: () => t('severity_medium'),
|
||||||
{ rank: 4, issuer: 'Gourmet Restaurant Group', violations: 5, refundRate: '68%', avgDelay: '2.9d', riskLevel: t('severity_medium') },
|
low: () => t('severity_low'),
|
||||||
{ rank: 5, issuer: 'Joy Entertainment Media', violations: 4, refundRate: '72%', avgDelay: '2.1d', riskLevel: t('severity_low') },
|
};
|
||||||
];
|
return map[severity]?.() ?? severity;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getComplaintStatusStyle = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'processing': return { bg: 'var(--color-warning-light)', color: 'var(--color-warning)' };
|
||||||
|
case 'resolved': return { bg: 'var(--color-success-light)', color: 'var(--color-success)' };
|
||||||
|
case 'completed': return { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
||||||
|
default: return { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getComplaintStatusLabel = (status: string) => {
|
||||||
|
const map: Record<string, () => string> = {
|
||||||
|
processing: () => t('cp_processing'),
|
||||||
|
resolved: () => t('cp_resolved'),
|
||||||
|
completed: () => t('completed'),
|
||||||
|
};
|
||||||
|
return map[status]?.() ?? status;
|
||||||
|
};
|
||||||
|
|
||||||
export const ConsumerProtectionPage: React.FC = () => {
|
export const ConsumerProtectionPage: React.FC = () => {
|
||||||
|
const { data, isLoading, error } = useApi<ConsumerProtectionData>('/api/v1/admin/insurance/stats');
|
||||||
|
|
||||||
|
if (error) return <div style={loadingBox}>Error: {error.message}</div>;
|
||||||
|
if (isLoading) return <div style={loadingBox}>Loading...</div>;
|
||||||
|
|
||||||
|
const stats = data?.stats ?? [];
|
||||||
|
const complaintCategories = data?.complaintCategories ?? [];
|
||||||
|
const csatTrend = data?.csatTrend ?? [];
|
||||||
|
const recentComplaints = data?.recentComplaints ?? [];
|
||||||
|
const nonCompliantIssuers = data?.nonCompliantIssuers ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
|
@ -89,7 +98,7 @@ export const ConsumerProtectionPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
font: 'var(--text-label-sm)',
|
font: 'var(--text-label-sm)',
|
||||||
color: (stat.label === t('cp_total_complaints') || stat.label === t('cp_processing') || stat.label === t('cp_avg_resolution_time'))
|
color: stat.invertTrend
|
||||||
? (stat.trend === 'down' ? 'var(--color-success)' : 'var(--color-error)')
|
? (stat.trend === 'down' ? 'var(--color-success)' : 'var(--color-error)')
|
||||||
: (stat.trend === 'up' ? 'var(--color-success)' : 'var(--color-error)'),
|
: (stat.trend === 'up' ? 'var(--color-success)' : 'var(--color-error)'),
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
|
|
@ -147,9 +156,16 @@ export const ConsumerProtectionPage: React.FC = () => {
|
||||||
}}>
|
}}>
|
||||||
<div style={{ font: 'var(--text-h3)', marginBottom: 12 }}>{t('cp_csat')}</div>
|
<div style={{ font: 'var(--text-h3)', marginBottom: 12 }}>{t('cp_csat')}</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 12 }}>
|
||||||
<span style={{ font: 'var(--text-h1)', color: 'var(--color-success)' }}>4.5</span>
|
<span style={{ font: 'var(--text-h1)', color: 'var(--color-success)' }}>
|
||||||
|
{csatTrend.length > 0 ? csatTrend[csatTrend.length - 1].score : '-'}
|
||||||
|
</span>
|
||||||
<span style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)' }}>/5.0</span>
|
<span style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)' }}>/5.0</span>
|
||||||
<span style={{ font: 'var(--text-label-sm)', color: 'var(--color-success)' }}>+0.1</span>
|
{csatTrend.length >= 2 && (
|
||||||
|
<span style={{ font: 'var(--text-label-sm)', color: 'var(--color-success)' }}>
|
||||||
|
{(csatTrend[csatTrend.length - 1].score - csatTrend[csatTrend.length - 2].score) >= 0 ? '+' : ''}
|
||||||
|
{(csatTrend[csatTrend.length - 1].score - csatTrend[csatTrend.length - 2].score).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
{csatTrend.map(item => (
|
{csatTrend.map(item => (
|
||||||
|
|
@ -193,7 +209,7 @@ export const ConsumerProtectionPage: React.FC = () => {
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
color: 'var(--color-text-tertiary)',
|
color: 'var(--color-text-tertiary)',
|
||||||
}}>
|
}}>
|
||||||
Recharts 仪表盘图 (基金池 $520K / 已用 $78K / 使用率 15%)
|
Recharts 仪表盘图 (基金池 / 已用 / 使用率)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -235,8 +251,8 @@ export const ConsumerProtectionPage: React.FC = () => {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{recentComplaints.map(row => {
|
{recentComplaints.map(row => {
|
||||||
const sev = severityConfig[row.severity];
|
const sev = getSeverityStyle(row.severity);
|
||||||
const st = statusConfig[row.status];
|
const st = getComplaintStatusStyle(row.status);
|
||||||
return (
|
return (
|
||||||
<tr key={row.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
<tr key={row.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
<td style={{ font: 'var(--text-body-sm)', fontFamily: 'var(--font-family-mono)', padding: '10px 14px', color: 'var(--color-text-tertiary)' }}>
|
<td style={{ font: 'var(--text-body-sm)', fontFamily: 'var(--font-family-mono)', padding: '10px 14px', color: 'var(--color-text-tertiary)' }}>
|
||||||
|
|
@ -249,7 +265,7 @@ export const ConsumerProtectionPage: React.FC = () => {
|
||||||
background: sev.bg,
|
background: sev.bg,
|
||||||
color: sev.color,
|
color: sev.color,
|
||||||
font: 'var(--text-caption)',
|
font: 'var(--text-caption)',
|
||||||
}}>{row.severity}</span>
|
}}>{getSeverityLabel(row.severity)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ font: 'var(--text-label-sm)', padding: '10px 14px' }}>{row.category}</td>
|
<td style={{ font: 'var(--text-label-sm)', padding: '10px 14px' }}>{row.category}</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px', maxWidth: 280 }}>{row.title}</td>
|
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px', maxWidth: 280 }}>{row.title}</td>
|
||||||
|
|
@ -260,7 +276,7 @@ export const ConsumerProtectionPage: React.FC = () => {
|
||||||
background: st.bg,
|
background: st.bg,
|
||||||
color: st.color,
|
color: st.color,
|
||||||
font: 'var(--text-caption)',
|
font: 'var(--text-caption)',
|
||||||
}}>{row.status}</span>
|
}}>{getComplaintStatusLabel(row.status)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px' }}>{row.assignee}</td>
|
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px' }}>{row.assignee}</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', padding: '10px 14px' }}>{row.created}</td>
|
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', padding: '10px 14px' }}>{row.created}</td>
|
||||||
|
|
@ -296,7 +312,7 @@ export const ConsumerProtectionPage: React.FC = () => {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{nonCompliantIssuers.map(row => {
|
{nonCompliantIssuers.map(row => {
|
||||||
const risk = severityConfig[row.riskLevel];
|
const risk = getSeverityStyle(row.riskLevel);
|
||||||
return (
|
return (
|
||||||
<tr key={row.rank} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
<tr key={row.rank} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
<td style={{
|
<td style={{
|
||||||
|
|
@ -333,7 +349,7 @@ export const ConsumerProtectionPage: React.FC = () => {
|
||||||
background: risk.bg,
|
background: risk.bg,
|
||||||
color: risk.color,
|
color: risk.color,
|
||||||
font: 'var(--text-caption)',
|
font: 'var(--text-caption)',
|
||||||
}}>{row.riskLevel}</span>
|
}}>{getSeverityLabel(row.riskLevel)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 14px', display: 'flex', gap: 4 }}>
|
<td style={{ padding: '10px 14px', display: 'flex', gap: 4 }}>
|
||||||
<button style={{ padding: '4px 10px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'none', cursor: 'pointer', font: 'var(--text-caption)', color: 'var(--color-text-secondary)' }}>{t('investigate')}</button>
|
<button style={{ padding: '4px 10px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'none', cursor: 'pointer', font: 'var(--text-caption)', color: 'var(--color-text-secondary)' }}>{t('investigate')}</button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 券分析仪表盘
|
* 券分析仪表盘
|
||||||
|
|
@ -7,53 +10,31 @@ import { t } from '@/i18n/locales';
|
||||||
* 券发行/核销/过期统计、品类分布、热销排行、Breakage趋势、二级市场分析
|
* 券发行/核销/过期统计、品类分布、热销排行、Breakage趋势、二级市场分析
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const stats = [
|
interface CouponAnalyticsData {
|
||||||
{ label: t('ca_total_coupons'), value: '45,230', change: '+6.5%', trend: 'up' as const, color: 'var(--color-primary)' },
|
stats: { label: string; value: string; change: string; trend: 'up' | 'down'; color: string }[];
|
||||||
{ label: t('ca_active_coupons'), value: '32,100', change: '+3.2%', trend: 'up' as const, color: 'var(--color-success)' },
|
categoryDistribution: { name: string; count: number; percent: number; color: string }[];
|
||||||
{ label: t('ca_redeemed'), value: '8,450', change: '+12.1%', trend: 'up' as const, color: 'var(--color-info)' },
|
topCoupons: { rank: number; brand: string; name: string; sales: number; revenue: string; rating: number }[];
|
||||||
{ label: t('ca_expiring_soon'), value: '2,340', change: '+8.7%', trend: 'up' as const, color: 'var(--color-warning)' },
|
breakageTrend: { month: string; rate: string }[];
|
||||||
];
|
secondaryMarket: { metric: string; value: string; change: string; trend: 'up' | 'down' }[];
|
||||||
|
}
|
||||||
|
|
||||||
const categoryDistribution = [
|
const loadingBox: React.CSSProperties = {
|
||||||
{ name: '餐饮', count: 14_474, percent: 32, color: 'var(--color-primary)' },
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
{ name: '零售', count: 11_308, percent: 25, color: 'var(--color-success)' },
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
{ name: '娱乐', count: 9_046, percent: 20, color: 'var(--color-info)' },
|
};
|
||||||
{ name: '旅游', count: 5_428, percent: 12, color: 'var(--color-warning)' },
|
|
||||||
{ name: '数码', count: 4_974, percent: 11, color: 'var(--color-error)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const topCoupons = [
|
|
||||||
{ rank: 1, brand: '星巴克', name: '大杯拿铁兑换券', sales: 4_230, revenue: '$105,750', rating: 4.8 },
|
|
||||||
{ rank: 2, brand: 'Amazon', name: '$100电子礼品卡', sales: 3_890, revenue: '$389,000', rating: 4.9 },
|
|
||||||
{ rank: 3, brand: 'Nike', name: '旗舰店8折券', sales: 2_750, revenue: '$220,000', rating: 4.6 },
|
|
||||||
{ rank: 4, brand: '海底捞', name: '双人套餐券', sales: 2_340, revenue: '$187,200', rating: 4.7 },
|
|
||||||
{ rank: 5, brand: 'Target', name: '$30消费券', sales: 2_100, revenue: '$63,000', rating: 4.5 },
|
|
||||||
{ rank: 6, brand: 'Apple', name: 'App Store $25', sales: 1_980, revenue: '$49,500', rating: 4.8 },
|
|
||||||
{ rank: 7, brand: '万达影城', name: '双人电影票', sales: 1_750, revenue: '$52,500', rating: 4.4 },
|
|
||||||
{ rank: 8, brand: 'Uber', name: '$20出行券', sales: 1_620, revenue: '$32,400', rating: 4.3 },
|
|
||||||
{ rank: 9, brand: '携程', name: '酒店满减券', sales: 1_480, revenue: '$148,000', rating: 4.6 },
|
|
||||||
{ rank: 10, brand: 'Steam', name: '$50充值卡', sales: 1_310, revenue: '$65,500', rating: 4.7 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const breakageTrend = [
|
|
||||||
{ month: '9月', rate: '18.2%' },
|
|
||||||
{ month: '10月', rate: '17.5%' },
|
|
||||||
{ month: '11月', rate: '16.8%' },
|
|
||||||
{ month: '12月', rate: '19.3%' },
|
|
||||||
{ month: '1月', rate: '17.1%' },
|
|
||||||
{ month: '2月', rate: '16.5%' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const secondaryMarket = [
|
|
||||||
{ metric: 'Listing Rate', value: '23.5%', change: '+1.2%', trend: 'up' as const },
|
|
||||||
{ metric: 'Avg Markup', value: '8.3%', change: '-0.5%', trend: 'down' as const },
|
|
||||||
{ metric: 'Daily Volume', value: '1,230', change: '+15.2%', trend: 'up' as const },
|
|
||||||
{ metric: 'Daily Amount', value: '$98,400', change: '+11.8%', trend: 'up' as const },
|
|
||||||
{ metric: 'Avg Fill Time', value: '4.2h', change: '-8.3%', trend: 'down' as const },
|
|
||||||
{ metric: 'Cancel Rate', value: '12.1%', change: '+0.8%', trend: 'up' as const },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const CouponAnalyticsPage: React.FC = () => {
|
export const CouponAnalyticsPage: React.FC = () => {
|
||||||
|
const { data, isLoading, error } = useApi<CouponAnalyticsData>('/api/v1/admin/analytics/coupons/stats');
|
||||||
|
|
||||||
|
if (error) return <div style={loadingBox}>Error: {error.message}</div>;
|
||||||
|
if (isLoading) return <div style={loadingBox}>Loading...</div>;
|
||||||
|
|
||||||
|
const stats = data?.stats ?? [];
|
||||||
|
const categoryDistribution = data?.categoryDistribution ?? [];
|
||||||
|
const topCoupons = data?.topCoupons ?? [];
|
||||||
|
const breakageTrend = data?.breakageTrend ?? [];
|
||||||
|
const secondaryMarket = data?.secondaryMarket ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 24 }}>
|
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 24 }}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi, useApiMutation } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 做市商管理仪表盘
|
* 做市商管理仪表盘
|
||||||
|
|
@ -7,61 +10,52 @@ import { t } from '@/i18n/locales';
|
||||||
* 做市商列表、流动性池、订单簿深度、市场健康指标、风险预警
|
* 做市商列表、流动性池、订单簿深度、市场健康指标、风险预警
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const stats = [
|
interface MarketMakerData {
|
||||||
{ label: t('mm_active_makers'), value: '12', change: '+2', trend: 'up' as const, color: 'var(--color-primary)' },
|
stats: { label: string; value: string; change: string; trend: 'up' | 'down'; color: string; invertTrend?: boolean }[];
|
||||||
{ label: t('mm_total_liquidity'), value: '$5.2M', change: '+8.3%', trend: 'up' as const, color: 'var(--color-success)' },
|
marketMakers: { name: string; status: 'active' | 'paused' | 'suspended'; tvl: string; spread: string; volume: string; pnl: string }[];
|
||||||
{ label: t('mm_daily_volume'), value: '$320K', change: '+12.5%', trend: 'up' as const, color: 'var(--color-info)' },
|
liquidityPools: { category: string; tvl: string; percent: number; makers: number; color: string }[];
|
||||||
{ label: t('mm_avg_spread'), value: '1.8%', change: '-0.3%', trend: 'down' as const, color: 'var(--color-warning)' },
|
healthIndicators: { name: string; value: string; target: string; status: 'good' | 'warning' }[];
|
||||||
];
|
riskAlerts: { time: string; maker: string; type: string; desc: string; severity: 'high' | 'medium' | 'low' }[];
|
||||||
|
}
|
||||||
|
|
||||||
const marketMakers = [
|
const loadingBox: React.CSSProperties = {
|
||||||
{ name: 'AlphaLiquidity', status: 'active' as const, tvl: '$1,250,000', spread: '1.2%', volume: '$85,000', pnl: '+$12,340' },
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
{ name: 'BetaMarkets', status: 'active' as const, tvl: '$980,000', spread: '1.5%', volume: '$72,000', pnl: '+$8,920' },
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
{ name: 'GammaTrading', status: 'active' as const, tvl: '$850,000', spread: '1.8%', volume: '$65,400', pnl: '+$6,780' },
|
|
||||||
{ name: 'DeltaCapital', status: 'paused' as const, tvl: '$620,000', spread: '2.1%', volume: '$0', pnl: '-$1,230' },
|
|
||||||
{ name: 'EpsilonFund', status: 'active' as const, tvl: '$540,000', spread: '1.6%', volume: '$43,200', pnl: '+$5,410' },
|
|
||||||
{ name: 'ZetaPartners', status: 'active' as const, tvl: '$430,000', spread: '2.0%', volume: '$31,800', pnl: '+$3,670' },
|
|
||||||
{ name: 'EtaVentures', status: 'suspended' as const, tvl: '$0', spread: '-', volume: '$0', pnl: '-$4,560' },
|
|
||||||
{ name: 'ThetaQuant', status: 'active' as const, tvl: '$280,000', spread: '1.9%', volume: '$22,600', pnl: '+$2,890' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusConfig: Record<string, { bg: string; color: string; label: string }> = {
|
|
||||||
active: { bg: 'var(--color-success-light)', color: 'var(--color-success)', label: t('mm_status_active') },
|
|
||||||
paused: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)', label: t('mm_status_paused') },
|
|
||||||
suspended: { bg: 'var(--color-error-light)', color: 'var(--color-error)', label: t('mm_status_suspended') },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const liquidityPools = [
|
const getStatusConfig = (status: string) => {
|
||||||
{ category: '餐饮', tvl: '$1,560,000', percent: 30, makers: 8, color: 'var(--color-primary)' },
|
const map: Record<string, { bg: string; color: string; label: () => string }> = {
|
||||||
{ category: '零售', tvl: '$1,300,000', percent: 25, makers: 7, color: 'var(--color-success)' },
|
active: { bg: 'var(--color-success-light)', color: 'var(--color-success)', label: () => t('mm_status_active') },
|
||||||
{ category: '娱乐', tvl: '$1,040,000', percent: 20, makers: 6, color: 'var(--color-info)' },
|
paused: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)', label: () => t('mm_status_paused') },
|
||||||
{ category: '旅游', tvl: '$780,000', percent: 15, makers: 5, color: 'var(--color-warning)' },
|
suspended: { bg: 'var(--color-error-light)', color: 'var(--color-error)', label: () => t('mm_status_suspended') },
|
||||||
{ category: '数码', tvl: '$520,000', percent: 10, makers: 4, color: 'var(--color-error)' },
|
};
|
||||||
];
|
return map[status] ?? { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)', label: () => status };
|
||||||
|
};
|
||||||
|
|
||||||
const healthIndicators = [
|
const getSeverityConfig = (severity: string) => {
|
||||||
{ name: 'Bid-Ask 价差', value: '1.8%', target: '< 3.0%', status: 'good' as const },
|
const map: Record<string, { bg: string; color: string; label: () => string }> = {
|
||||||
{ name: '滑点 (Slippage)', value: '0.42%', target: '< 1.0%', status: 'good' as const },
|
high: { bg: 'var(--color-error-light)', color: 'var(--color-error)', label: () => t('severity_high') },
|
||||||
{ name: '成交率 (Fill Rate)', value: '94.7%', target: '> 90%', status: 'good' as const },
|
medium: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)', label: () => t('severity_medium') },
|
||||||
{ name: '流动性深度', value: '$5.2M', target: '> $3M', status: 'good' as const },
|
low: { bg: 'var(--color-info-light)', color: 'var(--color-info)', label: () => t('severity_low') },
|
||||||
{ name: '价格偏差', value: '2.1%', target: '< 2.0%', status: 'warning' as const },
|
};
|
||||||
{ name: '做市商覆盖率', value: '87%', target: '> 85%', status: 'good' as const },
|
return map[severity] ?? { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)', label: () => severity };
|
||||||
];
|
|
||||||
|
|
||||||
const riskAlerts = [
|
|
||||||
{ time: '14:25', maker: 'DeltaCapital', type: '流动性撤出', desc: '30分钟内撤出65%流动性,已自动暂停', severity: 'high' as const },
|
|
||||||
{ time: '13:40', maker: 'EtaVentures', type: '异常交易', desc: '检测到自成交行为,账户已停用待审', severity: 'high' as const },
|
|
||||||
{ time: '12:15', maker: 'ZetaPartners', type: '价差偏高', desc: '餐饮品类价差达3.2%,超出阈值', severity: 'medium' as const },
|
|
||||||
{ time: '11:00', maker: 'ThetaQuant', type: 'API延迟', desc: '报价延迟升至800ms,可能影响做市质量', severity: 'low' as const },
|
|
||||||
];
|
|
||||||
|
|
||||||
const severityConfig: Record<string, { bg: string; color: string; label: string }> = {
|
|
||||||
high: { bg: 'var(--color-error-light)', color: 'var(--color-error)', label: t('severity_high') },
|
|
||||||
medium: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)', label: t('severity_medium') },
|
|
||||||
low: { bg: 'var(--color-info-light)', color: 'var(--color-info)', label: t('severity_low') },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MarketMakerPage: React.FC = () => {
|
export const MarketMakerPage: React.FC = () => {
|
||||||
|
const { data, isLoading, error } = useApi<MarketMakerData>('/api/v1/admin/mm/list');
|
||||||
|
|
||||||
|
const suspendMutation = useApiMutation('patch', '/api/v1/admin/mm/suspend', { invalidateKeys: ['/api/v1/admin/mm/list'] });
|
||||||
|
const resumeMutation = useApiMutation('patch', '/api/v1/admin/mm/resume', { invalidateKeys: ['/api/v1/admin/mm/list'] });
|
||||||
|
|
||||||
|
if (error) return <div style={loadingBox}>Error: {error.message}</div>;
|
||||||
|
if (isLoading) return <div style={loadingBox}>Loading...</div>;
|
||||||
|
|
||||||
|
const stats = data?.stats ?? [];
|
||||||
|
const marketMakers = data?.marketMakers ?? [];
|
||||||
|
const liquidityPools = data?.liquidityPools ?? [];
|
||||||
|
const healthIndicators = data?.healthIndicators ?? [];
|
||||||
|
const riskAlerts = data?.riskAlerts ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
|
@ -89,9 +83,9 @@ export const MarketMakerPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
font: 'var(--text-label-sm)',
|
font: 'var(--text-label-sm)',
|
||||||
color: stat.trend === 'up'
|
color: stat.invertTrend
|
||||||
? (stat.label === t('mm_avg_spread') ? 'var(--color-error)' : 'var(--color-success)')
|
? (stat.trend === 'up' ? 'var(--color-error)' : 'var(--color-success)')
|
||||||
: (stat.label === t('mm_avg_spread') ? 'var(--color-success)' : 'var(--color-error)'),
|
: (stat.trend === 'up' ? 'var(--color-success)' : 'var(--color-error)'),
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
}}>
|
}}>
|
||||||
{stat.change}
|
{stat.change}
|
||||||
|
|
@ -126,7 +120,7 @@ export const MarketMakerPage: React.FC = () => {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{marketMakers.map(mm => {
|
{marketMakers.map(mm => {
|
||||||
const s = statusConfig[mm.status];
|
const s = getStatusConfig(mm.status);
|
||||||
return (
|
return (
|
||||||
<tr key={mm.name} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
<tr key={mm.name} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
<td style={{ font: 'var(--text-label-sm)', padding: '10px 14px' }}>{mm.name}</td>
|
<td style={{ font: 'var(--text-label-sm)', padding: '10px 14px' }}>{mm.name}</td>
|
||||||
|
|
@ -137,7 +131,7 @@ export const MarketMakerPage: React.FC = () => {
|
||||||
background: s.bg,
|
background: s.bg,
|
||||||
color: s.color,
|
color: s.color,
|
||||||
font: 'var(--text-caption)',
|
font: 'var(--text-caption)',
|
||||||
}}>{s.label}</span>
|
}}>{s.label()}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px' }}>{mm.tvl}</td>
|
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px' }}>{mm.tvl}</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px' }}>{mm.spread}</td>
|
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px' }}>{mm.spread}</td>
|
||||||
|
|
@ -150,10 +144,10 @@ export const MarketMakerPage: React.FC = () => {
|
||||||
<td style={{ padding: '10px 14px', display: 'flex', gap: 4 }}>
|
<td style={{ padding: '10px 14px', display: 'flex', gap: 4 }}>
|
||||||
<button style={{ padding: '4px 10px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'none', cursor: 'pointer', font: 'var(--text-caption)', color: 'var(--color-text-secondary)' }}>{t('details')}</button>
|
<button style={{ padding: '4px 10px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'none', cursor: 'pointer', font: 'var(--text-caption)', color: 'var(--color-text-secondary)' }}>{t('details')}</button>
|
||||||
{mm.status === 'active' && (
|
{mm.status === 'active' && (
|
||||||
<button style={{ padding: '4px 10px', border: 'none', borderRadius: 'var(--radius-sm)', background: 'var(--color-warning)', cursor: 'pointer', font: 'var(--text-caption)', color: 'white' }}>{t('suspend')}</button>
|
<button onClick={() => suspendMutation.mutate({ name: mm.name })} style={{ padding: '4px 10px', border: 'none', borderRadius: 'var(--radius-sm)', background: 'var(--color-warning)', cursor: 'pointer', font: 'var(--text-caption)', color: 'white' }}>{t('suspend')}</button>
|
||||||
)}
|
)}
|
||||||
{mm.status === 'paused' && (
|
{mm.status === 'paused' && (
|
||||||
<button style={{ padding: '4px 10px', border: 'none', borderRadius: 'var(--radius-sm)', background: 'var(--color-success)', cursor: 'pointer', font: 'var(--text-caption)', color: 'white' }}>{t('resume')}</button>
|
<button onClick={() => resumeMutation.mutate({ name: mm.name })} style={{ padding: '4px 10px', border: 'none', borderRadius: 'var(--radius-sm)', background: 'var(--color-success)', cursor: 'pointer', font: 'var(--text-caption)', color: 'white' }}>{t('resume')}</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -283,7 +277,7 @@ export const MarketMakerPage: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{riskAlerts.map((alert, i) => {
|
{riskAlerts.map((alert, i) => {
|
||||||
const sev = severityConfig[alert.severity];
|
const sev = getSeverityConfig(alert.severity);
|
||||||
return (
|
return (
|
||||||
<div key={i} style={{
|
<div key={i} style={{
|
||||||
padding: 12,
|
padding: 12,
|
||||||
|
|
@ -299,7 +293,7 @@ export const MarketMakerPage: React.FC = () => {
|
||||||
background: sev.bg,
|
background: sev.bg,
|
||||||
color: sev.color,
|
color: sev.color,
|
||||||
font: 'var(--text-caption)',
|
font: 'var(--text-caption)',
|
||||||
}}>{sev.label}</span>
|
}}>{sev.label()}</span>
|
||||||
<span style={{ font: 'var(--text-label-sm)' }}>{alert.maker}</span>
|
<span style={{ font: 'var(--text-label-sm)' }}>{alert.maker}</span>
|
||||||
<span style={{ font: 'var(--text-caption)', color: 'var(--color-text-tertiary)' }}>{alert.type}</span>
|
<span style={{ font: 'var(--text-caption)', color: 'var(--color-text-tertiary)' }}>{alert.type}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import React from 'react';
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户分析仪表盘
|
* 用户分析仪表盘
|
||||||
|
|
@ -7,49 +10,32 @@ import { t } from '@/i18n/locales';
|
||||||
* 用户增长趋势、KYC分布、地理分布、留存矩阵、活跃分群
|
* 用户增长趋势、KYC分布、地理分布、留存矩阵、活跃分群
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const stats = [
|
interface UserAnalyticsData {
|
||||||
{ label: t('ua_total_users'), value: '128,456', change: '+3.2%', trend: 'up' as const, color: 'var(--color-primary)' },
|
stats: { label: string; value: string; change: string; trend: 'up' | 'down'; color: string }[];
|
||||||
{ label: 'DAU', value: '12,340', change: '+5.8%', trend: 'up' as const, color: 'var(--color-success)' },
|
kycDistribution: { level: string; count: number; percent: number; color: string }[];
|
||||||
{ label: 'MAU', value: '45,678', change: '+2.1%', trend: 'up' as const, color: 'var(--color-info)' },
|
geoDistribution: { rank: number; region: string; users: string; percent: string }[];
|
||||||
{ label: t('ua_new_users_week'), value: '1,234', change: '-1.4%', trend: 'down' as const, color: 'var(--color-warning)' },
|
cohortRetention: { cohort: string; week0: string; week1: string; week2: string; week3: string; week4: string }[];
|
||||||
];
|
segments: { name: string; count: string; percent: number; color: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
const kycDistribution = [
|
const loadingBox: React.CSSProperties = {
|
||||||
{ level: 'L0 - 未验证', count: 32_114, percent: 25, color: 'var(--color-gray-400)' },
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
{ level: 'L1 - 基础验证', count: 51_382, percent: 40, color: 'var(--color-info)' },
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
{ level: 'L2 - 身份验证', count: 33_399, percent: 26, color: 'var(--color-primary)' },
|
};
|
||||||
{ level: 'L3 - 高级验证', count: 11_561, percent: 9, color: 'var(--color-success)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const geoDistribution = [
|
|
||||||
{ rank: 1, region: '北美', users: '38,536', percent: '30.0%' },
|
|
||||||
{ rank: 2, region: '东亚', users: '29,545', percent: '23.0%' },
|
|
||||||
{ rank: 3, region: '东南亚', users: '19,268', percent: '15.0%' },
|
|
||||||
{ rank: 4, region: '欧洲', users: '14,130', percent: '11.0%' },
|
|
||||||
{ rank: 5, region: '南美', users: '9,003', percent: '7.0%' },
|
|
||||||
{ rank: 6, region: '中东', users: '5,138', percent: '4.0%' },
|
|
||||||
{ rank: 7, region: '南亚', users: '3,854', percent: '3.0%' },
|
|
||||||
{ rank: 8, region: '非洲', users: '3,854', percent: '3.0%' },
|
|
||||||
{ rank: 9, region: '大洋洲', users: '2,569', percent: '2.0%' },
|
|
||||||
{ rank: 10, region: '其他', users: '2,559', percent: '2.0%' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const cohortRetention = [
|
|
||||||
{ cohort: '第1周 (01/06)', week0: '100%', week1: '68%', week2: '52%', week3: '41%', week4: '35%' },
|
|
||||||
{ cohort: '第2周 (01/13)', week0: '100%', week1: '71%', week2: '55%', week3: '44%', week4: '38%' },
|
|
||||||
{ cohort: '第3周 (01/20)', week0: '100%', week1: '65%', week2: '49%', week3: '40%', week4: '-' },
|
|
||||||
{ cohort: '第4周 (01/27)', week0: '100%', week1: '70%', week2: '53%', week3: '-', week4: '-' },
|
|
||||||
{ cohort: '第5周 (02/03)', week0: '100%', week1: '67%', week2: '-', week3: '-', week4: '-' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const userSegments = [
|
|
||||||
{ name: t('ua_segment_high_freq'), count: '8,456', percent: 6.6, color: 'var(--color-primary)' },
|
|
||||||
{ name: t('ua_segment_occasional'), count: '34,230', percent: 26.6, color: 'var(--color-success)' },
|
|
||||||
{ name: t('ua_segment_browse'), count: '52,890', percent: 41.2, color: 'var(--color-warning)' },
|
|
||||||
{ name: t('ua_segment_churned'), count: '32,880', percent: 25.6, color: 'var(--color-error)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const UserAnalyticsPage: React.FC = () => {
|
export const UserAnalyticsPage: React.FC = () => {
|
||||||
|
const [period, setPeriod] = useState('30D');
|
||||||
|
const { data, isLoading, error } = useApi<UserAnalyticsData>('/api/v1/admin/analytics/users/stats', { params: { period } });
|
||||||
|
|
||||||
|
if (error) return <div style={loadingBox}>Error: {error.message}</div>;
|
||||||
|
if (isLoading) return <div style={loadingBox}>Loading...</div>;
|
||||||
|
|
||||||
|
const stats = data?.stats ?? [];
|
||||||
|
const kycDistribution = data?.kycDistribution ?? [];
|
||||||
|
const geoDistribution = data?.geoDistribution ?? [];
|
||||||
|
const cohortRetention = data?.cohortRetention ?? [];
|
||||||
|
const userSegments = data?.segments ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 24 }}>
|
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 24 }}>
|
||||||
|
|
@ -94,12 +80,12 @@ export const UserAnalyticsPage: React.FC = () => {
|
||||||
<span style={{ font: 'var(--text-h3)' }}>{t('ua_growth_trend')}</span>
|
<span style={{ font: 'var(--text-h3)' }}>{t('ua_growth_trend')}</span>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
{['7D', '30D', '90D', '1Y'].map(p => (
|
{['7D', '30D', '90D', '1Y'].map(p => (
|
||||||
<button key={p} style={{
|
<button key={p} onClick={() => setPeriod(p)} style={{
|
||||||
padding: '4px 10px',
|
padding: '4px 10px',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 'var(--radius-full)',
|
borderRadius: 'var(--radius-full)',
|
||||||
background: p === '30D' ? 'var(--color-primary)' : 'none',
|
background: p === period ? 'var(--color-primary)' : 'none',
|
||||||
color: p === '30D' ? 'white' : 'var(--color-text-tertiary)',
|
color: p === period ? 'white' : 'var(--color-text-tertiary)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
font: 'var(--text-caption)',
|
font: 'var(--text-caption)',
|
||||||
}}>{p}</button>
|
}}>{p}</button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D4. 链上监控 - 合约状态与链上数据
|
* D4. 链上监控 - 合约状态与链上数据
|
||||||
|
|
@ -8,19 +11,16 @@ import { t } from '@/i18n/locales';
|
||||||
* 注:此页面仅对平台管理员可见,发行方/消费者不可见
|
* 注:此页面仅对平台管理员可见,发行方/消费者不可见
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const contractStats = [
|
interface ChainMonitorData {
|
||||||
{ label: 'CouponFactory', status: 'Active', txCount: '45,231', lastBlock: '#18,234,567' },
|
contracts: { label: string; status: string; txCount: string; lastBlock: string }[];
|
||||||
{ label: 'Marketplace', status: 'Active', txCount: '12,890', lastBlock: '#18,234,560' },
|
events: { event: string; detail: string; hash: string; time: string; type: string }[];
|
||||||
{ label: 'RedemptionGateway', status: 'Active', txCount: '38,456', lastBlock: '#18,234,565' },
|
gasMonitor: { current: string; average: string; todaySpend: string };
|
||||||
{ label: 'StablecoinBridge', status: 'Active', txCount: '8,901', lastBlock: '#18,234,555' },
|
}
|
||||||
];
|
|
||||||
|
|
||||||
const recentEvents = [
|
const loadingBox: React.CSSProperties = {
|
||||||
{ event: 'Mint', detail: 'Starbucks $25 Gift Card x500', hash: '0xabc...def', time: '2分钟前', type: 'mint' },
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
{ event: 'Transfer', detail: 'P2P Transfer #1234', hash: '0x123...456', time: '5分钟前', type: 'transfer' },
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
{ event: 'Redeem', detail: 'Batch Redeem x8', hash: '0x789...abc', time: '8分钟前', type: 'redeem' },
|
};
|
||||||
{ event: 'Burn', detail: 'Expired coupons x12', hash: '0xdef...789', time: '15分钟前', type: 'burn' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const eventColors: Record<string, string> = {
|
const eventColors: Record<string, string> = {
|
||||||
mint: 'var(--color-success)',
|
mint: 'var(--color-success)',
|
||||||
|
|
@ -30,6 +30,15 @@ const eventColors: Record<string, string> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChainMonitorPage: React.FC = () => {
|
export const ChainMonitorPage: React.FC = () => {
|
||||||
|
const { data, isLoading, error } = useApi<ChainMonitorData>('/api/v1/admin/chain/contracts');
|
||||||
|
|
||||||
|
if (error) return <div style={loadingBox}>Error: {error.message}</div>;
|
||||||
|
if (isLoading) return <div style={loadingBox}>Loading...</div>;
|
||||||
|
|
||||||
|
const contractStats = data?.contracts ?? [];
|
||||||
|
const recentEvents = data?.events ?? [];
|
||||||
|
const gasMonitor = data?.gasMonitor ?? { current: '-', average: '-', todaySpend: '-' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 24 }}>{t('chain_title')}</h1>
|
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 24 }}>{t('chain_title')}</h1>
|
||||||
|
|
@ -45,7 +54,9 @@ export const ChainMonitorPage: React.FC = () => {
|
||||||
<span style={{ font: 'var(--text-label)', color: 'var(--color-text-primary)' }}>{c.label}</span>
|
<span style={{ font: 'var(--text-label)', color: 'var(--color-text-primary)' }}>{c.label}</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)',
|
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)',
|
||||||
background: 'var(--color-success-light)', color: 'var(--color-success)', fontWeight: 600,
|
background: c.status === 'Active' ? 'var(--color-success-light)' : 'var(--color-error-light)',
|
||||||
|
color: c.status === 'Active' ? 'var(--color-success)' : 'var(--color-error)',
|
||||||
|
fontWeight: 600,
|
||||||
}}>{c.status}</span>
|
}}>{c.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)' }}>TX: {c.txCount} · Block: {c.lastBlock}</div>
|
<div style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)' }}>TX: {c.txCount} · Block: {c.lastBlock}</div>
|
||||||
|
|
@ -60,7 +71,7 @@ export const ChainMonitorPage: React.FC = () => {
|
||||||
{recentEvents.map((e, i) => (
|
{recentEvents.map((e, i) => (
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', padding: '10px 0', borderBottom: '1px solid var(--color-border-light)' }}>
|
<div key={i} style={{ display: 'flex', alignItems: 'center', padding: '10px 0', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 8, height: 8, borderRadius: '50%', background: eventColors[e.type], marginRight: 12,
|
width: 8, height: 8, borderRadius: '50%', background: eventColors[e.type] ?? 'var(--color-gray-400)', marginRight: 12,
|
||||||
}} />
|
}} />
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ font: 'var(--text-label)', color: 'var(--color-text-primary)' }}>{e.event}</div>
|
<div style={{ font: 'var(--text-label)', color: 'var(--color-text-primary)' }}>{e.event}</div>
|
||||||
|
|
@ -79,9 +90,9 @@ export const ChainMonitorPage: React.FC = () => {
|
||||||
<h2 style={{ font: 'var(--text-h2)', marginBottom: 16 }}>{t('chain_gas_monitor')}</h2>
|
<h2 style={{ font: 'var(--text-h2)', marginBottom: 16 }}>{t('chain_gas_monitor')}</h2>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, marginBottom: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, marginBottom: 16 }}>
|
||||||
{[
|
{[
|
||||||
{ label: t('chain_current_gas'), value: '12 gwei', color: 'var(--color-success)' },
|
{ label: t('chain_current_gas'), value: gasMonitor.current, color: 'var(--color-success)' },
|
||||||
{ label: t('chain_today_avg'), value: '18 gwei', color: 'var(--color-info)' },
|
{ label: t('chain_today_avg'), value: gasMonitor.average, color: 'var(--color-info)' },
|
||||||
{ label: t('chain_today_gas_spend'), value: '$1,234', color: 'var(--color-warning)' },
|
{ label: t('chain_today_gas_spend'), value: gasMonitor.todaySpend, color: 'var(--color-warning)' },
|
||||||
].map(g => (
|
].map(g => (
|
||||||
<div key={g.label} style={{ textAlign: 'center', padding: 12, background: 'var(--color-gray-50)', borderRadius: 'var(--radius-sm)' }}>
|
<div key={g.label} style={{ textAlign: 'center', padding: 12, background: 'var(--color-gray-50)', borderRadius: 'var(--radius-sm)' }}>
|
||||||
<div style={{ font: 'var(--text-h3)', color: g.color }}>{g.value}</div>
|
<div style={{ font: 'var(--text-h3)', color: g.color }}>{g.value}</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D6. 合规报表
|
* D6. 合规报表
|
||||||
|
|
@ -9,9 +10,58 @@ import { t } from '@/i18n/locales';
|
||||||
* SAR管理、CTR管理、审计日志、监管报表
|
* SAR管理、CTR管理、审计日志、监管报表
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
interface SarRecord {
|
||||||
|
id: string;
|
||||||
|
txn: string;
|
||||||
|
user: string;
|
||||||
|
amount: number;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditLog {
|
||||||
|
time: string;
|
||||||
|
action: string;
|
||||||
|
detail: string;
|
||||||
|
ip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComplianceReport {
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
date: string;
|
||||||
|
auto: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComplianceData {
|
||||||
|
sar: SarRecord[];
|
||||||
|
sarPendingCount: number;
|
||||||
|
auditLogs: AuditLog[];
|
||||||
|
reports: ComplianceReport[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingBox: React.CSSProperties = {
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
|
};
|
||||||
|
|
||||||
export const CompliancePage: React.FC = () => {
|
export const CompliancePage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<'sar' | 'ctr' | 'audit' | 'reports'>('sar');
|
const [activeTab, setActiveTab] = useState<'sar' | 'ctr' | 'audit' | 'reports'>('sar');
|
||||||
|
|
||||||
|
const { data: sarData, isLoading: sarLoading } = useApi<{ items: SarRecord[]; pendingCount: number }>(
|
||||||
|
activeTab === 'sar' ? '/api/v1/admin/compliance/sar' : null,
|
||||||
|
);
|
||||||
|
const { data: auditData, isLoading: auditLoading } = useApi<AuditLog[]>(
|
||||||
|
activeTab === 'audit' ? '/api/v1/admin/compliance/audit-logs' : null,
|
||||||
|
);
|
||||||
|
const { data: reportsData, isLoading: reportsLoading } = useApi<ComplianceReport[]>(
|
||||||
|
activeTab === 'reports' ? '/api/v1/admin/compliance/reports' : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sarItems = sarData?.items ?? [];
|
||||||
|
const sarPendingCount = sarData?.pendingCount ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
|
@ -32,7 +82,7 @@ export const CompliancePage: React.FC = () => {
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid var(--color-border-light)', paddingBottom: 0 }}>
|
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid var(--color-border-light)', paddingBottom: 0 }}>
|
||||||
{[
|
{[
|
||||||
{ key: 'sar', label: t('compliance_tab_sar'), badge: 3 },
|
{ key: 'sar', label: t('compliance_tab_sar'), badge: sarPendingCount },
|
||||||
{ key: 'ctr', label: t('compliance_tab_ctr'), badge: 0 },
|
{ key: 'ctr', label: t('compliance_tab_ctr'), badge: 0 },
|
||||||
{ key: 'audit', label: t('compliance_tab_audit'), badge: 0 },
|
{ key: 'audit', label: t('compliance_tab_audit'), badge: 0 },
|
||||||
{ key: 'reports', label: t('compliance_tab_reports'), badge: 0 },
|
{ key: 'reports', label: t('compliance_tab_reports'), badge: 0 },
|
||||||
|
|
@ -75,6 +125,9 @@ export const CompliancePage: React.FC = () => {
|
||||||
border: '1px solid var(--color-border-light)',
|
border: '1px solid var(--color-border-light)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
|
{sarLoading ? (
|
||||||
|
<div style={loadingBox}>Loading...</div>
|
||||||
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: 'var(--color-gray-50)' }}>
|
<tr style={{ background: 'var(--color-gray-50)' }}>
|
||||||
|
|
@ -84,16 +137,12 @@ export const CompliancePage: React.FC = () => {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{[
|
{sarItems.map(sar => (
|
||||||
{ id: 'SAR-2026-001', txn: 'TXN-8901', user: 'U-045', amount: '$4,560', type: t('risk_type_high_freq'), status: t('compliance_sar_pending') },
|
|
||||||
{ id: 'SAR-2026-002', txn: 'TXN-8900', user: 'U-078', amount: '$8,900', type: t('risk_type_large_single'), status: t('compliance_sar_submitted') },
|
|
||||||
{ id: 'SAR-2026-003', txn: 'TXN-8850', user: 'U-012', amount: '$12,000', type: t('risk_type_related_account'), status: t('compliance_sar_submitted') },
|
|
||||||
].map(sar => (
|
|
||||||
<tr key={sar.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
<tr key={sar.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
<td style={{ font: 'var(--text-body-sm)', fontFamily: 'var(--font-family-mono)', padding: '10px 14px' }}>{sar.id}</td>
|
<td style={{ font: 'var(--text-body-sm)', fontFamily: 'var(--font-family-mono)', padding: '10px 14px' }}>{sar.id}</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px', color: 'var(--color-text-link)' }}>{sar.txn}</td>
|
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px', color: 'var(--color-text-link)' }}>{sar.txn}</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px' }}>{sar.user}</td>
|
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px' }}>{sar.user}</td>
|
||||||
<td style={{ font: 'var(--text-label-sm)', color: 'var(--color-error)', padding: '10px 14px' }}>{sar.amount}</td>
|
<td style={{ font: 'var(--text-label-sm)', color: 'var(--color-error)', padding: '10px 14px' }}>${sar.amount?.toLocaleString()}</td>
|
||||||
<td style={{ padding: '10px 14px' }}>
|
<td style={{ padding: '10px 14px' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 'var(--radius-full)',
|
padding: '2px 8px', borderRadius: 'var(--radius-full)',
|
||||||
|
|
@ -104,12 +153,14 @@ export const CompliancePage: React.FC = () => {
|
||||||
<td style={{ padding: '10px 14px' }}>
|
<td style={{ padding: '10px 14px' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 'var(--radius-full)',
|
padding: '2px 8px', borderRadius: 'var(--radius-full)',
|
||||||
background: sar.status === t('compliance_sar_submitted') ? 'var(--color-success-light)' : 'var(--color-warning-light)',
|
background: sar.status === 'submitted' ? 'var(--color-success-light)' : 'var(--color-warning-light)',
|
||||||
color: sar.status === t('compliance_sar_submitted') ? 'var(--color-success)' : 'var(--color-warning)',
|
color: sar.status === 'submitted' ? 'var(--color-success)' : 'var(--color-warning)',
|
||||||
font: 'var(--text-caption)',
|
font: 'var(--text-caption)',
|
||||||
}}>{sar.status}</span>
|
}}>
|
||||||
|
{sar.status === 'submitted' ? t('compliance_sar_submitted') : t('compliance_sar_pending')}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', padding: '10px 14px' }}>2026-02-{10 - parseInt(sar.id.slice(-1))}</td>
|
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', padding: '10px 14px' }}>{sar.createdAt}</td>
|
||||||
<td style={{ padding: '10px 14px' }}>
|
<td style={{ padding: '10px 14px' }}>
|
||||||
<button style={{ padding: '4px 12px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'none', cursor: 'pointer', font: 'var(--text-caption)', color: 'var(--color-primary)' }}>{t('view')}</button>
|
<button style={{ padding: '4px 12px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'none', cursor: 'pointer', font: 'var(--text-caption)', color: 'var(--color-primary)' }}>{t('view')}</button>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -117,6 +168,7 @@ export const CompliancePage: React.FC = () => {
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -153,7 +205,9 @@ export const CompliancePage: React.FC = () => {
|
||||||
{t('export')}
|
{t('export')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{Array.from({ length: 6 }, (_, i) => (
|
{auditLoading ? (
|
||||||
|
<div style={loadingBox}>Loading...</div>
|
||||||
|
) : (auditData ?? []).map((log, i) => (
|
||||||
<div key={i} style={{
|
<div key={i} style={{
|
||||||
padding: '12px 16px',
|
padding: '12px 16px',
|
||||||
borderBottom: '1px solid var(--color-border-light)',
|
borderBottom: '1px solid var(--color-border-light)',
|
||||||
|
|
@ -161,19 +215,19 @@ export const CompliancePage: React.FC = () => {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 12,
|
gap: 12,
|
||||||
}}>
|
}}>
|
||||||
<span style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', width: 80 }}>14:{30 + i}:00</span>
|
<span style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', width: 80 }}>{log.time}</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 'var(--radius-full)',
|
padding: '2px 8px', borderRadius: 'var(--radius-full)',
|
||||||
background: 'var(--color-info-light)', color: 'var(--color-info)',
|
background: 'var(--color-info-light)', color: 'var(--color-info)',
|
||||||
font: 'var(--text-caption)',
|
font: 'var(--text-caption)',
|
||||||
}}>
|
}}>
|
||||||
{[t('compliance_audit_action_login'), t('compliance_audit_action_review'), t('compliance_audit_action_config'), t('compliance_audit_action_freeze'), t('compliance_audit_action_export'), t('compliance_audit_action_query')][i]}
|
{log.action}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ font: 'var(--text-body-sm)', flex: 1 }}>
|
<span style={{ font: 'var(--text-body-sm)', flex: 1 }}>
|
||||||
管理员 admin{i + 1} {['登录系统', '审核发行方ISS-003通过', '修改手续费率为2.5%', '冻结用户U-045', '导出月度报表', '查询OFAC筛查记录'][i]}
|
{log.detail}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ font: 'var(--text-caption)', color: 'var(--color-text-tertiary)' }}>
|
<span style={{ font: 'var(--text-caption)', color: 'var(--color-text-tertiary)' }}>
|
||||||
192.168.1.{100 + i}
|
{log.ip}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -182,13 +236,11 @@ export const CompliancePage: React.FC = () => {
|
||||||
|
|
||||||
{/* Reports Tab */}
|
{/* Reports Tab */}
|
||||||
{activeTab === 'reports' && (
|
{activeTab === 'reports' && (
|
||||||
|
reportsLoading ? (
|
||||||
|
<div style={loadingBox}>Loading...</div>
|
||||||
|
) : (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16 }}>
|
||||||
{[
|
{(reportsData ?? []).map(report => (
|
||||||
{ title: t('compliance_report_daily'), desc: '每日交易汇总、异常事件', date: '2026-02-10', auto: true },
|
|
||||||
{ title: t('compliance_report_monthly'), desc: '月度运营指标、合规状态', date: '2026-01-31', auto: true },
|
|
||||||
{ title: t('compliance_report_sar_quarterly'), desc: '季度可疑活动报告汇总', date: '2025-12-31', auto: false },
|
|
||||||
{ title: t('compliance_report_annual'), desc: '年度合规审计报告', date: '2025-12-31', auto: false },
|
|
||||||
].map(report => (
|
|
||||||
<div key={report.title} style={{
|
<div key={report.title} style={{
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-md)',
|
||||||
|
|
@ -221,6 +273,7 @@ export const CompliancePage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D8.4 IPO准备度检查清单 - 独立页面
|
* D8.4 IPO准备度检查清单 - 独立页面
|
||||||
*
|
|
||||||
* 法律/财务/合规/治理/保险 五大类别
|
|
||||||
* Gantt时间线、依赖管理、阻塞项跟踪
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface CheckItem {
|
interface CheckItem {
|
||||||
|
|
@ -19,74 +19,45 @@ interface CheckItem {
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const categories = [
|
interface IpoData {
|
||||||
{ key: 'legal', label: t('ipo_cat_legal'), icon: '§', color: 'var(--color-primary)' },
|
overallProgress: { total: number; done: number; inProgress: number; blocked: number; pending: number; percent: number };
|
||||||
{ key: 'financial', label: t('ipo_cat_financial'), icon: '$', color: 'var(--color-success)' },
|
milestones: { name: string; date: string; status: 'done' | 'progress' | 'pending' }[];
|
||||||
{ key: 'sox', label: t('ipo_cat_sox'), icon: '✓', color: 'var(--color-info)' },
|
checklistItems: CheckItem[];
|
||||||
{ key: 'governance', label: t('ipo_cat_governance'), icon: '◆', color: 'var(--color-warning)' },
|
keyContacts: { role: string; name: string; status: string }[];
|
||||||
{ key: 'insurance', label: t('ipo_cat_insurance'), icon: '☂', color: '#FF6B6B' },
|
}
|
||||||
];
|
|
||||||
|
|
||||||
const overallProgress = {
|
const loadingBox: React.CSSProperties = {
|
||||||
total: 28,
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
done: 16,
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
inProgress: 7,
|
|
||||||
blocked: 2,
|
|
||||||
pending: 3,
|
|
||||||
percent: 72,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const milestones: { name: string; date: string; status: 'done' | 'progress' | 'pending' }[] = [
|
const categories = [
|
||||||
{ name: 'S-1初稿提交', date: '2026-Q2', status: 'progress' },
|
{ key: 'legal', label: () => t('ipo_cat_legal'), icon: '§', color: 'var(--color-primary)' },
|
||||||
{ name: 'SEC审核期', date: '2026-Q3', status: 'pending' },
|
{ key: 'financial', label: () => t('ipo_cat_financial'), icon: '$', color: 'var(--color-success)' },
|
||||||
{ name: '路演 (Roadshow)', date: '2026-Q3', status: 'pending' },
|
{ key: 'sox', label: () => t('ipo_cat_sox'), icon: '✓', color: 'var(--color-info)' },
|
||||||
{ name: '定价 & 上市', date: '2026-Q4', status: 'pending' },
|
{ key: 'governance', label: () => t('ipo_cat_governance'), icon: '◆', color: 'var(--color-warning)' },
|
||||||
|
{ key: 'insurance', label: () => t('ipo_cat_insurance'), icon: '☂', color: '#FF6B6B' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const checklistItems: CheckItem[] = [
|
const statusConfig: Record<string, { label: () => string; bg: string; fg: string }> = {
|
||||||
// Legal
|
done: { label: () => t('completed'), bg: 'var(--color-success-light)', fg: 'var(--color-success)' },
|
||||||
{ id: 'L1', item: 'FinCEN MSB牌照', category: 'legal', status: 'done', owner: 'Legal', deadline: '2026-01-15' },
|
progress: { label: () => t('in_progress'), bg: 'var(--color-warning-light)', fg: 'var(--color-warning)' },
|
||||||
{ id: 'L2', item: 'NY BitLicense申请', category: 'legal', status: 'progress', owner: 'Legal', deadline: '2026-06-30', note: '材料已提交,等待审核' },
|
blocked: { label: () => t('blocked'), bg: 'var(--color-error-light)', fg: 'var(--color-error)' },
|
||||||
{ id: 'L3', item: '各州MTL注册 (48州)', category: 'legal', status: 'progress', owner: 'Legal', deadline: '2026-05-31', note: '已完成38/48州' },
|
pending: { label: () => t('pending'), bg: 'var(--color-gray-100)', fg: 'var(--color-text-tertiary)' },
|
||||||
{ id: 'L4', item: 'SEC法律顾问意见书', category: 'legal', status: 'progress', owner: 'External Counsel', deadline: '2026-04-30' },
|
|
||||||
{ id: 'L5', item: '知识产权审计 (IP Audit)', category: 'legal', status: 'done', owner: 'Legal', deadline: '2026-02-01' },
|
|
||||||
{ id: 'L6', item: '商标注册 (USPTO)', category: 'legal', status: 'done', owner: 'Legal', deadline: '2026-01-20' },
|
|
||||||
// Financial
|
|
||||||
{ id: 'F1', item: '独立审计报告 (Deloitte)', category: 'financial', status: 'progress', owner: 'Finance', deadline: '2026-05-15', dependency: 'F2' },
|
|
||||||
{ id: 'F2', item: 'GAAP财务报表 (3年)', category: 'financial', status: 'done', owner: 'Finance', deadline: '2026-03-01' },
|
|
||||||
{ id: 'F3', item: '税务合规证明', category: 'financial', status: 'done', owner: 'Finance', deadline: '2026-02-28' },
|
|
||||||
{ id: 'F4', item: '收入确认政策 (ASC 606)', category: 'financial', status: 'done', owner: 'Finance', deadline: '2026-02-15' },
|
|
||||||
{ id: 'F5', item: '估值模型 & 定价区间', category: 'financial', status: 'pending', owner: 'Finance + IB', deadline: '2026-07-31' },
|
|
||||||
// SOX
|
|
||||||
{ id: 'S1', item: 'ICFR内部控制框架', category: 'sox', status: 'done', owner: 'Compliance', deadline: '2026-01-31' },
|
|
||||||
{ id: 'S2', item: 'IT通用控制 (ITGC)', category: 'sox', status: 'done', owner: 'Engineering', deadline: '2026-02-15' },
|
|
||||||
{ id: 'S3', item: '访问控制审计', category: 'sox', status: 'done', owner: 'Security', deadline: '2026-02-10' },
|
|
||||||
{ id: 'S4', item: '变更管理流程', category: 'sox', status: 'done', owner: 'Engineering', deadline: '2026-02-01' },
|
|
||||||
{ id: 'S5', item: 'SOX 404管理层评估', category: 'sox', status: 'progress', owner: 'Compliance', deadline: '2026-05-31', dependency: 'S1' },
|
|
||||||
// Governance
|
|
||||||
{ id: 'G1', item: '独立董事会组建 (3+)', category: 'governance', status: 'done', owner: 'Board', deadline: '2026-03-01', note: '4名独立董事已任命' },
|
|
||||||
{ id: 'G2', item: '审计委员会', category: 'governance', status: 'done', owner: 'Board', deadline: '2026-03-15' },
|
|
||||||
{ id: 'G3', item: '薪酬委员会', category: 'governance', status: 'done', owner: 'Board', deadline: '2026-03-15' },
|
|
||||||
{ id: 'G4', item: '公司章程 & 治理政策', category: 'governance', status: 'done', owner: 'Legal', deadline: '2026-02-28' },
|
|
||||||
{ id: 'G5', item: 'D&O保险', category: 'governance', status: 'blocked', owner: 'Legal', deadline: '2026-04-30', note: '等待承保方最终报价' },
|
|
||||||
{ id: 'G6', item: 'Insider Trading Policy', category: 'governance', status: 'done', owner: 'Legal', deadline: '2026-03-01' },
|
|
||||||
// Insurance
|
|
||||||
{ id: 'I1', item: '消费者保护基金 ≥$2M', category: 'insurance', status: 'done', owner: 'Finance', deadline: '2026-02-01' },
|
|
||||||
{ id: 'I2', item: 'AML/KYC体系', category: 'insurance', status: 'done', owner: 'Compliance', deadline: '2026-01-15' },
|
|
||||||
{ id: 'I3', item: '赔付机制运行报告', category: 'insurance', status: 'progress', owner: 'Operations', deadline: '2026-05-01' },
|
|
||||||
{ id: 'I4', item: 'Cyber保险', category: 'insurance', status: 'blocked', owner: 'Legal', deadline: '2026-04-15', note: '正在比价3家承保方' },
|
|
||||||
{ id: 'I5', item: '做市商协议签署', category: 'insurance', status: 'pending', owner: 'Finance + IB', deadline: '2026-07-15' },
|
|
||||||
{ id: 'I6', item: '承销商尽职调查', category: 'insurance', status: 'pending', owner: 'External', deadline: '2026-08-01' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusConfig: Record<string, { label: string; bg: string; fg: string }> = {
|
|
||||||
done: { label: t('completed'), bg: 'var(--color-success-light)', fg: 'var(--color-success)' },
|
|
||||||
progress: { label: t('in_progress'), bg: 'var(--color-warning-light)', fg: 'var(--color-warning)' },
|
|
||||||
blocked: { label: t('blocked'), bg: 'var(--color-error-light)', fg: 'var(--color-error)' },
|
|
||||||
pending: { label: t('pending'), bg: 'var(--color-gray-100)', fg: 'var(--color-text-tertiary)' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IpoReadinessPage: React.FC = () => {
|
export const IpoReadinessPage: React.FC = () => {
|
||||||
|
const { data: ipoData, isLoading, error } = useApi<IpoData>('/api/v1/admin/compliance/reports');
|
||||||
|
const { data: insuranceData } = useApi<{ ipoReadiness: number }>('/api/v1/admin/insurance/stats');
|
||||||
|
|
||||||
|
if (error) return <div style={loadingBox}>Error: {error.message}</div>;
|
||||||
|
if (isLoading) return <div style={loadingBox}>Loading...</div>;
|
||||||
|
|
||||||
|
const overallProgress = ipoData?.overallProgress ?? { total: 0, done: 0, inProgress: 0, blocked: 0, pending: 0, percent: 0 };
|
||||||
|
const milestones = ipoData?.milestones ?? [];
|
||||||
|
const checklistItems = ipoData?.checklistItems ?? [];
|
||||||
|
const keyContacts = ipoData?.keyContacts ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 8 }}>{t('ipo_title')}</h1>
|
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 8 }}>{t('ipo_title')}</h1>
|
||||||
|
|
@ -123,9 +94,9 @@ export const IpoReadinessPage: React.FC = () => {
|
||||||
<span style={{ font: 'var(--text-h2)', color: 'var(--color-primary)' }}>{overallProgress.percent}%</span>
|
<span style={{ font: 'var(--text-h2)', color: 'var(--color-primary)' }}>{overallProgress.percent}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 12, background: 'var(--color-gray-100)', borderRadius: 'var(--radius-full)', overflow: 'hidden', display: 'flex' }}>
|
<div style={{ height: 12, background: 'var(--color-gray-100)', borderRadius: 'var(--radius-full)', overflow: 'hidden', display: 'flex' }}>
|
||||||
<div style={{ width: `${(overallProgress.done / overallProgress.total * 100).toFixed(0)}%`, height: '100%', background: 'var(--color-success)' }} />
|
<div style={{ width: `${overallProgress.total > 0 ? (overallProgress.done / overallProgress.total * 100).toFixed(0) : 0}%`, height: '100%', background: 'var(--color-success)' }} />
|
||||||
<div style={{ width: `${(overallProgress.inProgress / overallProgress.total * 100).toFixed(0)}%`, height: '100%', background: 'var(--color-warning)' }} />
|
<div style={{ width: `${overallProgress.total > 0 ? (overallProgress.inProgress / overallProgress.total * 100).toFixed(0) : 0}%`, height: '100%', background: 'var(--color-warning)' }} />
|
||||||
<div style={{ width: `${(overallProgress.blocked / overallProgress.total * 100).toFixed(0)}%`, height: '100%', background: 'var(--color-error)' }} />
|
<div style={{ width: `${overallProgress.total > 0 ? (overallProgress.blocked / overallProgress.total * 100).toFixed(0) : 0}%`, height: '100%', background: 'var(--color-error)' }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 16, marginTop: 8 }}>
|
<div style={{ display: 'flex', gap: 16, marginTop: 8 }}>
|
||||||
{[
|
{[
|
||||||
|
|
@ -148,6 +119,7 @@ export const IpoReadinessPage: React.FC = () => {
|
||||||
{categories.map(cat => {
|
{categories.map(cat => {
|
||||||
const items = checklistItems.filter(i => i.category === cat.key);
|
const items = checklistItems.filter(i => i.category === cat.key);
|
||||||
const catDone = items.filter(i => i.status === 'done').length;
|
const catDone = items.filter(i => i.status === 'done').length;
|
||||||
|
if (items.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={cat.key} style={{
|
<div key={cat.key} style={{
|
||||||
background: 'var(--color-surface)', borderRadius: 'var(--radius-md)',
|
background: 'var(--color-surface)', borderRadius: 'var(--radius-md)',
|
||||||
|
|
@ -162,13 +134,12 @@ export const IpoReadinessPage: React.FC = () => {
|
||||||
}}>
|
}}>
|
||||||
{cat.icon}
|
{cat.icon}
|
||||||
</div>
|
</div>
|
||||||
<span style={{ font: 'var(--text-h3)', color: 'var(--color-text-primary)' }}>{cat.label}</span>
|
<span style={{ font: 'var(--text-h3)', color: 'var(--color-text-primary)' }}>{cat.label()}</span>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ font: 'var(--text-label-sm)', color: 'var(--color-text-secondary)' }}>
|
<span style={{ font: 'var(--text-label-sm)', color: 'var(--color-text-secondary)' }}>
|
||||||
{catDone}/{items.length} {t('ipo_unit_done')}
|
{catDone}/{items.length} {t('ipo_unit_done')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -201,7 +172,7 @@ export const IpoReadinessPage: React.FC = () => {
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)',
|
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)',
|
||||||
fontWeight: 600, background: st.bg, color: st.fg,
|
fontWeight: 600, background: st.bg, color: st.fg,
|
||||||
}}>{st.label}</span>
|
}}>{st.label()}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
@ -222,7 +193,7 @@ export const IpoReadinessPage: React.FC = () => {
|
||||||
}}>
|
}}>
|
||||||
<h2 style={{ font: 'var(--text-h2)', marginBottom: 16 }}>{t('ipo_timeline')}</h2>
|
<h2 style={{ font: 'var(--text-h2)', marginBottom: 16 }}>{t('ipo_timeline')}</h2>
|
||||||
{milestones.map((m, i) => (
|
{milestones.map((m, i) => (
|
||||||
<div key={m.name} style={{ display: 'flex', gap: 12, marginBottom: i < milestones.length - 1 ? 0 : 0 }}>
|
<div key={m.name} style={{ display: 'flex', gap: 12 }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 12, height: 12, borderRadius: '50%',
|
width: 12, height: 12, borderRadius: '50%',
|
||||||
|
|
@ -270,12 +241,13 @@ export const IpoReadinessPage: React.FC = () => {
|
||||||
<h2 style={{ font: 'var(--text-h2)', marginBottom: 16 }}>{t('ipo_category_progress')}</h2>
|
<h2 style={{ font: 'var(--text-h2)', marginBottom: 16 }}>{t('ipo_category_progress')}</h2>
|
||||||
{categories.map(cat => {
|
{categories.map(cat => {
|
||||||
const items = checklistItems.filter(i => i.category === cat.key);
|
const items = checklistItems.filter(i => i.category === cat.key);
|
||||||
|
if (items.length === 0) return null;
|
||||||
const catDone = items.filter(i => i.status === 'done').length;
|
const catDone = items.filter(i => i.status === 'done').length;
|
||||||
const pct = Math.round(catDone / items.length * 100);
|
const pct = Math.round(catDone / items.length * 100);
|
||||||
return (
|
return (
|
||||||
<div key={cat.key} style={{ marginBottom: 14 }}>
|
<div key={cat.key} style={{ marginBottom: 14 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
<span style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-primary)' }}>{cat.label}</span>
|
<span style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-primary)' }}>{cat.label()}</span>
|
||||||
<span style={{ font: 'var(--text-label-sm)', color: cat.color }}>{pct}%</span>
|
<span style={{ font: 'var(--text-label-sm)', color: cat.color }}>{pct}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 6, background: 'var(--color-gray-100)', borderRadius: 'var(--radius-full)', overflow: 'hidden' }}>
|
<div style={{ height: 6, background: 'var(--color-gray-100)', borderRadius: 'var(--radius-full)', overflow: 'hidden' }}>
|
||||||
|
|
@ -292,12 +264,7 @@ export const IpoReadinessPage: React.FC = () => {
|
||||||
border: '1px solid var(--color-border-light)', padding: 20,
|
border: '1px solid var(--color-border-light)', padding: 20,
|
||||||
}}>
|
}}>
|
||||||
<h2 style={{ font: 'var(--text-h2)', marginBottom: 16 }}>{t('ipo_key_contacts')}</h2>
|
<h2 style={{ font: 'var(--text-h2)', marginBottom: 16 }}>{t('ipo_key_contacts')}</h2>
|
||||||
{[
|
{keyContacts.map(c => (
|
||||||
{ role: '承销商 (Lead)', name: 'Goldman Sachs', status: '已签约' },
|
|
||||||
{ role: '审计师', name: 'Deloitte', status: '审计中' },
|
|
||||||
{ role: '法律顾问', name: 'Skadden, Arps', status: '已签约' },
|
|
||||||
{ role: 'SEC联络', name: 'SEC Division of Corp Finance', status: '对接中' },
|
|
||||||
].map(c => (
|
|
||||||
<div key={c.role} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--color-border-light)' }}>
|
<div key={c.role} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ font: 'var(--text-label-sm)', color: 'var(--color-text-primary)' }}>{c.role}</div>
|
<div style={{ font: 'var(--text-label-sm)', color: 'var(--color-text-primary)' }}>{c.role}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,97 +1,77 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* License & Regulatory Permits Management - 牌照与监管许可管理
|
* License & Regulatory Permits Management - 牌照与监管许可管理
|
||||||
*
|
|
||||||
* 管理平台在各司法管辖区的金融牌照、监管许可证状态,
|
|
||||||
* 包括MSB、MTL、各州Money Transmitter License等,追踪续期与申请进度。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const licenseStats = [
|
interface LicenseData {
|
||||||
{ label: t('license_active_count'), value: '12', color: 'var(--color-success)' },
|
stats: { label: string; value: string; color: string }[];
|
||||||
{ label: t('license_pending'), value: '4', color: 'var(--color-info)' },
|
licenses: { id: string; name: string; jurisdiction: string; regBody: string; status: string; issueDate: string; expiryDate: string }[];
|
||||||
{ label: t('license_expiring_soon'), value: '2', color: 'var(--color-warning)' },
|
regulatoryBodies: { name: string; fullName: string; scope: string; licenses: number }[];
|
||||||
{ label: t('license_revoked'), value: '0', color: 'var(--color-error)' },
|
renewalAlerts: { license: string; expiryDate: string; daysRemaining: number; urgency: string }[];
|
||||||
];
|
activeLicenseCount: number;
|
||||||
|
jurisdictionsCovered: number;
|
||||||
|
}
|
||||||
|
|
||||||
const licenses = [
|
const loadingBox: React.CSSProperties = {
|
||||||
{ id: 'LIC-001', name: 'FinCEN MSB Registration', jurisdiction: 'Federal (US)', regBody: 'FinCEN', status: t('license_status_active'), issueDate: '2024-06-01', expiryDate: '2026-06-01' },
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
{ id: 'LIC-002', name: 'New York BitLicense', jurisdiction: 'New York', regBody: 'NYDFS', status: t('license_status_active'), issueDate: '2024-09-15', expiryDate: '2026-09-15' },
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
{ id: 'LIC-003', name: 'California MTL', jurisdiction: 'California', regBody: 'DFPI', status: t('license_status_active'), issueDate: '2025-01-10', expiryDate: '2027-01-10' },
|
};
|
||||||
{ id: 'LIC-004', name: 'Texas Money Transmitter', jurisdiction: 'Texas', regBody: 'TDSML', status: t('license_status_expiring'), issueDate: '2024-03-20', expiryDate: '2026-03-20' },
|
|
||||||
{ id: 'LIC-005', name: 'Florida Money Transmitter', jurisdiction: 'Florida', regBody: 'OFR', status: t('license_status_active'), issueDate: '2025-04-01', expiryDate: '2027-04-01' },
|
|
||||||
{ id: 'LIC-006', name: 'Illinois TOMA', jurisdiction: 'Illinois', regBody: 'IDFPR', status: t('license_status_applying'), issueDate: '-', expiryDate: '-' },
|
|
||||||
{ id: 'LIC-007', name: 'Washington Money Transmitter', jurisdiction: 'Washington', regBody: 'DFI', status: t('license_status_active'), issueDate: '2025-02-15', expiryDate: '2027-02-15' },
|
|
||||||
{ id: 'LIC-008', name: 'SEC Broker-Dealer Registration', jurisdiction: 'Federal (US)', regBody: 'SEC / FINRA', status: t('license_status_applying'), issueDate: '-', expiryDate: '-' },
|
|
||||||
{ id: 'LIC-009', name: 'Georgia Money Transmitter', jurisdiction: 'Georgia', regBody: 'DBF', status: t('license_status_renewal'), issueDate: '2024-02-28', expiryDate: '2026-02-28' },
|
|
||||||
{ id: 'LIC-010', name: 'Nevada Money Transmitter', jurisdiction: 'Nevada', regBody: 'FID', status: t('license_status_active'), issueDate: '2025-06-01', expiryDate: '2027-06-01' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const regulatoryBodies = [
|
|
||||||
{ name: 'FinCEN', fullName: 'Financial Crimes Enforcement Network', scope: '联邦反洗钱监管', licenses: 1 },
|
|
||||||
{ name: 'SEC', fullName: 'Securities and Exchange Commission', scope: '证券交易监管', licenses: 1 },
|
|
||||||
{ name: 'NYDFS', fullName: 'NY Dept. of Financial Services', scope: '纽约州金融服务', licenses: 1 },
|
|
||||||
{ name: 'DFPI', fullName: 'CA Dept. of Financial Protection & Innovation', scope: '加州金融保护', licenses: 1 },
|
|
||||||
{ name: 'FINRA', fullName: 'Financial Industry Regulatory Authority', scope: '经纪商自律监管', licenses: 1 },
|
|
||||||
{ name: 'OCC', fullName: 'Office of the Comptroller of the Currency', scope: '联邦银行监管', licenses: 0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const renewalAlerts = [
|
|
||||||
{ license: 'Texas Money Transmitter', expiryDate: '2026-03-20', daysRemaining: 38, urgency: 'high' },
|
|
||||||
{ license: 'Georgia Money Transmitter', expiryDate: '2026-02-28', daysRemaining: 18, urgency: 'critical' },
|
|
||||||
{ license: 'FinCEN MSB Registration', expiryDate: '2026-06-01', daysRemaining: 111, urgency: 'medium' },
|
|
||||||
{ license: 'New York BitLicense', expiryDate: '2026-09-15', daysRemaining: 217, urgency: 'low' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const getLicenseStatusStyle = (status: string) => {
|
const getLicenseStatusStyle = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case t('license_status_active'):
|
case 'active': return { background: 'var(--color-success-light)', color: 'var(--color-success)' };
|
||||||
return { background: 'var(--color-success-light)', color: 'var(--color-success)' };
|
case 'applying': return { background: 'var(--color-info-light)', color: 'var(--color-info)' };
|
||||||
case t('license_status_applying'):
|
case 'renewal': return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' };
|
||||||
return { background: 'var(--color-info-light)', color: 'var(--color-info)' };
|
case 'expiring': return { background: 'var(--color-error-light)', color: 'var(--color-error)' };
|
||||||
case t('license_status_renewal'):
|
case 'expired': return { background: 'var(--color-gray-100)', color: 'var(--color-error)' };
|
||||||
return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' };
|
default: return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
||||||
case t('license_status_expiring'):
|
|
||||||
return { background: 'var(--color-error-light)', color: 'var(--color-error)' };
|
|
||||||
case t('license_status_expired'):
|
|
||||||
return { background: 'var(--color-gray-100)', color: 'var(--color-error)' };
|
|
||||||
default:
|
|
||||||
return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getLicenseStatusLabel = (status: string) => {
|
||||||
|
const map: Record<string, () => string> = {
|
||||||
|
active: () => t('license_status_active'),
|
||||||
|
applying: () => t('license_status_applying'),
|
||||||
|
renewal: () => t('license_status_renewal'),
|
||||||
|
expiring: () => t('license_status_expiring'),
|
||||||
|
};
|
||||||
|
return map[status]?.() ?? status;
|
||||||
|
};
|
||||||
|
|
||||||
const getUrgencyStyle = (urgency: string) => {
|
const getUrgencyStyle = (urgency: string) => {
|
||||||
switch (urgency) {
|
switch (urgency) {
|
||||||
case 'critical':
|
case 'critical': return { background: 'var(--color-error)', color: 'white' };
|
||||||
return { background: 'var(--color-error)', color: 'white' };
|
case 'high': return { background: 'var(--color-error-light)', color: 'var(--color-error)' };
|
||||||
case 'high':
|
case 'medium': return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' };
|
||||||
return { background: 'var(--color-error-light)', color: 'var(--color-error)' };
|
case 'low': return { background: 'var(--color-success-light)', color: 'var(--color-success)' };
|
||||||
case 'medium':
|
default: return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
||||||
return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' };
|
|
||||||
case 'low':
|
|
||||||
return { background: 'var(--color-success-light)', color: 'var(--color-success)' };
|
|
||||||
default:
|
|
||||||
return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LicenseManagementPage: React.FC = () => {
|
export const LicenseManagementPage: React.FC = () => {
|
||||||
|
const { data, isLoading, error } = useApi<LicenseData>('/api/v1/admin/compliance/licenses');
|
||||||
|
|
||||||
|
if (error) return <div style={loadingBox}>Error: {error.message}</div>;
|
||||||
|
if (isLoading) return <div style={loadingBox}>Loading...</div>;
|
||||||
|
|
||||||
|
const licenseStats = data?.stats ?? [];
|
||||||
|
const licenses = data?.licenses ?? [];
|
||||||
|
const regulatoryBodies = data?.regulatoryBodies ?? [];
|
||||||
|
const renewalAlerts = data?.renewalAlerts ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)' }}>{t('license_title')}</h1>
|
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)' }}>{t('license_title')}</h1>
|
||||||
<button style={{
|
<button style={{
|
||||||
padding: '8px 16px',
|
padding: '8px 16px', border: 'none', borderRadius: 'var(--radius-full)',
|
||||||
border: 'none',
|
background: 'var(--color-primary)', color: 'white', cursor: 'pointer', font: 'var(--text-label-sm)',
|
||||||
borderRadius: 'var(--radius-full)',
|
}}>{t('license_new')}</button>
|
||||||
background: 'var(--color-primary)',
|
|
||||||
color: 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
font: 'var(--text-label-sm)',
|
|
||||||
}}>
|
|
||||||
{t('license_new')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
|
|
@ -142,7 +122,7 @@ export const LicenseManagementPage: React.FC = () => {
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
||||||
...getLicenseStatusStyle(l.status),
|
...getLicenseStatusStyle(l.status),
|
||||||
}}>{l.status}</span>
|
}}>{getLicenseStatusLabel(l.status)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 14px' }}>
|
<td style={{ padding: '10px 14px' }}>
|
||||||
<button style={{ padding: '4px 12px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'none', cursor: 'pointer', font: 'var(--text-caption)', color: 'var(--color-primary)' }}>{t('details')}</button>
|
<button style={{ padding: '4px 12px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'none', cursor: 'pointer', font: 'var(--text-caption)', color: 'var(--color-primary)' }}>{t('details')}</button>
|
||||||
|
|
@ -218,16 +198,13 @@ export const LicenseManagementPage: React.FC = () => {
|
||||||
border: 'none', borderRadius: 'var(--radius-sm)',
|
border: 'none', borderRadius: 'var(--radius-sm)',
|
||||||
background: 'var(--color-error)', color: 'white',
|
background: 'var(--color-error)', color: 'white',
|
||||||
cursor: 'pointer', font: 'var(--text-label-sm)',
|
cursor: 'pointer', font: 'var(--text-label-sm)',
|
||||||
}}>
|
}}>{t('renew_now')}</button>
|
||||||
{t('renew_now')}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* Summary */}
|
|
||||||
<div style={{ marginTop: 16, padding: 12, background: 'var(--color-gray-50)', borderRadius: 'var(--radius-sm)' }}>
|
<div style={{ marginTop: 16, padding: 12, background: 'var(--color-gray-50)', borderRadius: 'var(--radius-sm)' }}>
|
||||||
<div style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-secondary)' }}>
|
<div style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-secondary)' }}>
|
||||||
{licenses.filter(l => l.status === t('license_status_active')).length} {t('license_jurisdictions_covered').replace('{0}', String(new Set(licenses.filter(l => l.status === t('license_status_active')).map(l => l.jurisdiction)).size))}
|
{data?.activeLicenseCount ?? 0} {t('license_jurisdictions_covered').replace('{0}', String(data?.jurisdictionsCovered ?? 0))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,74 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SEC Filing Management - SEC文件提交与管理
|
* SEC Filing Management - SEC文件提交与管理
|
||||||
*
|
|
||||||
* 管理所有SEC申报文件(10-K, 10-Q, S-1, 8-K等),
|
|
||||||
* 追踪提交状态、审核进度、截止日期,以及自动生成披露文件的状态。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const filingStats = [
|
interface SecFilingData {
|
||||||
{ label: t('sec_filed_count'), value: '24', color: 'var(--color-primary)' },
|
stats: { label: string; value: string; color: string }[];
|
||||||
{ label: t('sec_pending_review'), value: '3', color: 'var(--color-warning)' },
|
filings: { id: string; formType: string; title: string; filingDate: string; deadline: string; status: string; reviewer: string }[];
|
||||||
{ label: t('sec_passed'), value: '19', color: 'var(--color-success)' },
|
timeline: { date: string; event: string; type: string; done: boolean }[];
|
||||||
{ label: t('sec_next_deadline'), value: '18天', color: 'var(--color-error)' },
|
disclosureItems: { name: string; status: string; lastUpdated: string }[];
|
||||||
];
|
disclosureProgress: number;
|
||||||
|
}
|
||||||
|
|
||||||
const secFilings = [
|
const loadingBox: React.CSSProperties = {
|
||||||
{ id: 'SEC-001', formType: 'S-1', title: 'IPO注册声明书', filingDate: '2026-01-15', deadline: '2026-02-28', status: t('sec_status_reviewing'), reviewer: 'SEC Division of Corporation Finance' },
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
{ id: 'SEC-002', formType: '10-K', title: '2025年度报告', filingDate: '2026-01-30', deadline: '2026-03-31', status: t('sec_status_submitted'), reviewer: 'Internal Audit' },
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
{ id: 'SEC-003', formType: '10-Q', title: '2025 Q4季度报告', filingDate: '2026-02-01', deadline: '2026-02-15', status: t('sec_status_passed'), reviewer: 'External Auditor' },
|
};
|
||||||
{ id: 'SEC-004', formType: '8-K', title: '重大事项披露-战略合作', filingDate: '2026-02-05', deadline: '2026-02-09', status: t('sec_status_passed'), reviewer: 'Legal Counsel' },
|
|
||||||
{ id: 'SEC-005', formType: 'S-1/A', title: 'S-1修订稿(第2版)', filingDate: '2026-02-08', deadline: '2026-02-28', status: t('sec_status_needs_revision'), reviewer: 'SEC Division of Corporation Finance' },
|
|
||||||
{ id: 'SEC-006', formType: '10-Q', title: '2026 Q1季度报告', filingDate: '', deadline: '2026-05-15', status: t('sec_status_pending'), reviewer: '-' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const timelineEvents = [
|
|
||||||
{ date: '2026-02-15', event: '10-Q (Q4 2025) 截止', type: 'deadline', done: true },
|
|
||||||
{ date: '2026-02-28', event: 'S-1 注册声明审核回复', type: 'deadline', done: false },
|
|
||||||
{ date: '2026-03-15', event: '8-K 材料事件披露窗口', type: 'info', done: false },
|
|
||||||
{ date: '2026-03-31', event: '10-K 年度报告截止', type: 'deadline', done: false },
|
|
||||||
{ date: '2026-04-15', event: 'Proxy Statement 提交', type: 'deadline', done: false },
|
|
||||||
{ date: '2026-05-15', event: '10-Q (Q1 2026) 截止', type: 'deadline', done: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
const disclosureItems = [
|
|
||||||
{ name: '风险因素 (Risk Factors)', status: 'done', lastUpdated: '2026-02-05' },
|
|
||||||
{ name: '管理层讨论与分析 (MD&A)', status: 'done', lastUpdated: '2026-02-03' },
|
|
||||||
{ name: '财务报表 (Financial Statements)', status: 'progress', lastUpdated: '2026-02-08' },
|
|
||||||
{ name: '关联交易披露', status: 'progress', lastUpdated: '2026-02-07' },
|
|
||||||
{ name: '高管薪酬披露', status: 'pending', lastUpdated: '-' },
|
|
||||||
{ name: '公司治理结构', status: 'done', lastUpdated: '2026-01-28' },
|
|
||||||
{ name: '法律诉讼披露', status: 'pending', lastUpdated: '-' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const getFilingStatusStyle = (status: string) => {
|
const getFilingStatusStyle = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case t('sec_status_passed'):
|
case 'passed':
|
||||||
return { background: 'var(--color-success-light)', color: 'var(--color-success)' };
|
return { background: 'var(--color-success-light)', color: 'var(--color-success)' };
|
||||||
case t('sec_status_reviewing'):
|
case 'reviewing':
|
||||||
return { background: 'var(--color-info-light)', color: 'var(--color-info)' };
|
return { background: 'var(--color-info-light)', color: 'var(--color-info)' };
|
||||||
case t('sec_status_submitted'):
|
case 'submitted':
|
||||||
return { background: 'var(--color-primary-light)', color: 'var(--color-primary)' };
|
return { background: 'var(--color-primary-light)', color: 'var(--color-primary)' };
|
||||||
case t('sec_status_needs_revision'):
|
case 'needs_revision':
|
||||||
return { background: 'var(--color-error-light)', color: 'var(--color-error)' };
|
return { background: 'var(--color-error-light)', color: 'var(--color-error)' };
|
||||||
case t('sec_status_pending'):
|
case 'pending':
|
||||||
return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
||||||
default:
|
default:
|
||||||
return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
const map: Record<string, () => string> = {
|
||||||
|
passed: () => t('sec_status_passed'),
|
||||||
|
reviewing: () => t('sec_status_reviewing'),
|
||||||
|
submitted: () => t('sec_status_submitted'),
|
||||||
|
needs_revision: () => t('sec_status_needs_revision'),
|
||||||
|
pending: () => t('sec_status_pending'),
|
||||||
|
};
|
||||||
|
return map[status]?.() ?? status;
|
||||||
|
};
|
||||||
|
|
||||||
export const SecFilingPage: React.FC = () => {
|
export const SecFilingPage: React.FC = () => {
|
||||||
|
const { data, isLoading, error } = useApi<SecFilingData>('/api/v1/admin/compliance/sec-filing');
|
||||||
|
|
||||||
|
if (error) return <div style={loadingBox}>Error: {error.message}</div>;
|
||||||
|
if (isLoading) return <div style={loadingBox}>Loading...</div>;
|
||||||
|
|
||||||
|
const filingStats = data?.stats ?? [];
|
||||||
|
const secFilings = data?.filings ?? [];
|
||||||
|
const timelineEvents = data?.timeline ?? [];
|
||||||
|
const disclosureItems = data?.disclosureItems ?? [];
|
||||||
|
const disclosureProgress = data?.disclosureProgress ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)' }}>{t('sec_title')}</h1>
|
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)' }}>{t('sec_title')}</h1>
|
||||||
<button style={{
|
<button style={{
|
||||||
padding: '8px 16px',
|
padding: '8px 16px', border: 'none', borderRadius: 'var(--radius-full)',
|
||||||
border: 'none',
|
background: 'var(--color-primary)', color: 'white', cursor: 'pointer', font: 'var(--text-label-sm)',
|
||||||
borderRadius: 'var(--radius-full)',
|
}}>{t('sec_new_filing')}</button>
|
||||||
background: 'var(--color-primary)',
|
|
||||||
color: 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
font: 'var(--text-label-sm)',
|
|
||||||
}}>
|
|
||||||
{t('sec_new_filing')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
|
|
@ -126,7 +119,7 @@ export const SecFilingPage: React.FC = () => {
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
||||||
...getFilingStatusStyle(f.status),
|
...getFilingStatusStyle(f.status),
|
||||||
}}>{f.status}</span>
|
}}>{getStatusLabel(f.status)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 14px' }}>
|
<td style={{ padding: '10px 14px' }}>
|
||||||
<button style={{ padding: '4px 12px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'none', cursor: 'pointer', font: 'var(--text-caption)', color: 'var(--color-primary)' }}>{t('view')}</button>
|
<button style={{ padding: '4px 12px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'none', cursor: 'pointer', font: 'var(--text-caption)', color: 'var(--color-primary)' }}>{t('view')}</button>
|
||||||
|
|
@ -193,10 +186,10 @@ export const SecFilingPage: React.FC = () => {
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
<span style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-secondary)' }}>{t('sec_disclosure_progress')}</span>
|
<span style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-secondary)' }}>{t('sec_disclosure_progress')}</span>
|
||||||
<span style={{ font: 'var(--text-label-sm)', color: 'var(--color-primary)' }}>57%</span>
|
<span style={{ font: 'var(--text-label-sm)', color: 'var(--color-primary)' }}>{disclosureProgress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 8, background: 'var(--color-gray-100)', borderRadius: 'var(--radius-full)', overflow: 'hidden' }}>
|
<div style={{ height: 8, background: 'var(--color-gray-100)', borderRadius: 'var(--radius-full)', overflow: 'hidden' }}>
|
||||||
<div style={{ width: '57%', height: '100%', background: 'var(--color-primary)', borderRadius: 'var(--radius-full)' }} />
|
<div style={{ width: `${disclosureProgress}%`, height: '100%', background: 'var(--color-primary)', borderRadius: 'var(--radius-full)' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,111 +1,82 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SOX Compliance (Sarbanes-Oxley) - SOX合规管理
|
* SOX Compliance (Sarbanes-Oxley) - SOX合规管理
|
||||||
*
|
|
||||||
* 管理SOX 404内部控制合规状态,包括ICFR、ITGC、访问控制、变更管理、运营控制等,
|
|
||||||
* 追踪测试结果、缺陷修复、审计师审核进度。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const overallScore = 78;
|
interface SoxData {
|
||||||
|
overallScore: number;
|
||||||
|
controlCategories: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
controls: { name: string; result: string; lastTest: string; nextTest: string }[];
|
||||||
|
}[];
|
||||||
|
deficiencies: { id: string; control: string; category: string; severity: string; description: string; foundDate: string; dueDate: string; status: string; owner: string }[];
|
||||||
|
auditorReview: { phase: string; status: string; date: string; auditor: string }[];
|
||||||
|
auditProgress: number;
|
||||||
|
}
|
||||||
|
|
||||||
const controlCategories = [
|
const loadingBox: React.CSSProperties = {
|
||||||
{
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
name: 'ICFR',
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
description: 'Financial Reporting Internal Controls - Revenue recognition, expense allocation, asset valuation',
|
};
|
||||||
controls: [
|
|
||||||
{ name: 'Revenue Recognition', result: t('sox_result_passed'), lastTest: '2026-01-15', nextTest: '2026-04-15' },
|
|
||||||
{ name: 'Expense Approval', result: t('sox_result_passed'), lastTest: '2026-01-20', nextTest: '2026-04-20' },
|
|
||||||
{ name: 'Period-end Close', result: t('sox_result_defect'), lastTest: '2026-02-01', nextTest: '2026-03-01' },
|
|
||||||
{ name: 'Consolidation', result: t('sox_result_passed'), lastTest: '2026-01-25', nextTest: '2026-04-25' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ITGC',
|
|
||||||
description: 'IT General Controls - System development, program change, computer operations, data security',
|
|
||||||
controls: [
|
|
||||||
{ name: 'SDLC', result: t('sox_result_passed'), lastTest: '2026-01-10', nextTest: '2026-04-10' },
|
|
||||||
{ name: 'Change Management', result: t('sox_result_passed'), lastTest: '2026-01-18', nextTest: '2026-04-18' },
|
|
||||||
{ name: 'Backup & Recovery', result: t('sox_result_defect'), lastTest: '2026-02-03', nextTest: '2026-03-03' },
|
|
||||||
{ name: 'Logical Security', result: t('sox_result_pending'), lastTest: '-', nextTest: '2026-02-20' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Access Control',
|
|
||||||
description: 'System & data access management - User permissions, privileged accounts, SoD',
|
|
||||||
controls: [
|
|
||||||
{ name: 'User Access Review', result: t('sox_result_passed'), lastTest: '2026-02-01', nextTest: '2026-05-01' },
|
|
||||||
{ name: 'Privileged Access', result: t('sox_result_passed'), lastTest: '2026-01-28', nextTest: '2026-04-28' },
|
|
||||||
{ name: 'SoD', result: t('sox_result_defect'), lastTest: '2026-02-05', nextTest: '2026-03-05' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Change Management',
|
|
||||||
description: 'Production change approval, testing, deployment process controls',
|
|
||||||
controls: [
|
|
||||||
{ name: 'Change Approval', result: t('sox_result_passed'), lastTest: '2026-01-22', nextTest: '2026-04-22' },
|
|
||||||
{ name: 'Pre-deploy Testing', result: t('sox_result_passed'), lastTest: '2026-01-30', nextTest: '2026-04-30' },
|
|
||||||
{ name: 'Emergency Change', result: t('sox_result_pending'), lastTest: '-', nextTest: '2026-02-25' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Operational Controls',
|
|
||||||
description: 'Daily operations - Transaction monitoring, reconciliation, exception handling',
|
|
||||||
controls: [
|
|
||||||
{ name: 'EOD Reconciliation', result: t('sox_result_passed'), lastTest: '2026-02-08', nextTest: '2026-05-08' },
|
|
||||||
{ name: 'Anomaly Monitoring', result: t('sox_result_passed'), lastTest: '2026-02-06', nextTest: '2026-05-06' },
|
|
||||||
{ name: 'Client Fund Segregation', result: t('sox_result_passed'), lastTest: '2026-02-04', nextTest: '2026-05-04' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const deficiencies = [
|
|
||||||
{ id: 'DEF-001', control: 'Period-end Close', category: 'ICFR', severity: t('sox_severity_major'), description: 'Manual adjustments missing secondary approval', foundDate: '2026-02-01', dueDate: '2026-03-01', status: t('sox_status_remediating'), owner: 'CFO Office' },
|
|
||||||
{ id: 'DEF-002', control: 'Backup & Recovery', category: 'ITGC', severity: t('sox_severity_minor'), description: 'DR drill not executed quarterly', foundDate: '2026-02-03', dueDate: '2026-03-15', status: t('sox_status_remediating'), owner: 'IT Dept' },
|
|
||||||
{ id: 'DEF-003', control: 'SoD', category: 'Access Control', severity: t('sox_severity_major'), description: '3 users with both create & approve access', foundDate: '2026-02-05', dueDate: '2026-02-20', status: t('sox_status_pending'), owner: 'Compliance' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const auditorReview = [
|
|
||||||
{ phase: 'Audit Plan Confirmation', status: 'done', date: '2026-01-05', auditor: 'Deloitte' },
|
|
||||||
{ phase: 'Walk-through Testing', status: 'done', date: '2026-01-20', auditor: 'Deloitte' },
|
|
||||||
{ phase: 'Controls Effectiveness Testing', status: 'progress', date: '2026-02-10', auditor: 'Deloitte' },
|
|
||||||
{ phase: 'Deficiency Assessment', status: 'pending', date: '2026-03-01', auditor: 'Deloitte' },
|
|
||||||
{ phase: 'Management Report', status: 'pending', date: '2026-03-15', auditor: 'Deloitte' },
|
|
||||||
{ phase: 'Final Audit Opinion', status: 'pending', date: '2026-04-01', auditor: 'Deloitte' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const getResultStyle = (result: string) => {
|
const getResultStyle = (result: string) => {
|
||||||
switch (result) {
|
switch (result) {
|
||||||
case t('sox_result_passed'):
|
case 'passed': return { background: 'var(--color-success-light)', color: 'var(--color-success)' };
|
||||||
return { background: 'var(--color-success-light)', color: 'var(--color-success)' };
|
case 'defect': return { background: 'var(--color-error-light)', color: 'var(--color-error)' };
|
||||||
case t('sox_result_defect'):
|
case 'pending': return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
||||||
return { background: 'var(--color-error-light)', color: 'var(--color-error)' };
|
default: return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
||||||
case t('sox_result_pending'):
|
|
||||||
return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
|
||||||
default:
|
|
||||||
return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getResultLabel = (result: string) => {
|
||||||
|
const map: Record<string, () => string> = {
|
||||||
|
passed: () => t('sox_result_passed'),
|
||||||
|
defect: () => t('sox_result_defect'),
|
||||||
|
pending: () => t('sox_result_pending'),
|
||||||
|
};
|
||||||
|
return map[result]?.() ?? result;
|
||||||
|
};
|
||||||
|
|
||||||
const getSeverityStyle = (severity: string) => {
|
const getSeverityStyle = (severity: string) => {
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case t('sox_severity_major'):
|
case 'major': return { background: 'var(--color-error-light)', color: 'var(--color-error)' };
|
||||||
return { background: 'var(--color-error-light)', color: 'var(--color-error)' };
|
case 'minor': return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' };
|
||||||
case t('sox_severity_minor'):
|
case 'observation': return { background: 'var(--color-info-light)', color: 'var(--color-info)' };
|
||||||
return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' };
|
default: return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
||||||
case t('sox_severity_observation'):
|
|
||||||
return { background: 'var(--color-info-light)', color: 'var(--color-info)' };
|
|
||||||
default:
|
|
||||||
return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSeverityLabel = (severity: string) => {
|
||||||
|
const map: Record<string, () => string> = {
|
||||||
|
major: () => t('sox_severity_major'),
|
||||||
|
minor: () => t('sox_severity_minor'),
|
||||||
|
observation: () => t('sox_severity_observation'),
|
||||||
|
};
|
||||||
|
return map[severity]?.() ?? severity;
|
||||||
|
};
|
||||||
|
|
||||||
export const SoxCompliancePage: React.FC = () => {
|
export const SoxCompliancePage: React.FC = () => {
|
||||||
|
const { data, isLoading, error } = useApi<SoxData>('/api/v1/admin/compliance/reports');
|
||||||
|
|
||||||
|
if (error) return <div style={loadingBox}>Error: {error.message}</div>;
|
||||||
|
if (isLoading) return <div style={loadingBox}>Loading...</div>;
|
||||||
|
|
||||||
|
const overallScore = data?.overallScore ?? 0;
|
||||||
|
const controlCategories = data?.controlCategories ?? [];
|
||||||
|
const deficiencies = data?.deficiencies ?? [];
|
||||||
|
const auditorReview = data?.auditorReview ?? [];
|
||||||
|
const auditProgress = data?.auditProgress ?? 0;
|
||||||
|
|
||||||
const totalControls = controlCategories.reduce((sum, cat) => sum + cat.controls.length, 0);
|
const totalControls = controlCategories.reduce((sum, cat) => sum + cat.controls.length, 0);
|
||||||
const passedControls = controlCategories.reduce((sum, cat) => sum + cat.controls.filter(c => c.result === t('sox_result_passed')).length, 0);
|
const passedControls = controlCategories.reduce((sum, cat) => sum + cat.controls.filter(c => c.result === 'passed').length, 0);
|
||||||
const defectControls = controlCategories.reduce((sum, cat) => sum + cat.controls.filter(c => c.result === t('sox_result_defect')).length, 0);
|
const defectControls = controlCategories.reduce((sum, cat) => sum + cat.controls.filter(c => c.result === 'defect').length, 0);
|
||||||
const pendingControls = controlCategories.reduce((sum, cat) => sum + cat.controls.filter(c => c.result === t('sox_result_pending')).length, 0);
|
const pendingControls = controlCategories.reduce((sum, cat) => sum + cat.controls.filter(c => c.result === 'pending').length, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -113,7 +84,6 @@ export const SoxCompliancePage: React.FC = () => {
|
||||||
|
|
||||||
{/* Compliance Score Gauge + Summary Stats */}
|
{/* Compliance Score Gauge + Summary Stats */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 3fr', gap: 24, marginBottom: 24 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 3fr', gap: 24, marginBottom: 24 }}>
|
||||||
{/* Gauge */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--color-surface)', borderRadius: 'var(--radius-md)',
|
background: 'var(--color-surface)', borderRadius: 'var(--radius-md)',
|
||||||
border: '1px solid var(--color-border-light)', padding: 24,
|
border: '1px solid var(--color-border-light)', padding: 24,
|
||||||
|
|
@ -137,7 +107,6 @@ export const SoxCompliancePage: React.FC = () => {
|
||||||
<div style={{ font: 'var(--text-caption)', color: 'var(--color-text-tertiary)', marginTop: 8 }}>{t('sox_full_score')}</div>
|
<div style={{ font: 'var(--text-caption)', color: 'var(--color-text-tertiary)', marginTop: 8 }}>{t('sox_full_score')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Stats */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16 }}>
|
||||||
{[
|
{[
|
||||||
{ label: t('sox_total_controls'), value: String(totalControls), color: 'var(--color-primary)' },
|
{ label: t('sox_total_controls'), value: String(totalControls), color: 'var(--color-primary)' },
|
||||||
|
|
@ -166,12 +135,10 @@ export const SoxCompliancePage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
{controlCategories.map((cat, catIdx) => (
|
{controlCategories.map((cat, catIdx) => (
|
||||||
<div key={catIdx} style={{ borderBottom: catIdx < controlCategories.length - 1 ? '2px solid var(--color-border-light)' : 'none' }}>
|
<div key={catIdx} style={{ borderBottom: catIdx < controlCategories.length - 1 ? '2px solid var(--color-border-light)' : 'none' }}>
|
||||||
{/* Category Header */}
|
|
||||||
<div style={{ padding: '14px 20px', background: 'var(--color-gray-50)' }}>
|
<div style={{ padding: '14px 20px', background: 'var(--color-gray-50)' }}>
|
||||||
<div style={{ font: 'var(--text-h3)', color: 'var(--color-text-primary)', marginBottom: 4 }}>{cat.name}</div>
|
<div style={{ font: 'var(--text-h3)', color: 'var(--color-text-primary)', marginBottom: 4 }}>{cat.name}</div>
|
||||||
<div style={{ font: 'var(--text-caption)', color: 'var(--color-text-tertiary)' }}>{cat.description}</div>
|
<div style={{ font: 'var(--text-caption)', color: 'var(--color-text-tertiary)' }}>{cat.description}</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Controls */}
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -188,7 +155,7 @@ export const SoxCompliancePage: React.FC = () => {
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
||||||
...getResultStyle(ctrl.result),
|
...getResultStyle(ctrl.result),
|
||||||
}}>{ctrl.result}</span>
|
}}>{getResultLabel(ctrl.result)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', padding: '10px 20px', color: 'var(--color-text-tertiary)' }}>{ctrl.lastTest}</td>
|
<td style={{ font: 'var(--text-body-sm)', padding: '10px 20px', color: 'var(--color-text-tertiary)' }}>{ctrl.lastTest}</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', padding: '10px 20px', color: 'var(--color-text-secondary)' }}>{ctrl.nextTest}</td>
|
<td style={{ font: 'var(--text-body-sm)', padding: '10px 20px', color: 'var(--color-text-secondary)' }}>{ctrl.nextTest}</td>
|
||||||
|
|
@ -226,16 +193,18 @@ export const SoxCompliancePage: React.FC = () => {
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
||||||
...getSeverityStyle(d.severity),
|
...getSeverityStyle(d.severity),
|
||||||
}}>{d.severity}</span>
|
}}>{getSeverityLabel(d.severity)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ font: 'var(--text-caption)', padding: '10px 14px', color: 'var(--color-text-secondary)', maxWidth: 200 }}>{d.description}</td>
|
<td style={{ font: 'var(--text-caption)', padding: '10px 14px', color: 'var(--color-text-secondary)', maxWidth: 200 }}>{d.description}</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px', color: 'var(--color-text-tertiary)' }}>{d.dueDate}</td>
|
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px', color: 'var(--color-text-tertiary)' }}>{d.dueDate}</td>
|
||||||
<td style={{ padding: '10px 14px' }}>
|
<td style={{ padding: '10px 14px' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
||||||
background: d.status === t('sox_status_remediating') ? 'var(--color-warning-light)' : 'var(--color-error-light)',
|
background: d.status === 'remediating' ? 'var(--color-warning-light)' : 'var(--color-error-light)',
|
||||||
color: d.status === t('sox_status_remediating') ? 'var(--color-warning)' : 'var(--color-error)',
|
color: d.status === 'remediating' ? 'var(--color-warning)' : 'var(--color-error)',
|
||||||
}}>{d.status}</span>
|
}}>
|
||||||
|
{d.status === 'remediating' ? t('sox_status_remediating') : t('sox_status_pending')}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ font: 'var(--text-caption)', padding: '10px 14px', color: 'var(--color-text-tertiary)' }}>{d.owner}</td>
|
<td style={{ font: 'var(--text-caption)', padding: '10px 14px', color: 'var(--color-text-tertiary)' }}>{d.owner}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -247,7 +216,7 @@ export const SoxCompliancePage: React.FC = () => {
|
||||||
{/* Auditor Review Status */}
|
{/* Auditor Review Status */}
|
||||||
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-light)', padding: 20 }}>
|
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-light)', padding: 20 }}>
|
||||||
<h2 style={{ font: 'var(--text-h2)', marginBottom: 4 }}>{t('sox_auditor_progress')}</h2>
|
<h2 style={{ font: 'var(--text-h2)', marginBottom: 4 }}>{t('sox_auditor_progress')}</h2>
|
||||||
<div style={{ font: 'var(--text-caption)', color: 'var(--color-text-tertiary)', marginBottom: 16 }}>External Auditor: Deloitte</div>
|
<div style={{ font: 'var(--text-caption)', color: 'var(--color-text-tertiary)', marginBottom: 16 }}>External Auditor: {auditorReview[0]?.auditor ?? 'N/A'}</div>
|
||||||
{auditorReview.map((phase, i) => (
|
{auditorReview.map((phase, i) => (
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', padding: '10px 0', borderBottom: '1px solid var(--color-border-light)' }}>
|
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', padding: '10px 0', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|
@ -270,14 +239,13 @@ export const SoxCompliancePage: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* Progress Bar */}
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
<span style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-secondary)' }}>{t('sox_audit_progress')}</span>
|
<span style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-secondary)' }}>{t('sox_audit_progress')}</span>
|
||||||
<span style={{ font: 'var(--text-label-sm)', color: 'var(--color-primary)' }}>33%</span>
|
<span style={{ font: 'var(--text-label-sm)', color: 'var(--color-primary)' }}>{auditProgress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 8, background: 'var(--color-gray-100)', borderRadius: 'var(--radius-full)', overflow: 'hidden' }}>
|
<div style={{ height: 8, background: 'var(--color-gray-100)', borderRadius: 'var(--radius-full)', overflow: 'hidden' }}>
|
||||||
<div style={{ width: '33%', height: '100%', background: 'var(--color-primary)', borderRadius: 'var(--radius-full)' }} />
|
<div style={{ width: `${auditProgress}%`, height: '100%', background: 'var(--color-primary)', borderRadius: 'var(--radius-full)' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,106 +1,84 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tax Compliance Management - 税务合规管理
|
* Tax Compliance Management - 税务合规管理
|
||||||
*
|
|
||||||
* 管理平台在各司法管辖区的税务义务,追踪联邦和州级税务申报,
|
|
||||||
* 包括所得税、销售税、预扣税、交易税等,以及IRS表格提交进度。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const taxStats = [
|
interface TaxData {
|
||||||
{ label: t('tax_payable'), value: '$1,245,890', color: 'var(--color-primary)' },
|
stats: { label: string; value: string; color: string }[];
|
||||||
{ label: t('tax_paid'), value: '$982,450', color: 'var(--color-success)' },
|
obligations: { jurisdiction: string; taxType: string; period: string; amount: number; paid: number; status: string; dueDate: string }[];
|
||||||
{ label: t('tax_compliance_rate'), value: '96.8%', color: 'var(--color-info)' },
|
typeBreakdown: { type: string; federal: string; state: string; total: string; percentage: number }[];
|
||||||
{ label: t('tax_pending_items'), value: '5', color: 'var(--color-warning)' },
|
irsFilings: { form: string; description: string; taxYear: string; deadline: string; status: string; filedDate: string }[];
|
||||||
];
|
deadlines: { date: string; event: string; done: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
const taxObligations = [
|
const loadingBox: React.CSSProperties = {
|
||||||
{ jurisdiction: 'Federal', taxType: 'Corporate Income Tax', period: 'FY 2025', amount: '$425,000', paid: '$425,000', status: t('tax_status_paid'), dueDate: '2026-04-15' },
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
{ jurisdiction: 'Federal', taxType: 'Employment Tax (FICA)', period: 'Q4 2025', amount: '$68,200', paid: '$68,200', status: t('tax_status_paid'), dueDate: '2026-01-31' },
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
{ jurisdiction: 'California', taxType: 'State Income Tax', amount: '$187,500', paid: '$187,500', period: 'FY 2025', status: t('tax_status_paid'), dueDate: '2026-04-15' },
|
};
|
||||||
{ jurisdiction: 'California', taxType: 'Sales & Use Tax', amount: '$42,300', paid: '$42,300', period: 'Q4 2025', status: t('tax_status_paid'), dueDate: '2026-01-31' },
|
|
||||||
{ jurisdiction: 'New York', taxType: 'State Income Tax', amount: '$156,800', paid: '$120,000', period: 'FY 2025', status: t('tax_status_partial'), dueDate: '2026-04-15' },
|
|
||||||
{ jurisdiction: 'New York', taxType: 'Metropolitan Commuter Tax', amount: '$12,400', paid: '$0', period: 'FY 2025', status: t('tax_status_unpaid'), dueDate: '2026-04-15' },
|
|
||||||
{ jurisdiction: 'Texas', taxType: 'Franchise Tax', amount: '$34,600', paid: '$34,600', period: 'FY 2025', status: t('tax_status_paid'), dueDate: '2026-05-15' },
|
|
||||||
{ jurisdiction: 'Florida', taxType: 'Sales Tax', amount: '$28,900', paid: '$28,900', period: 'Q4 2025', status: t('tax_status_paid'), dueDate: '2026-01-31' },
|
|
||||||
{ jurisdiction: 'Federal', taxType: 'Estimated Tax (Q1 2026)', amount: '$263,190', paid: '$0', period: 'Q1 2026', status: t('tax_status_unpaid'), dueDate: '2026-04-15' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const taxTypeBreakdown = [
|
|
||||||
{ type: 'Income Tax (所得税)', federal: '$425,000', state: '$344,300', total: '$769,300', percentage: 61.7 },
|
|
||||||
{ type: 'Sales/Use Tax (销售税)', federal: '-', state: '$71,200', total: '$71,200', percentage: 5.7 },
|
|
||||||
{ type: 'Employment/Withholding Tax (预扣税)', federal: '$68,200', state: '$45,600', total: '$113,800', percentage: 9.1 },
|
|
||||||
{ type: 'Transaction Tax (交易税)', federal: '$28,400', state: '$0', total: '$28,400', percentage: 2.3 },
|
|
||||||
{ type: 'Estimated Tax (预估税)', federal: '$263,190', state: '$0', total: '$263,190', percentage: 21.2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const irsFilings = [
|
|
||||||
{ form: 'Form 1120', description: 'Corporate Income Tax', taxYear: '2025', deadline: '2026-04-15', status: t('tax_filing_preparing'), filedDate: '-' },
|
|
||||||
{ form: 'Form 1099-K', description: 'Payment Card & Third-party Network', taxYear: '2025', deadline: '2026-01-31', status: t('tax_filing_submitted'), filedDate: '2026-01-28' },
|
|
||||||
{ form: 'Form 1099-MISC', description: 'Miscellaneous Income (Contractors)', taxYear: '2025', deadline: '2026-01-31', status: t('tax_filing_submitted'), filedDate: '2026-01-29' },
|
|
||||||
{ form: 'Form 1099-NEC', description: 'Non-employee Compensation', taxYear: '2025', deadline: '2026-01-31', status: t('tax_filing_submitted'), filedDate: '2026-01-30' },
|
|
||||||
{ form: 'Form 941', description: 'Employer Quarterly Federal Tax', taxYear: 'Q4 2025', deadline: '2026-01-31', status: t('tax_filing_submitted'), filedDate: '2026-01-25' },
|
|
||||||
{ form: 'Form W-2', description: 'Wage & Tax Statement', taxYear: '2025', deadline: '2026-01-31', status: t('tax_filing_submitted'), filedDate: '2026-01-27' },
|
|
||||||
{ form: 'Form 1042-S', description: 'Foreign Person Withholding', taxYear: '2025', deadline: '2026-03-15', status: t('tax_filing_preparing'), filedDate: '-' },
|
|
||||||
{ form: 'Form 8300', description: 'Cash Payments Over $10,000', taxYear: '2025', deadline: '15 days after txn', status: t('tax_filing_on_demand'), filedDate: '-' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const taxDeadlines = [
|
|
||||||
{ date: '2026-01-31', event: 'Form 1099-K/1099-MISC/W-2 提交截止', done: true },
|
|
||||||
{ date: '2026-01-31', event: 'Q4 Employment Tax (Form 941) 截止', done: true },
|
|
||||||
{ date: '2026-03-15', event: 'Form 1042-S 外国人预扣税申报截止', done: false },
|
|
||||||
{ date: '2026-04-15', event: 'Form 1120 公司所得税申报截止', done: false },
|
|
||||||
{ date: '2026-04-15', event: 'Q1 2026 Estimated Tax Payment', done: false },
|
|
||||||
{ date: '2026-04-15', event: 'CA/NY State Income Tax 截止', done: false },
|
|
||||||
{ date: '2026-05-15', event: 'Texas Franchise Tax 截止', done: false },
|
|
||||||
{ date: '2026-06-15', event: 'Q2 2026 Estimated Tax Payment', done: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
const getPaymentStatusStyle = (status: string) => {
|
const getPaymentStatusStyle = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case t('tax_status_paid'):
|
case 'paid': return { background: 'var(--color-success-light)', color: 'var(--color-success)' };
|
||||||
return { background: 'var(--color-success-light)', color: 'var(--color-success)' };
|
case 'partial': return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' };
|
||||||
case t('tax_status_partial'):
|
case 'unpaid': return { background: 'var(--color-error-light)', color: 'var(--color-error)' };
|
||||||
return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' };
|
default: return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
||||||
case t('tax_status_unpaid'):
|
|
||||||
return { background: 'var(--color-error-light)', color: 'var(--color-error)' };
|
|
||||||
default:
|
|
||||||
return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getPaymentStatusLabel = (status: string) => {
|
||||||
|
const map: Record<string, () => string> = {
|
||||||
|
paid: () => t('tax_status_paid'),
|
||||||
|
partial: () => t('tax_status_partial'),
|
||||||
|
unpaid: () => t('tax_status_unpaid'),
|
||||||
|
};
|
||||||
|
return map[status]?.() ?? status;
|
||||||
|
};
|
||||||
|
|
||||||
const getFilingStatusStyle = (status: string) => {
|
const getFilingStatusStyle = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case t('tax_filing_submitted'):
|
case 'submitted': return { background: 'var(--color-success-light)', color: 'var(--color-success)' };
|
||||||
return { background: 'var(--color-success-light)', color: 'var(--color-success)' };
|
case 'preparing': return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' };
|
||||||
case t('tax_filing_preparing'):
|
case 'on_demand': return { background: 'var(--color-info-light)', color: 'var(--color-info)' };
|
||||||
return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' };
|
case 'overdue': return { background: 'var(--color-error-light)', color: 'var(--color-error)' };
|
||||||
case t('tax_filing_on_demand'):
|
default: return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
||||||
return { background: 'var(--color-info-light)', color: 'var(--color-info)' };
|
|
||||||
case t('tax_filing_overdue'):
|
|
||||||
return { background: 'var(--color-error-light)', color: 'var(--color-error)' };
|
|
||||||
default:
|
|
||||||
return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getFilingStatusLabel = (status: string) => {
|
||||||
|
const map: Record<string, () => string> = {
|
||||||
|
submitted: () => t('tax_filing_submitted'),
|
||||||
|
preparing: () => t('tax_filing_preparing'),
|
||||||
|
on_demand: () => t('tax_filing_on_demand'),
|
||||||
|
overdue: () => t('tax_filing_overdue'),
|
||||||
|
};
|
||||||
|
return map[status]?.() ?? status;
|
||||||
|
};
|
||||||
|
|
||||||
export const TaxCompliancePage: React.FC = () => {
|
export const TaxCompliancePage: React.FC = () => {
|
||||||
|
const { data, isLoading, error } = useApi<TaxData>('/api/v1/admin/compliance/tax');
|
||||||
|
|
||||||
|
if (error) return <div style={loadingBox}>Error: {error.message}</div>;
|
||||||
|
if (isLoading) return <div style={loadingBox}>Loading...</div>;
|
||||||
|
|
||||||
|
const taxStats = data?.stats ?? [];
|
||||||
|
const taxObligations = data?.obligations ?? [];
|
||||||
|
const taxTypeBreakdown = data?.typeBreakdown ?? [];
|
||||||
|
const irsFilings = data?.irsFilings ?? [];
|
||||||
|
const taxDeadlines = data?.deadlines ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)' }}>{t('tax_title')}</h1>
|
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)' }}>{t('tax_title')}</h1>
|
||||||
<button style={{
|
<button style={{
|
||||||
padding: '8px 16px',
|
padding: '8px 16px', border: 'none', borderRadius: 'var(--radius-full)',
|
||||||
border: 'none',
|
background: 'var(--color-primary)', color: 'white', cursor: 'pointer', font: 'var(--text-label-sm)',
|
||||||
borderRadius: 'var(--radius-full)',
|
}}>{t('tax_export_report')}</button>
|
||||||
background: 'var(--color-primary)',
|
|
||||||
color: 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
font: 'var(--text-label-sm)',
|
|
||||||
}}>
|
|
||||||
{t('tax_export_report')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
|
|
@ -116,7 +94,7 @@ export const TaxCompliancePage: React.FC = () => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tax Obligations by Jurisdiction */}
|
{/* Tax Obligations */}
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--color-surface)', borderRadius: 'var(--radius-md)',
|
background: 'var(--color-surface)', borderRadius: 'var(--radius-md)',
|
||||||
border: '1px solid var(--color-border-light)', overflow: 'hidden', marginBottom: 24,
|
border: '1px solid var(--color-border-light)', overflow: 'hidden', marginBottom: 24,
|
||||||
|
|
@ -145,14 +123,14 @@ export const TaxCompliancePage: React.FC = () => {
|
||||||
</td>
|
</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px', color: 'var(--color-text-primary)' }}>{tax.taxType}</td>
|
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px', color: 'var(--color-text-primary)' }}>{tax.taxType}</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px', color: 'var(--color-text-tertiary)' }}>{tax.period}</td>
|
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px', color: 'var(--color-text-tertiary)' }}>{tax.period}</td>
|
||||||
<td style={{ font: 'var(--text-label-sm)', padding: '10px 14px', color: 'var(--color-text-primary)' }}>{tax.amount}</td>
|
<td style={{ font: 'var(--text-label-sm)', padding: '10px 14px', color: 'var(--color-text-primary)' }}>${tax.amount?.toLocaleString()}</td>
|
||||||
<td style={{ font: 'var(--text-label-sm)', padding: '10px 14px', color: 'var(--color-success)' }}>{tax.paid}</td>
|
<td style={{ font: 'var(--text-label-sm)', padding: '10px 14px', color: 'var(--color-success)' }}>${tax.paid?.toLocaleString()}</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px', color: 'var(--color-text-tertiary)' }}>{tax.dueDate}</td>
|
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px', color: 'var(--color-text-tertiary)' }}>{tax.dueDate}</td>
|
||||||
<td style={{ padding: '10px 14px' }}>
|
<td style={{ padding: '10px 14px' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
||||||
...getPaymentStatusStyle(tax.status),
|
...getPaymentStatusStyle(tax.status),
|
||||||
}}>{tax.status}</span>
|
}}>{getPaymentStatusLabel(tax.status)}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
@ -160,9 +138,8 @@ export const TaxCompliancePage: React.FC = () => {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tax Type Breakdown + IRS Filings */}
|
{/* Tax Type Breakdown + Tax Calendar */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginBottom: 24 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginBottom: 24 }}>
|
||||||
{/* Tax Type Breakdown */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--color-surface)', borderRadius: 'var(--radius-md)',
|
background: 'var(--color-surface)', borderRadius: 'var(--radius-md)',
|
||||||
border: '1px solid var(--color-border-light)', overflow: 'hidden',
|
border: '1px solid var(--color-border-light)', overflow: 'hidden',
|
||||||
|
|
@ -199,7 +176,6 @@ export const TaxCompliancePage: React.FC = () => {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tax Calendar / Deadlines */}
|
|
||||||
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-light)', padding: 20 }}>
|
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-light)', padding: 20 }}>
|
||||||
<h2 style={{ font: 'var(--text-h2)', marginBottom: 16 }}>{t('tax_calendar')}</h2>
|
<h2 style={{ font: 'var(--text-h2)', marginBottom: 16 }}>{t('tax_calendar')}</h2>
|
||||||
{taxDeadlines.map((evt, i) => (
|
{taxDeadlines.map((evt, i) => (
|
||||||
|
|
@ -261,7 +237,7 @@ export const TaxCompliancePage: React.FC = () => {
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
||||||
...getFilingStatusStyle(f.status),
|
...getFilingStatusStyle(f.status),
|
||||||
}}>{f.status}</span>
|
}}>{getFilingStatusLabel(f.status)}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi, useApiMutation } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D2. 券管理 - 平台券审核与管理
|
* D2. 券管理 - 平台券审核与管理
|
||||||
|
|
@ -22,12 +23,10 @@ interface CouponBatch {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockCoupons: CouponBatch[] = [
|
interface CouponsResponse {
|
||||||
{ id: 'C001', issuer: 'Starbucks', name: '¥25 礼品卡', template: '礼品卡', faceValue: 25, quantity: 5000, sold: 4200, redeemed: 3300, status: 'active', createdAt: '2026-01-15' },
|
items: CouponBatch[];
|
||||||
{ id: 'C002', issuer: 'Amazon', name: '¥100 购物券', template: '代金券', faceValue: 100, quantity: 2000, sold: 1580, redeemed: 980, status: 'active', createdAt: '2026-01-20' },
|
total: number;
|
||||||
{ id: 'C003', issuer: 'Nike', name: '8折运动券', template: '折扣券', faceValue: 80, quantity: 1000, sold: 0, redeemed: 0, status: 'pending', createdAt: '2026-02-08' },
|
}
|
||||||
{ id: 'C004', issuer: 'Walmart', name: '¥50 生活券', template: '代金券', faceValue: 50, quantity: 3000, sold: 3000, redeemed: 2800, status: 'expired', createdAt: '2025-08-01' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
pending: 'var(--color-warning)',
|
pending: 'var(--color-warning)',
|
||||||
|
|
@ -35,6 +34,35 @@ const statusColors: Record<string, string> = {
|
||||||
suspended: 'var(--color-error)',
|
suspended: 'var(--color-error)',
|
||||||
expired: 'var(--color-text-tertiary)',
|
expired: 'var(--color-text-tertiary)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadingBox: React.CSSProperties = {
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CouponManagementPage: React.FC = () => {
|
||||||
|
const [filter, setFilter] = useState('all');
|
||||||
|
const [page] = useState(1);
|
||||||
|
const [limit] = useState(20);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useApi<CouponsResponse>('/api/v1/admin/coupons', {
|
||||||
|
params: { page, limit, status: filter === 'all' ? undefined : filter },
|
||||||
|
});
|
||||||
|
|
||||||
|
const approveMutation = useApiMutation<void>('POST', '', {
|
||||||
|
invalidateKeys: ['/api/v1/admin/coupons'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectMutation = useApiMutation<void>('POST', '', {
|
||||||
|
invalidateKeys: ['/api/v1/admin/coupons'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const suspendMutation = useApiMutation<void>('POST', '', {
|
||||||
|
invalidateKeys: ['/api/v1/admin/coupons'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const coupons = data?.items ?? [];
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
pending: t('coupon_pending_review'),
|
pending: t('coupon_pending_review'),
|
||||||
active: t('coupon_active'),
|
active: t('coupon_active'),
|
||||||
|
|
@ -42,11 +70,6 @@ const statusLabels: Record<string, string> = {
|
||||||
expired: t('coupon_expired'),
|
expired: t('coupon_expired'),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CouponManagementPage: React.FC = () => {
|
|
||||||
const [filter, setFilter] = useState('all');
|
|
||||||
|
|
||||||
const filtered = filter === 'all' ? mockCoupons : mockCoupons.filter(c => c.status === filter);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
|
@ -66,6 +89,11 @@ export const CouponManagementPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Coupon Table */}
|
{/* Coupon Table */}
|
||||||
|
{error ? (
|
||||||
|
<div style={loadingBox}>Error: {error.message}</div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div style={loadingBox}>Loading...</div>
|
||||||
|
) : (
|
||||||
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-light)', overflow: 'hidden' }}>
|
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-light)', overflow: 'hidden' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -76,7 +104,7 @@ export const CouponManagementPage: React.FC = () => {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map(coupon => (
|
{coupons.map(coupon => (
|
||||||
<tr key={coupon.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
<tr key={coupon.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
<td style={cellStyle}><span style={{ font: 'var(--text-label-sm)', fontFamily: 'var(--font-family-mono)' }}>{coupon.id}</span></td>
|
<td style={cellStyle}><span style={{ font: 'var(--text-label-sm)', fontFamily: 'var(--font-family-mono)' }}>{coupon.id}</span></td>
|
||||||
<td style={cellStyle}>{coupon.issuer}</td>
|
<td style={cellStyle}>{coupon.issuer}</td>
|
||||||
|
|
@ -96,12 +124,18 @@ export const CouponManagementPage: React.FC = () => {
|
||||||
<td style={cellStyle}>
|
<td style={cellStyle}>
|
||||||
{coupon.status === 'pending' && (
|
{coupon.status === 'pending' && (
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button style={btnStyle('var(--color-success)')}>{t('coupon_approve')}</button>
|
<button
|
||||||
<button style={btnStyle('var(--color-error)')}>{t('coupon_reject')}</button>
|
onClick={() => approveMutation.mutate({ couponId: coupon.id, action: 'approve' })}
|
||||||
|
style={btnStyle('var(--color-success)')}>{t('coupon_approve')}</button>
|
||||||
|
<button
|
||||||
|
onClick={() => rejectMutation.mutate({ couponId: coupon.id, action: 'reject' })}
|
||||||
|
style={btnStyle('var(--color-error)')}>{t('coupon_reject')}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{coupon.status === 'active' && (
|
{coupon.status === 'active' && (
|
||||||
<button style={btnStyle('var(--color-warning)')}>{t('suspend')}</button>
|
<button
|
||||||
|
onClick={() => suspendMutation.mutate({ couponId: coupon.id, action: 'suspend' })}
|
||||||
|
style={btnStyle('var(--color-warning)')}>{t('suspend')}</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -109,6 +143,7 @@ export const CouponManagementPage: React.FC = () => {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D1. 平台运营仪表盘
|
* D1. 平台运营仪表盘
|
||||||
|
|
@ -8,24 +11,60 @@ import { t } from '@/i18n/locales';
|
||||||
* 实时交易流、系统健康状态
|
* 实时交易流、系统健康状态
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface StatCard {
|
interface DashboardStats {
|
||||||
label: string;
|
totalVolume: number;
|
||||||
value: string;
|
totalAmount: number;
|
||||||
change: string;
|
activeUsers: number;
|
||||||
trend: 'up' | 'down';
|
issuerCount: number;
|
||||||
color: string;
|
couponCirculation: number;
|
||||||
|
systemHealthPercent: number;
|
||||||
|
totalVolumeChange: string;
|
||||||
|
totalAmountChange: string;
|
||||||
|
activeUsersChange: string;
|
||||||
|
issuerCountChange: string;
|
||||||
|
couponCirculationChange: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats: StatCard[] = [
|
interface RealtimeTrade {
|
||||||
{ label: t('dashboard_total_volume'), value: '156,890', change: '+12.3%', trend: 'up', color: 'var(--color-primary)' },
|
time: string;
|
||||||
{ label: t('dashboard_total_amount'), value: '$4,523,456', change: '+8.7%', trend: 'up', color: 'var(--color-success)' },
|
type: string;
|
||||||
{ label: t('dashboard_active_users'), value: '28,456', change: '+5.2%', trend: 'up', color: 'var(--color-info)' },
|
orderId: string;
|
||||||
{ label: t('dashboard_issuer_count'), value: '342', change: '+15', trend: 'up', color: 'var(--color-warning)' },
|
amount: number;
|
||||||
{ label: t('dashboard_coupon_circulation'), value: '1,234,567', change: '-2.1%', trend: 'down', color: 'var(--color-primary-dark)' },
|
status: string;
|
||||||
{ label: t('dashboard_system_health'), value: '99.97%', change: 'Normal', trend: 'up', color: 'var(--color-success)' },
|
}
|
||||||
];
|
|
||||||
|
interface ServiceHealth {
|
||||||
|
name: string;
|
||||||
|
status: 'healthy' | 'warning' | 'error';
|
||||||
|
latency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingBox: React.CSSProperties = {
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
|
};
|
||||||
|
|
||||||
export const DashboardPage: React.FC = () => {
|
export const DashboardPage: React.FC = () => {
|
||||||
|
const { data: statsData, isLoading: statsLoading, error: statsError } = useApi<DashboardStats>('/api/v1/admin/dashboard/stats');
|
||||||
|
const { data: tradesData, isLoading: tradesLoading } = useApi<RealtimeTrade[]>('/api/v1/admin/dashboard/realtime-trades');
|
||||||
|
const { data: healthData, isLoading: healthLoading } = useApi<ServiceHealth[]>('/api/v1/admin/dashboard/system-health');
|
||||||
|
|
||||||
|
const formatNumber = (n: number) => n?.toLocaleString() ?? '-';
|
||||||
|
const formatCurrency = (n: number) => `$${n?.toLocaleString() ?? '0'}`;
|
||||||
|
|
||||||
|
const stats = statsData ? [
|
||||||
|
{ label: t('dashboard_total_volume'), value: formatNumber(statsData.totalVolume), change: statsData.totalVolumeChange, trend: statsData.totalVolumeChange?.startsWith('-') ? 'down' as const : 'up' as const, color: 'var(--color-primary)' },
|
||||||
|
{ label: t('dashboard_total_amount'), value: formatCurrency(statsData.totalAmount), change: statsData.totalAmountChange, trend: statsData.totalAmountChange?.startsWith('-') ? 'down' as const : 'up' as const, color: 'var(--color-success)' },
|
||||||
|
{ label: t('dashboard_active_users'), value: formatNumber(statsData.activeUsers), change: statsData.activeUsersChange, trend: statsData.activeUsersChange?.startsWith('-') ? 'down' as const : 'up' as const, color: 'var(--color-info)' },
|
||||||
|
{ label: t('dashboard_issuer_count'), value: formatNumber(statsData.issuerCount), change: statsData.issuerCountChange, trend: statsData.issuerCountChange?.startsWith('-') ? 'down' as const : 'up' as const, color: 'var(--color-warning)' },
|
||||||
|
{ label: t('dashboard_coupon_circulation'), value: formatNumber(statsData.couponCirculation), change: statsData.couponCirculationChange, trend: statsData.couponCirculationChange?.startsWith('-') ? 'down' as const : 'up' as const, color: 'var(--color-primary-dark)' },
|
||||||
|
{ label: t('dashboard_system_health'), value: `${statsData.systemHealthPercent}%`, change: 'Normal', trend: 'up' as const, color: 'var(--color-success)' },
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
if (statsError) {
|
||||||
|
return <div style={loadingBox}>Error: {statsError.message}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 24 }}>
|
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 24 }}>
|
||||||
|
|
@ -39,7 +78,9 @@ export const DashboardPage: React.FC = () => {
|
||||||
gap: 16,
|
gap: 16,
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}>
|
}}>
|
||||||
{stats.map(stat => (
|
{statsLoading ? (
|
||||||
|
<div style={{ ...loadingBox, gridColumn: '1 / -1' }}>Loading...</div>
|
||||||
|
) : stats.map(stat => (
|
||||||
<div key={stat.label} style={{
|
<div key={stat.label} style={{
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-md)',
|
||||||
|
|
@ -127,6 +168,9 @@ export const DashboardPage: React.FC = () => {
|
||||||
animation: 'pulse 2s infinite',
|
animation: 'pulse 2s infinite',
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
|
{tradesLoading ? (
|
||||||
|
<div style={loadingBox}>Loading...</div>
|
||||||
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
<tr style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
|
|
@ -141,33 +185,28 @@ export const DashboardPage: React.FC = () => {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{[
|
{(tradesData ?? []).map((row, i) => (
|
||||||
{ time: '14:32:15', type: t('dashboard_type_purchase'), order: 'GNX-20260210-001234', amount: '$21.25', status: t('dashboard_status_completed') },
|
|
||||||
{ time: '14:31:58', type: t('dashboard_type_redeem'), order: 'GNX-20260210-001233', amount: '$50.00', status: t('dashboard_status_completed') },
|
|
||||||
{ time: '14:31:42', type: t('dashboard_type_resell'), order: 'GNX-20260210-001232', amount: '$85.00', status: t('dashboard_status_completed') },
|
|
||||||
{ time: '14:31:20', type: t('dashboard_type_purchase'), order: 'GNX-20260210-001231', amount: '$42.50', status: t('dashboard_status_processing') },
|
|
||||||
{ time: '14:30:55', type: t('dashboard_type_transfer'), order: 'GNX-20260210-001230', amount: '$30.00', status: t('dashboard_status_completed') },
|
|
||||||
].map((row, i) => (
|
|
||||||
<tr key={i} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
<tr key={i} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', padding: '10px 12px' }}>{row.time}</td>
|
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', padding: '10px 12px' }}>{row.time}</td>
|
||||||
<td style={{ font: 'var(--text-label-sm)', padding: '10px 12px' }}>{row.type}</td>
|
<td style={{ font: 'var(--text-label-sm)', padding: '10px 12px' }}>{row.type}</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', fontFamily: 'var(--font-family-mono)', padding: '10px 12px' }}>{row.order}</td>
|
<td style={{ font: 'var(--text-body-sm)', fontFamily: 'var(--font-family-mono)', padding: '10px 12px' }}>{row.orderId}</td>
|
||||||
<td style={{ font: 'var(--text-label-sm)', color: 'var(--color-primary)', padding: '10px 12px' }}>{row.amount}</td>
|
<td style={{ font: 'var(--text-label-sm)', color: 'var(--color-primary)', padding: '10px 12px' }}>${row.amount?.toFixed(2)}</td>
|
||||||
<td style={{ padding: '10px 12px' }}>
|
<td style={{ padding: '10px 12px' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
borderRadius: 'var(--radius-full)',
|
borderRadius: 'var(--radius-full)',
|
||||||
font: 'var(--text-caption)',
|
font: 'var(--text-caption)',
|
||||||
background: row.status === t('dashboard_status_completed') ? 'var(--color-success-light)' : 'var(--color-warning-light)',
|
background: row.status === 'completed' ? 'var(--color-success-light)' : 'var(--color-warning-light)',
|
||||||
color: row.status === t('dashboard_status_completed') ? 'var(--color-success)' : 'var(--color-warning)',
|
color: row.status === 'completed' ? 'var(--color-success)' : 'var(--color-warning)',
|
||||||
}}>
|
}}>
|
||||||
{row.status}
|
{row.status === 'completed' ? t('dashboard_status_completed') : t('dashboard_status_processing')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* System Health */}
|
{/* System Health */}
|
||||||
|
|
@ -178,13 +217,9 @@ export const DashboardPage: React.FC = () => {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ font: 'var(--text-h3)', marginBottom: 16 }}>{t('dashboard_system_health')}</div>
|
<div style={{ font: 'var(--text-h3)', marginBottom: 16 }}>{t('dashboard_system_health')}</div>
|
||||||
{[
|
{healthLoading ? (
|
||||||
{ name: t('dashboard_service_api'), status: 'healthy', latency: '12ms' },
|
<div style={loadingBox}>Loading...</div>
|
||||||
{ name: t('dashboard_service_db'), status: 'healthy', latency: '3ms' },
|
) : (healthData ?? []).map(service => (
|
||||||
{ name: 'Genex Chain', status: 'healthy', latency: '156ms' },
|
|
||||||
{ name: t('dashboard_service_cache'), status: 'healthy', latency: '1ms' },
|
|
||||||
{ name: t('dashboard_service_mq'), status: 'warning', latency: '45ms' },
|
|
||||||
].map(service => (
|
|
||||||
<div key={service.name} style={{
|
<div key={service.name} style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
@ -194,7 +229,7 @@ export const DashboardPage: React.FC = () => {
|
||||||
<span style={{
|
<span style={{
|
||||||
width: 8, height: 8,
|
width: 8, height: 8,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: service.status === 'healthy' ? 'var(--color-success)' : 'var(--color-warning)',
|
background: service.status === 'healthy' ? 'var(--color-success)' : service.status === 'warning' ? 'var(--color-warning)' : 'var(--color-error)',
|
||||||
marginRight: 8,
|
marginRight: 8,
|
||||||
}} />
|
}} />
|
||||||
<span style={{ flex: 1, font: 'var(--text-body-sm)' }}>{service.name}</span>
|
<span style={{ flex: 1, font: 'var(--text-body-sm)' }}>{service.name}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D8. 争议处理
|
* D8. 争议处理
|
||||||
|
|
@ -20,28 +23,44 @@ interface Dispute {
|
||||||
sla: string;
|
sla: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockDisputes: Dispute[] = [
|
interface DisputeData {
|
||||||
{ id: 'DSP-001', type: t('dispute_type_buyer'), order: 'GNX-20260208-001200', plaintiff: 'U-012', defendant: 'U-045', amount: '$85.00', status: 'pending', createdAt: '2026-02-09', sla: '23h' },
|
items: Dispute[];
|
||||||
{ id: 'DSP-002', type: t('dispute_type_refund'), order: 'GNX-20260207-001150', plaintiff: 'U-023', defendant: '-', amount: '$42.50', status: 'processing', createdAt: '2026-02-08', sla: '6h' },
|
summary: { pending: number; processing: number; resolvedToday: number };
|
||||||
{ id: 'DSP-003', type: t('dispute_type_seller'), order: 'GNX-20260206-001100', plaintiff: 'U-078', defendant: 'U-091', amount: '$120.00', status: 'pending', createdAt: '2026-02-07', sla: '47h' },
|
}
|
||||||
{ id: 'DSP-004', type: t('dispute_type_buyer'), order: 'GNX-20260205-001050', plaintiff: 'U-034', defendant: 'U-056', amount: '$30.00', status: 'resolved', createdAt: '2026-02-05', sla: '-' },
|
|
||||||
{ id: 'DSP-005', type: t('dispute_type_refund'), order: 'GNX-20260204-001000', plaintiff: 'U-067', defendant: '-', amount: '$21.25', status: 'rejected', createdAt: '2026-02-04', sla: '-' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusConfig: Record<string, { label: string; bg: string; color: string }> = {
|
const loadingBox: React.CSSProperties = {
|
||||||
pending: { label: t('dispute_pending'), bg: 'var(--color-warning-light)', color: 'var(--color-warning)' },
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
processing: { label: t('dispute_processing'), bg: 'var(--color-info-light)', color: 'var(--color-info)' },
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
resolved: { label: t('dispute_resolved'), bg: 'var(--color-success-light)', color: 'var(--color-success)' },
|
|
||||||
rejected: { label: t('dispute_rejected'), bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeConfig: Record<string, { bg: string; color: string }> = {
|
const getStatusConfig = (status: string) => {
|
||||||
[t('dispute_type_buyer')]: { bg: 'var(--color-error-light)', color: 'var(--color-error)' },
|
const map: Record<string, { label: () => string; bg: string; color: string }> = {
|
||||||
[t('dispute_type_seller')]: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)' },
|
pending: { label: () => t('dispute_pending'), bg: 'var(--color-warning-light)', color: 'var(--color-warning)' },
|
||||||
[t('dispute_type_refund')]: { bg: 'var(--color-info-light)', color: 'var(--color-info)' },
|
processing: { label: () => t('dispute_processing'), bg: 'var(--color-info-light)', color: 'var(--color-info)' },
|
||||||
|
resolved: { label: () => t('dispute_resolved'), bg: 'var(--color-success-light)', color: 'var(--color-success)' },
|
||||||
|
rejected: { label: () => t('dispute_rejected'), bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' },
|
||||||
|
};
|
||||||
|
return map[status] ?? { label: () => status, bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeConfig = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'buyer': return { bg: 'var(--color-error-light)', color: 'var(--color-error)', label: () => t('dispute_type_buyer') };
|
||||||
|
case 'seller': return { bg: 'var(--color-warning-light)', color: 'var(--color-warning)', label: () => t('dispute_type_seller') };
|
||||||
|
case 'refund': return { bg: 'var(--color-info-light)', color: 'var(--color-info)', label: () => t('dispute_type_refund') };
|
||||||
|
default: return { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)', label: () => type };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DisputePage: React.FC = () => {
|
export const DisputePage: React.FC = () => {
|
||||||
|
const { data, isLoading, error } = useApi<DisputeData>('/api/v1/admin/disputes');
|
||||||
|
|
||||||
|
if (error) return <div style={loadingBox}>Error: {error.message}</div>;
|
||||||
|
if (isLoading) return <div style={loadingBox}>Loading...</div>;
|
||||||
|
|
||||||
|
const disputes = data?.items ?? [];
|
||||||
|
const summary = data?.summary ?? { pending: 0, processing: 0, resolvedToday: 0 };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
|
@ -49,9 +68,9 @@ export const DisputePage: React.FC = () => {
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
{[
|
{[
|
||||||
{ label: t('dispute_pending'), value: '3', color: 'var(--color-warning)' },
|
{ label: t('dispute_pending'), value: String(summary.pending), color: 'var(--color-warning)' },
|
||||||
{ label: t('dispute_processing'), value: '1', color: 'var(--color-info)' },
|
{ label: t('dispute_processing'), value: String(summary.processing), color: 'var(--color-info)' },
|
||||||
{ label: t('dispute_resolved_today'), value: '5', color: 'var(--color-success)' },
|
{ label: t('dispute_resolved_today'), value: String(summary.resolvedToday), color: 'var(--color-success)' },
|
||||||
].map(s => (
|
].map(s => (
|
||||||
<div key={s.label} style={{
|
<div key={s.label} style={{
|
||||||
padding: '6px 14px',
|
padding: '6px 14px',
|
||||||
|
|
@ -89,9 +108,9 @@ export const DisputePage: React.FC = () => {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{mockDisputes.map(d => {
|
{disputes.map(d => {
|
||||||
const sc = statusConfig[d.status];
|
const sc = getStatusConfig(d.status);
|
||||||
const tc = typeConfig[d.type];
|
const tc = getTypeConfig(d.type);
|
||||||
return (
|
return (
|
||||||
<tr key={d.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
<tr key={d.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
<td style={{ font: 'var(--text-body-sm)', fontFamily: 'var(--font-family-mono)', padding: '10px 12px' }}>{d.id}</td>
|
<td style={{ font: 'var(--text-body-sm)', fontFamily: 'var(--font-family-mono)', padding: '10px 12px' }}>{d.id}</td>
|
||||||
|
|
@ -99,7 +118,7 @@ export const DisputePage: React.FC = () => {
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 'var(--radius-full)',
|
padding: '2px 8px', borderRadius: 'var(--radius-full)',
|
||||||
background: tc.bg, color: tc.color, font: 'var(--text-caption)',
|
background: tc.bg, color: tc.color, font: 'var(--text-caption)',
|
||||||
}}>{d.type}</span>
|
}}>{tc.label()}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-link)', padding: '10px 12px', cursor: 'pointer' }}>{d.order}</td>
|
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-link)', padding: '10px 12px', cursor: 'pointer' }}>{d.order}</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', padding: '10px 12px' }}>{d.plaintiff}</td>
|
<td style={{ font: 'var(--text-body-sm)', padding: '10px 12px' }}>{d.plaintiff}</td>
|
||||||
|
|
@ -109,7 +128,7 @@ export const DisputePage: React.FC = () => {
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 'var(--radius-full)',
|
padding: '2px 8px', borderRadius: 'var(--radius-full)',
|
||||||
background: sc.bg, color: sc.color, font: 'var(--text-caption)',
|
background: sc.bg, color: sc.color, font: 'var(--text-caption)',
|
||||||
}}>{sc.label}</span>
|
}}>{sc.label()}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 12px' }}>
|
<td style={{ padding: '10px 12px' }}>
|
||||||
{d.sla !== '-' ? (
|
{d.sla !== '-' ? (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D3. 财务管理 - 平台级财务总览
|
* D3. 财务管理 - 平台级财务总览
|
||||||
|
|
@ -7,28 +10,51 @@ import { t } from '@/i18n/locales';
|
||||||
* 平台收入(手续费)、发行方结算、消费者退款、资金池监控
|
* 平台收入(手续费)、发行方结算、消费者退款、资金池监控
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const financeStats = [
|
interface FinanceSummary {
|
||||||
{ label: t('finance_platform_fee'), value: '$234,567', period: t('finance_period_month'), color: 'var(--color-success)' },
|
platformFee: number;
|
||||||
{ label: t('finance_pending_settlement'), value: '$1,456,000', period: t('finance_period_cumulative'), color: 'var(--color-warning)' },
|
pendingSettlement: number;
|
||||||
{ label: t('finance_consumer_refund'), value: '$12,340', period: t('finance_period_month'), color: 'var(--color-error)' },
|
consumerRefund: number;
|
||||||
{ label: t('finance_pool_balance'), value: '$8,234,567', period: t('finance_period_realtime'), color: 'var(--color-primary)' },
|
poolBalance: number;
|
||||||
];
|
}
|
||||||
|
|
||||||
const recentSettlements = [
|
interface Settlement {
|
||||||
{ issuer: 'Starbucks', amount: '$45,200', status: t('finance_status_settled'), time: '2026-02-10 14:00' },
|
issuer: string;
|
||||||
{ issuer: 'Amazon', amount: '$128,000', status: t('finance_status_processing'), time: '2026-02-10 12:00' },
|
amount: number;
|
||||||
{ issuer: 'Nike', amount: '$23,500', status: t('finance_status_pending'), time: '2026-02-09' },
|
status: string;
|
||||||
{ issuer: 'Walmart', amount: '$67,800', status: t('finance_status_settled'), time: '2026-02-08' },
|
time: string;
|
||||||
];
|
}
|
||||||
|
|
||||||
|
const loadingBox: React.CSSProperties = {
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
|
};
|
||||||
|
|
||||||
export const FinanceManagementPage: React.FC = () => {
|
export const FinanceManagementPage: React.FC = () => {
|
||||||
|
const { data: summaryData, isLoading: summaryLoading, error: summaryError } = useApi<FinanceSummary>('/api/v1/admin/finance/summary');
|
||||||
|
const { data: settlementsData, isLoading: settlementsLoading } = useApi<Settlement[]>('/api/v1/admin/finance/settlements');
|
||||||
|
|
||||||
|
const formatCurrency = (n: number) => `$${n?.toLocaleString() ?? '0'}`;
|
||||||
|
|
||||||
|
const financeStats = summaryData ? [
|
||||||
|
{ label: t('finance_platform_fee'), value: formatCurrency(summaryData.platformFee), period: t('finance_period_month'), color: 'var(--color-success)' },
|
||||||
|
{ label: t('finance_pending_settlement'), value: formatCurrency(summaryData.pendingSettlement), period: t('finance_period_cumulative'), color: 'var(--color-warning)' },
|
||||||
|
{ label: t('finance_consumer_refund'), value: formatCurrency(summaryData.consumerRefund), period: t('finance_period_month'), color: 'var(--color-error)' },
|
||||||
|
{ label: t('finance_pool_balance'), value: formatCurrency(summaryData.poolBalance), period: t('finance_period_realtime'), color: 'var(--color-primary)' },
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
if (summaryError) {
|
||||||
|
return <div style={loadingBox}>Error: {summaryError.message}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 24 }}>{t('finance_title')}</h1>
|
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 24 }}>{t('finance_title')}</h1>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
|
||||||
{financeStats.map(s => (
|
{summaryLoading ? (
|
||||||
|
<div style={{ ...loadingBox, gridColumn: '1 / -1' }}>Loading...</div>
|
||||||
|
) : financeStats.map(s => (
|
||||||
<div key={s.label} style={{
|
<div key={s.label} style={{
|
||||||
background: 'var(--color-surface)', borderRadius: 'var(--radius-md)',
|
background: 'var(--color-surface)', borderRadius: 'var(--radius-md)',
|
||||||
border: '1px solid var(--color-border-light)', padding: 20,
|
border: '1px solid var(--color-border-light)', padding: 20,
|
||||||
|
|
@ -47,6 +73,9 @@ export const FinanceManagementPage: React.FC = () => {
|
||||||
border: '1px solid var(--color-border-light)', padding: 20,
|
border: '1px solid var(--color-border-light)', padding: 20,
|
||||||
}}>
|
}}>
|
||||||
<h2 style={{ font: 'var(--text-h2)', marginBottom: 16 }}>{t('finance_settlement_queue')}</h2>
|
<h2 style={{ font: 'var(--text-h2)', marginBottom: 16 }}>{t('finance_settlement_queue')}</h2>
|
||||||
|
{settlementsLoading ? (
|
||||||
|
<div style={loadingBox}>Loading...</div>
|
||||||
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>{[t('finance_th_issuer'), t('finance_th_amount'), t('finance_th_status'), t('finance_th_time')].map(h => (
|
<tr>{[t('finance_th_issuer'), t('finance_th_amount'), t('finance_th_status'), t('finance_th_time')].map(h => (
|
||||||
|
|
@ -54,22 +83,25 @@ export const FinanceManagementPage: React.FC = () => {
|
||||||
))}</tr>
|
))}</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{recentSettlements.map((s, i) => (
|
{(settlementsData ?? []).map((s, i) => (
|
||||||
<tr key={i} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
<tr key={i} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
<td style={{ padding: '10px 0', font: 'var(--text-body)' }}>{s.issuer}</td>
|
<td style={{ padding: '10px 0', font: 'var(--text-body)' }}>{s.issuer}</td>
|
||||||
<td style={{ padding: '10px 0', font: 'var(--text-label)', color: 'var(--color-primary)' }}>{s.amount}</td>
|
<td style={{ padding: '10px 0', font: 'var(--text-label)', color: 'var(--color-primary)' }}>${s.amount?.toLocaleString()}</td>
|
||||||
<td style={{ padding: '10px 0' }}>
|
<td style={{ padding: '10px 0' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
||||||
background: s.status === t('finance_status_settled') ? 'var(--color-success-light)' : s.status === t('finance_status_processing') ? 'var(--color-info-light)' : 'var(--color-warning-light)',
|
background: s.status === 'settled' ? 'var(--color-success-light)' : s.status === 'processing' ? 'var(--color-info-light)' : 'var(--color-warning-light)',
|
||||||
color: s.status === t('finance_status_settled') ? 'var(--color-success)' : s.status === t('finance_status_processing') ? 'var(--color-info)' : 'var(--color-warning)',
|
color: s.status === 'settled' ? 'var(--color-success)' : s.status === 'processing' ? 'var(--color-info)' : 'var(--color-warning)',
|
||||||
}}>{s.status}</span>
|
}}>
|
||||||
|
{s.status === 'settled' ? t('finance_status_settled') : s.status === 'processing' ? t('finance_status_processing') : t('finance_status_pending')}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 0', font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)' }}>{s.time}</td>
|
<td style={{ padding: '10px 0', font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)' }}>{s.time}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Revenue Chart Placeholder */}
|
{/* Revenue Chart Placeholder */}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D8. 保险与消费者保护 - 平台保障体系管理
|
* D8. 保险与消费者保护 - 平台保障体系管理
|
||||||
|
|
@ -7,30 +10,47 @@ import { t } from '@/i18n/locales';
|
||||||
* 消费者保护基金、保险机制、赔付记录、IPO准备度
|
* 消费者保护基金、保险机制、赔付记录、IPO准备度
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const protectionStats = [
|
interface InsuranceData {
|
||||||
{ label: t('insurance_protection_fund'), value: '$2,345,678', color: 'var(--color-success)' },
|
stats: { label: string; value: string; color: string }[];
|
||||||
{ label: t('insurance_monthly_payout'), value: '$12,340', color: 'var(--color-warning)' },
|
claims: { id: string; user: string; reason: string; amount: string; status: string; date: string }[];
|
||||||
{ label: t('insurance_payout_rate'), value: '0.08%', color: 'var(--color-info)' },
|
ipoChecklist: { item: string; status: 'done' | 'progress' | 'pending' }[];
|
||||||
{ label: t('insurance_ipo_readiness'), value: '72%', color: 'var(--color-primary)' },
|
ipoProgress: number;
|
||||||
];
|
}
|
||||||
|
|
||||||
const recentClaims = [
|
const loadingBox: React.CSSProperties = {
|
||||||
{ id: 'CLM-001', user: 'User#12345', reason: '发行方破产', amount: '$250', status: t('insurance_status_paid'), date: '2026-02-08' },
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
{ id: 'CLM-002', user: 'User#23456', reason: '券核销失败', amount: '$100', status: t('insurance_status_processing'), date: '2026-02-09' },
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
{ id: 'CLM-003', user: 'User#34567', reason: '重复扣款', amount: '$42.50', status: t('insurance_status_paid'), date: '2026-02-07' },
|
};
|
||||||
];
|
|
||||||
|
|
||||||
const ipoChecklist = [
|
const getClaimStatusStyle = (status: string) => {
|
||||||
{ item: 'SOX合规审计', status: 'done' },
|
switch (status) {
|
||||||
{ item: '消费者保护机制', status: 'done' },
|
case 'paid': return { background: 'var(--color-success-light)', color: 'var(--color-success)' };
|
||||||
{ item: 'AML/KYC合规体系', status: 'done' },
|
case 'processing': return { background: 'var(--color-warning-light)', color: 'var(--color-warning)' };
|
||||||
{ item: 'SEC披露文件准备', status: 'progress' },
|
case 'rejected': return { background: 'var(--color-error-light)', color: 'var(--color-error)' };
|
||||||
{ item: '独立审计报告', status: 'progress' },
|
default: return { background: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' };
|
||||||
{ item: '市场做市商协议', status: 'pending' },
|
}
|
||||||
{ item: '牌照申请', status: 'pending' },
|
};
|
||||||
];
|
|
||||||
|
const getClaimStatusLabel = (status: string) => {
|
||||||
|
const map: Record<string, () => string> = {
|
||||||
|
paid: () => t('insurance_status_paid'),
|
||||||
|
processing: () => t('insurance_status_processing'),
|
||||||
|
rejected: () => t('insurance_status_rejected'),
|
||||||
|
};
|
||||||
|
return map[status]?.() ?? status;
|
||||||
|
};
|
||||||
|
|
||||||
export const InsurancePage: React.FC = () => {
|
export const InsurancePage: React.FC = () => {
|
||||||
|
const { data, isLoading, error } = useApi<InsuranceData>('/api/v1/admin/insurance/stats');
|
||||||
|
|
||||||
|
if (error) return <div style={loadingBox}>Error: {error.message}</div>;
|
||||||
|
if (isLoading) return <div style={loadingBox}>Loading...</div>;
|
||||||
|
|
||||||
|
const protectionStats = data?.stats ?? [];
|
||||||
|
const recentClaims = data?.claims ?? [];
|
||||||
|
const ipoChecklist = data?.ipoChecklist ?? [];
|
||||||
|
const ipoProgress = data?.ipoProgress ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 24 }}>{t('insurance_title')}</h1>
|
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 24 }}>{t('insurance_title')}</h1>
|
||||||
|
|
@ -68,9 +88,8 @@ export const InsurancePage: React.FC = () => {
|
||||||
<td style={{ padding: '10px 0' }}>
|
<td style={{ padding: '10px 0' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
padding: '2px 8px', borderRadius: 'var(--radius-full)', font: 'var(--text-caption)', fontWeight: 600,
|
||||||
background: c.status === t('insurance_status_paid') ? 'var(--color-success-light)' : 'var(--color-warning-light)',
|
...getClaimStatusStyle(c.status),
|
||||||
color: c.status === t('insurance_status_paid') ? 'var(--color-success)' : 'var(--color-warning)',
|
}}>{getClaimStatusLabel(c.status)}</span>
|
||||||
}}>{c.status}</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
@ -88,7 +107,7 @@ export const InsurancePage: React.FC = () => {
|
||||||
background: item.status === 'done' ? 'var(--color-success)' : item.status === 'progress' ? 'var(--color-warning)' : 'var(--color-gray-200)',
|
background: item.status === 'done' ? 'var(--color-success)' : item.status === 'progress' ? 'var(--color-warning)' : 'var(--color-gray-200)',
|
||||||
color: item.status === 'pending' ? 'var(--color-text-tertiary)' : 'white', fontSize: 12,
|
color: item.status === 'pending' ? 'var(--color-text-tertiary)' : 'white', fontSize: 12,
|
||||||
}}>
|
}}>
|
||||||
{item.status === 'done' ? '✓' : item.status === 'progress' ? '…' : '○'}
|
{item.status === 'done' ? '✓' : item.status === 'progress' ? '...' : '○'}
|
||||||
</div>
|
</div>
|
||||||
<span style={{ flex: 1, font: 'var(--text-body)', color: 'var(--color-text-primary)' }}>{item.item}</span>
|
<span style={{ flex: 1, font: 'var(--text-body)', color: 'var(--color-text-primary)' }}>{item.item}</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
|
|
@ -103,10 +122,10 @@ export const InsurancePage: React.FC = () => {
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
<span style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-secondary)' }}>{t('overall_progress')}</span>
|
<span style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-secondary)' }}>{t('overall_progress')}</span>
|
||||||
<span style={{ font: 'var(--text-label-sm)', color: 'var(--color-primary)' }}>72%</span>
|
<span style={{ font: 'var(--text-label-sm)', color: 'var(--color-primary)' }}>{ipoProgress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 8, background: 'var(--color-gray-100)', borderRadius: 'var(--radius-full)', overflow: 'hidden' }}>
|
<div style={{ height: 8, background: 'var(--color-gray-100)', borderRadius: 'var(--radius-full)', overflow: 'hidden' }}>
|
||||||
<div style={{ width: '72%', height: '100%', background: 'var(--color-primary)', borderRadius: 'var(--radius-full)' }} />
|
<div style={{ width: `${ipoProgress}%`, height: '100%', background: 'var(--color-primary)', borderRadius: 'var(--radius-full)' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi, useApiMutation } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D2. 发行方管理
|
* D2. 发行方管理
|
||||||
|
|
@ -16,21 +17,38 @@ interface Issuer {
|
||||||
status: 'pending' | 'approved' | 'rejected';
|
status: 'pending' | 'approved' | 'rejected';
|
||||||
submittedAt: string;
|
submittedAt: string;
|
||||||
couponCount: number;
|
couponCount: number;
|
||||||
totalVolume: string;
|
totalVolume: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockIssuers: Issuer[] = [
|
interface IssuersResponse {
|
||||||
{ id: 'ISS-001', name: 'Starbucks Inc.', creditRating: 'AAA', status: 'approved', submittedAt: '2026-01-15', couponCount: 12, totalVolume: '$128,450' },
|
items: Issuer[];
|
||||||
{ id: 'ISS-002', name: 'Amazon Corp.', creditRating: 'AA', status: 'approved', submittedAt: '2026-01-20', couponCount: 8, totalVolume: '$456,000' },
|
total: number;
|
||||||
{ id: 'ISS-003', name: 'NewBrand LLC', creditRating: '-', status: 'pending', submittedAt: '2026-02-09', couponCount: 0, totalVolume: '-' },
|
}
|
||||||
{ id: 'ISS-004', name: 'Target Corp.', creditRating: 'A', status: 'approved', submittedAt: '2026-01-25', couponCount: 5, totalVolume: '$67,200' },
|
|
||||||
{ id: 'ISS-005', name: 'FakeStore Inc.', creditRating: '-', status: 'rejected', submittedAt: '2026-02-05', couponCount: 0, totalVolume: '-' },
|
const loadingBox: React.CSSProperties = {
|
||||||
];
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
|
};
|
||||||
|
|
||||||
export const IssuerManagementPage: React.FC = () => {
|
export const IssuerManagementPage: React.FC = () => {
|
||||||
const [tab, setTab] = useState<'all' | 'pending' | 'approved' | 'rejected'>('all');
|
const [tab, setTab] = useState<'all' | 'pending' | 'approved' | 'rejected'>('all');
|
||||||
|
const [page] = useState(1);
|
||||||
|
const [limit] = useState(20);
|
||||||
|
|
||||||
const filtered = tab === 'all' ? mockIssuers : mockIssuers.filter(i => i.status === tab);
|
const { data, isLoading, error } = useApi<IssuersResponse>('/api/v1/admin/issuers', {
|
||||||
|
params: { page, limit, status: tab === 'all' ? undefined : tab },
|
||||||
|
});
|
||||||
|
|
||||||
|
const approveMutation = useApiMutation<void>('POST', '', {
|
||||||
|
invalidateKeys: ['/api/v1/admin/issuers'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectMutation = useApiMutation<void>('POST', '', {
|
||||||
|
invalidateKeys: ['/api/v1/admin/issuers'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const issuers = data?.items ?? [];
|
||||||
|
const pendingCount = issuers.filter(i => i.status === 'pending').length;
|
||||||
|
|
||||||
const creditColor = (rating: string) => {
|
const creditColor = (rating: string) => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
|
|
@ -56,6 +74,8 @@ export const IssuerManagementPage: React.FC = () => {
|
||||||
return map[status] || status;
|
return map[status] || status;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (n: number) => n > 0 ? `$${n.toLocaleString()}` : '-';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
|
@ -92,7 +112,7 @@ export const IssuerManagementPage: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tabKey === 'all' ? t('all') : statusLabel(tabKey)}
|
{tabKey === 'all' ? t('all') : statusLabel(tabKey)}
|
||||||
{tabKey === 'pending' && (
|
{tabKey === 'pending' && pendingCount > 0 && (
|
||||||
<span style={{
|
<span style={{
|
||||||
marginLeft: 4, padding: '0 5px',
|
marginLeft: 4, padding: '0 5px',
|
||||||
background: 'var(--color-error)',
|
background: 'var(--color-error)',
|
||||||
|
|
@ -100,7 +120,7 @@ export const IssuerManagementPage: React.FC = () => {
|
||||||
borderRadius: 'var(--radius-full)',
|
borderRadius: 'var(--radius-full)',
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
}}>
|
}}>
|
||||||
{mockIssuers.filter(i => i.status === 'pending').length}
|
{pendingCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -108,6 +128,11 @@ export const IssuerManagementPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
|
{error ? (
|
||||||
|
<div style={loadingBox}>Error: {error.message}</div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div style={loadingBox}>Loading...</div>
|
||||||
|
) : (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-md)',
|
||||||
|
|
@ -128,7 +153,7 @@ export const IssuerManagementPage: React.FC = () => {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map(issuer => {
|
{issuers.map(issuer => {
|
||||||
const ss = statusStyle(issuer.status);
|
const ss = statusStyle(issuer.status);
|
||||||
return (
|
return (
|
||||||
<tr key={issuer.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
<tr key={issuer.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
|
|
@ -146,7 +171,7 @@ export const IssuerManagementPage: React.FC = () => {
|
||||||
font: 'var(--text-label-sm)',
|
font: 'var(--text-label-sm)',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
}}>
|
}}>
|
||||||
{issuer.creditRating}
|
{issuer.creditRating || '-'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px 16px' }}>
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
|
@ -166,7 +191,7 @@ export const IssuerManagementPage: React.FC = () => {
|
||||||
</td>
|
</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', padding: '12px 16px' }}>{issuer.couponCount}</td>
|
<td style={{ font: 'var(--text-body-sm)', padding: '12px 16px' }}>{issuer.couponCount}</td>
|
||||||
<td style={{ font: 'var(--text-label-sm)', color: 'var(--color-primary)', padding: '12px 16px' }}>
|
<td style={{ font: 'var(--text-label-sm)', color: 'var(--color-primary)', padding: '12px 16px' }}>
|
||||||
{issuer.totalVolume}
|
{formatCurrency(issuer.totalVolume)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px 16px' }}>
|
<td style={{ padding: '12px 16px' }}>
|
||||||
<button style={{
|
<button style={{
|
||||||
|
|
@ -181,7 +206,10 @@ export const IssuerManagementPage: React.FC = () => {
|
||||||
{t('details')}
|
{t('details')}
|
||||||
</button>
|
</button>
|
||||||
{issuer.status === 'pending' && (
|
{issuer.status === 'pending' && (
|
||||||
<button style={{
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => approveMutation.mutate({ issuerId: issuer.id, action: 'approve' })}
|
||||||
|
style={{
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
padding: '4px 12px',
|
padding: '4px 12px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
|
|
@ -193,6 +221,21 @@ export const IssuerManagementPage: React.FC = () => {
|
||||||
}}>
|
}}>
|
||||||
{t('review')}
|
{t('review')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => rejectMutation.mutate({ issuerId: issuer.id, action: 'reject' })}
|
||||||
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
padding: '4px 12px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: 'var(--color-error)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
font: 'var(--text-caption)',
|
||||||
|
color: 'white',
|
||||||
|
}}>
|
||||||
|
{t('issuer_onboarding_rejected')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -201,6 +244,7 @@ export const IssuerManagementPage: React.FC = () => {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D6. 商户核销管理 - 平台视角的核销数据
|
* D6. 商户核销管理 - 平台视角的核销数据
|
||||||
|
|
@ -7,22 +10,27 @@ import { t } from '@/i18n/locales';
|
||||||
* 核销统计、门店核销排行、异常核销检测
|
* 核销统计、门店核销排行、异常核销检测
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const redemptionStats = [
|
interface MerchantData {
|
||||||
{ label: t('merchant_today_redemption'), value: '1,234', change: '+15%', color: 'var(--color-success)' },
|
stats: { label: string; value: string; change: string; color: string }[];
|
||||||
{ label: t('merchant_today_amount'), value: '$45,600', change: '+8%', color: 'var(--color-primary)' },
|
topStores: { rank: number; store: string; count: number; amount: string }[];
|
||||||
{ label: t('merchant_active_stores'), value: '89', change: '+3', color: 'var(--color-info)' },
|
realtimeFeed: { store: string; coupon: string; time: string }[];
|
||||||
{ label: t('merchant_abnormal_redemption'), value: '2', change: t('merchant_need_review'), color: 'var(--color-error)' },
|
}
|
||||||
];
|
|
||||||
|
|
||||||
const topStores = [
|
const loadingBox: React.CSSProperties = {
|
||||||
{ rank: 1, store: 'Starbucks 徐汇店', count: 156, amount: '$3,900' },
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
{ rank: 2, store: 'Amazon Locker #A23', count: 98, amount: '$9,800' },
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
{ rank: 3, store: 'Nike 南京西路店', count: 67, amount: '$5,360' },
|
};
|
||||||
{ rank: 4, store: 'Walmart 浦东店', count: 45, amount: '$2,250' },
|
|
||||||
{ rank: 5, store: 'Target Downtown', count: 34, amount: '$1,020' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const MerchantRedemptionPage: React.FC = () => {
|
export const MerchantRedemptionPage: React.FC = () => {
|
||||||
|
const { data, isLoading, error } = useApi<MerchantData>('/api/v1/admin/merchant/stats');
|
||||||
|
|
||||||
|
if (error) return <div style={loadingBox}>Error: {error.message}</div>;
|
||||||
|
if (isLoading) return <div style={loadingBox}>Loading...</div>;
|
||||||
|
|
||||||
|
const redemptionStats = data?.stats ?? [];
|
||||||
|
const topStores = data?.topStores ?? [];
|
||||||
|
const realtimeFeed = data?.realtimeFeed ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 24 }}>{t('merchant_title')}</h1>
|
<h1 style={{ font: 'var(--text-h1)', color: 'var(--color-text-primary)', marginBottom: 24 }}>{t('merchant_title')}</h1>
|
||||||
|
|
@ -65,13 +73,7 @@ export const MerchantRedemptionPage: React.FC = () => {
|
||||||
{/* Realtime Feed */}
|
{/* Realtime Feed */}
|
||||||
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-light)', padding: 20 }}>
|
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-light)', padding: 20 }}>
|
||||||
<h2 style={{ font: 'var(--text-h2)', marginBottom: 16 }}>{t('merchant_realtime_feed')}</h2>
|
<h2 style={{ font: 'var(--text-h2)', marginBottom: 16 }}>{t('merchant_realtime_feed')}</h2>
|
||||||
{[
|
{realtimeFeed.map((r, i) => (
|
||||||
{ store: 'Starbucks 徐汇店', coupon: '¥25 礼品卡', time: '刚刚' },
|
|
||||||
{ store: 'Nike 南京西路店', coupon: '¥80 运动券', time: '1分钟前' },
|
|
||||||
{ store: 'Amazon Locker #A23', coupon: '¥100 购物券', time: '3分钟前' },
|
|
||||||
{ store: 'Starbucks 徐汇店', coupon: '¥25 礼品卡 x2', time: '5分钟前' },
|
|
||||||
{ store: 'Walmart 浦东店', coupon: '¥50 生活券', time: '8分钟前' },
|
|
||||||
].map((r, i) => (
|
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--color-border-light)' }}>
|
<div key={i} style={{ display: 'flex', alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--color-success)', marginRight: 12 }} />
|
<div style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--color-success)', marginRight: 12 }} />
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D5. 报表中心 - 运营报表、合规报表、数据导出
|
* D5. 报表中心 - 运营报表、合规报表、数据导出
|
||||||
|
|
@ -8,59 +11,60 @@ import { t } from '@/i18n/locales';
|
||||||
* 包括:SOX审计、SEC Filing、税务合规
|
* 包括:SOX审计、SEC Filing、税务合规
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const reportCategories = [
|
interface Report {
|
||||||
{
|
name: string;
|
||||||
title: t('reports_operations'),
|
desc: string;
|
||||||
icon: '📊',
|
status: string;
|
||||||
reports: [
|
date: string;
|
||||||
{ name: '日度运营报表', desc: '交易量/金额/用户/核销率', status: t('reports_status_generated'), date: '2026-02-10' },
|
}
|
||||||
{ name: '周度运营报表', desc: '周趋势分析', status: t('reports_status_generated'), date: '2026-02-09' },
|
|
||||||
{ name: '月度运营报表', desc: '月度综合分析', status: t('reports_status_generated'), date: '2026-01-31' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('reports_compliance'),
|
|
||||||
icon: '📋',
|
|
||||||
reports: [
|
|
||||||
{ name: 'SAR可疑活动报告', desc: '本月可疑交易汇总', status: t('reports_status_pending_review'), date: '2026-02-10' },
|
|
||||||
{ name: 'CTR大额交易报告', desc: '>$10,000交易申报', status: t('reports_status_submitted'), date: '2026-02-10' },
|
|
||||||
{ name: 'OFAC筛查报告', desc: '制裁名单筛查结果', status: t('reports_status_generated'), date: '2026-02-09' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('reports_financial'),
|
|
||||||
icon: '💰',
|
|
||||||
reports: [
|
|
||||||
{ name: '发行方结算报表', desc: '各发行方结算明细', status: t('reports_status_generated'), date: '2026-02-10' },
|
|
||||||
{ name: '平台收入报表', desc: '手续费/Breakage收入', status: t('reports_status_generated'), date: '2026-01-31' },
|
|
||||||
{ name: '税务合规报表', desc: '1099-K/消费税汇总', status: t('reports_status_pending_generate'), date: '' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('reports_audit'),
|
|
||||||
icon: '🔍',
|
|
||||||
reports: [
|
|
||||||
{ name: 'SOX合规检查', desc: '内部控制审计', status: t('reports_status_passed'), date: '2026-01-15' },
|
|
||||||
{ name: 'SEC Filing', desc: '证券类披露(预留)', status: 'N/A', date: '' },
|
|
||||||
{ name: '操作审计日志', desc: '管理员操作记录', status: t('reports_status_generated'), date: '2026-02-10' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusStyle = (status: string): React.CSSProperties => {
|
interface ReportCategory {
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
reports: Report[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportsData {
|
||||||
|
categories: ReportCategory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingBox: React.CSSProperties = {
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusStyle = (status: string): React.CSSProperties => {
|
||||||
const map: Record<string, { bg: string; color: string }> = {
|
const map: Record<string, { bg: string; color: string }> = {
|
||||||
[t('reports_status_generated')]: { bg: 'var(--color-success-light)', color: 'var(--color-success)' },
|
generated: { bg: 'var(--color-success-light)', color: 'var(--color-success)' },
|
||||||
[t('reports_status_submitted')]: { bg: 'var(--color-info-light)', color: 'var(--color-info)' },
|
submitted: { bg: 'var(--color-info-light)', color: 'var(--color-info)' },
|
||||||
[t('reports_status_passed')]: { bg: 'var(--color-success-light)', color: 'var(--color-success)' },
|
passed: { bg: 'var(--color-success-light)', color: 'var(--color-success)' },
|
||||||
[t('reports_status_pending_review')]: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)' },
|
pending_review: { bg: 'var(--color-warning-light)', color: 'var(--color-warning)' },
|
||||||
[t('reports_status_pending_generate')]: { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' },
|
pending_generate: { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' },
|
||||||
'N/A': { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' },
|
'N/A': { bg: 'var(--color-gray-100)', color: 'var(--color-text-tertiary)' },
|
||||||
};
|
};
|
||||||
const s = map[status] || map['N/A'];
|
const s = map[status] || map['N/A'];
|
||||||
return { padding: '2px 8px', borderRadius: 'var(--radius-full)', background: s.bg, color: s.color, font: 'var(--text-caption)', fontWeight: 600 };
|
return { padding: '2px 8px', borderRadius: 'var(--radius-full)', background: s.bg, color: s.color, font: 'var(--text-caption)', fontWeight: 600 };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
const map: Record<string, () => string> = {
|
||||||
|
generated: () => t('reports_status_generated'),
|
||||||
|
submitted: () => t('reports_status_submitted'),
|
||||||
|
passed: () => t('reports_status_passed'),
|
||||||
|
pending_review: () => t('reports_status_pending_review'),
|
||||||
|
pending_generate: () => t('reports_status_pending_generate'),
|
||||||
|
};
|
||||||
|
return map[status]?.() ?? status;
|
||||||
|
};
|
||||||
|
|
||||||
export const ReportsPage: React.FC = () => {
|
export const ReportsPage: React.FC = () => {
|
||||||
|
const { data, isLoading, error } = useApi<ReportsData>('/api/v1/admin/reports');
|
||||||
|
|
||||||
|
if (error) return <div style={loadingBox}>Error: {error.message}</div>;
|
||||||
|
if (isLoading) return <div style={loadingBox}>Loading...</div>;
|
||||||
|
|
||||||
|
const reportCategories = data?.categories ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
|
@ -90,9 +94,9 @@ export const ReportsPage: React.FC = () => {
|
||||||
<div style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)' }}>{r.desc}</div>
|
<div style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)' }}>{r.desc}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<span style={statusStyle(r.status)}>{r.status}</span>
|
<span style={getStatusStyle(r.status)}>{getStatusLabel(r.status)}</span>
|
||||||
{r.date && <span style={{ font: 'var(--text-caption)', color: 'var(--color-text-tertiary)' }}>{r.date}</span>}
|
{r.date && <span style={{ font: 'var(--text-caption)', color: 'var(--color-text-tertiary)' }}>{r.date}</span>}
|
||||||
{r.status !== 'N/A' && r.status !== t('reports_status_pending_generate') && (
|
{r.status !== 'N/A' && r.status !== 'pending_generate' && (
|
||||||
<button style={{
|
<button style={{
|
||||||
padding: '4px 10px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)',
|
padding: '4px 10px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)',
|
||||||
background: 'transparent', cursor: 'pointer', font: 'var(--text-caption)', color: 'var(--color-text-secondary)',
|
background: 'transparent', cursor: 'pointer', font: 'var(--text-caption)', color: 'var(--color-text-secondary)',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi, useApiMutation } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D5. 风控中心
|
* D5. 风控中心
|
||||||
|
|
@ -7,7 +10,51 @@ import { t } from '@/i18n/locales';
|
||||||
* 风险仪表盘、可疑交易、黑名单管理、OFAC筛查日志
|
* 风险仪表盘、可疑交易、黑名单管理、OFAC筛查日志
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
interface RiskDashboard {
|
||||||
|
riskEvents: number;
|
||||||
|
suspiciousTrades: number;
|
||||||
|
frozenAccounts: number;
|
||||||
|
ofacHits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RiskAlert {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SuspiciousTrade {
|
||||||
|
id: string;
|
||||||
|
user: string;
|
||||||
|
type: string;
|
||||||
|
amount: number;
|
||||||
|
time: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingBox: React.CSSProperties = {
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
|
};
|
||||||
|
|
||||||
export const RiskCenterPage: React.FC = () => {
|
export const RiskCenterPage: React.FC = () => {
|
||||||
|
const { data: dashboardData, isLoading: dashLoading, error: dashError } = useApi<RiskDashboard>('/api/v1/admin/risk/dashboard');
|
||||||
|
const { data: alertsData, isLoading: alertsLoading } = useApi<RiskAlert[]>('/api/v1/admin/risk/alerts');
|
||||||
|
const { data: tradesData, isLoading: tradesLoading } = useApi<SuspiciousTrade[]>('/api/v1/admin/risk/suspicious-trades');
|
||||||
|
|
||||||
|
const freezeMutation = useApiMutation<void>('POST', '', {
|
||||||
|
invalidateKeys: ['/api/v1/admin/risk/suspicious-trades'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const riskStats = dashboardData ? [
|
||||||
|
{ label: t('risk_events'), value: String(dashboardData.riskEvents), color: 'var(--color-error)', bg: 'var(--color-error-light)' },
|
||||||
|
{ label: t('risk_suspicious_trades'), value: String(dashboardData.suspiciousTrades), color: 'var(--color-warning)', bg: 'var(--color-warning-light)' },
|
||||||
|
{ label: t('risk_frozen_accounts'), value: String(dashboardData.frozenAccounts), color: 'var(--color-info)', bg: 'var(--color-info-light)' },
|
||||||
|
{ label: t('risk_ofac_hits'), value: String(dashboardData.ofacHits), color: 'var(--color-success)', bg: 'var(--color-success-light)' },
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
if (dashError) {
|
||||||
|
return <div style={loadingBox}>Error: {dashError.message}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
|
@ -27,12 +74,9 @@ export const RiskCenterPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Risk Stats */}
|
{/* Risk Stats */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
|
||||||
{[
|
{dashLoading ? (
|
||||||
{ label: t('risk_events'), value: '23', color: 'var(--color-error)', bg: 'var(--color-error-light)' },
|
<div style={{ ...loadingBox, gridColumn: '1 / -1' }}>Loading...</div>
|
||||||
{ label: t('risk_suspicious_trades'), value: '15', color: 'var(--color-warning)', bg: 'var(--color-warning-light)' },
|
) : riskStats.map(s => (
|
||||||
{ label: t('risk_frozen_accounts'), value: '3', color: 'var(--color-info)', bg: 'var(--color-info-light)' },
|
|
||||||
{ label: t('risk_ofac_hits'), value: '0', color: 'var(--color-success)', bg: 'var(--color-success-light)' },
|
|
||||||
].map(s => (
|
|
||||||
<div key={s.label} style={{
|
<div key={s.label} style={{
|
||||||
background: s.bg,
|
background: s.bg,
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-md)',
|
||||||
|
|
@ -55,21 +99,20 @@ export const RiskCenterPage: React.FC = () => {
|
||||||
<div style={{ font: 'var(--text-label)', color: 'var(--color-primary)', marginBottom: 12 }}>
|
<div style={{ font: 'var(--text-label)', color: 'var(--color-primary)', marginBottom: 12 }}>
|
||||||
🤖 {t('risk_ai_warning')}
|
🤖 {t('risk_ai_warning')}
|
||||||
</div>
|
</div>
|
||||||
{[
|
{alertsLoading ? (
|
||||||
'检测到异常模式:用户U-045在30分钟内完成12笔交易,总金额$4,560,建议人工审核',
|
<div style={loadingBox}>Loading...</div>
|
||||||
'可疑关联账户:U-078和U-091 IP地址相同但KYC信息不同,可能存在刷单行为',
|
) : (alertsData ?? []).map((alert, i) => (
|
||||||
].map((alert, i) => (
|
|
||||||
<div key={i} style={{
|
<div key={i} style={{
|
||||||
padding: 12,
|
padding: 12,
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
font: 'var(--text-body-sm)',
|
font: 'var(--text-body-sm)',
|
||||||
marginBottom: i < 1 ? 8 : 0,
|
marginBottom: i < (alertsData?.length ?? 0) - 1 ? 8 : 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}>
|
}}>
|
||||||
<span>{alert}</span>
|
<span>{alert.message}</span>
|
||||||
<button style={{
|
<button style={{
|
||||||
padding: '4px 12px',
|
padding: '4px 12px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
|
|
@ -95,6 +138,9 @@ export const RiskCenterPage: React.FC = () => {
|
||||||
<div style={{ padding: '16px 20px', font: 'var(--text-h3)', borderBottom: '1px solid var(--color-border-light)' }}>
|
<div style={{ padding: '16px 20px', font: 'var(--text-h3)', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
{t('risk_suspicious_trades')}
|
{t('risk_suspicious_trades')}
|
||||||
</div>
|
</div>
|
||||||
|
{tradesLoading ? (
|
||||||
|
<div style={loadingBox}>Loading...</div>
|
||||||
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: 'var(--color-gray-50)' }}>
|
<tr style={{ background: 'var(--color-gray-50)' }}>
|
||||||
|
|
@ -109,12 +155,7 @@ export const RiskCenterPage: React.FC = () => {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{[
|
{(tradesData ?? []).map(tx => (
|
||||||
{ id: 'TXN-8901', user: 'U-045', type: t('risk_type_high_freq'), amount: '$4,560', time: '14:15', score: 87 },
|
|
||||||
{ id: 'TXN-8900', user: 'U-078', type: t('risk_type_large_single'), amount: '$8,900', time: '13:45', score: 72 },
|
|
||||||
{ id: 'TXN-8899', user: 'U-091', type: t('risk_type_related_account'), amount: '$3,200', time: '12:30', score: 65 },
|
|
||||||
{ id: 'TXN-8898', user: 'U-023', type: t('risk_type_abnormal_ip'), amount: '$1,500', time: '11:20', score: 58 },
|
|
||||||
].map(tx => (
|
|
||||||
<tr key={tx.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
<tr key={tx.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
<td style={{ font: 'var(--text-body-sm)', fontFamily: 'var(--font-family-mono)', padding: '10px 14px' }}>{tx.id}</td>
|
<td style={{ font: 'var(--text-body-sm)', fontFamily: 'var(--font-family-mono)', padding: '10px 14px' }}>{tx.id}</td>
|
||||||
<td style={{ font: 'var(--text-label-sm)', padding: '10px 14px' }}>{tx.user}</td>
|
<td style={{ font: 'var(--text-label-sm)', padding: '10px 14px' }}>{tx.user}</td>
|
||||||
|
|
@ -127,7 +168,7 @@ export const RiskCenterPage: React.FC = () => {
|
||||||
font: 'var(--text-caption)',
|
font: 'var(--text-caption)',
|
||||||
}}>{tx.type}</span>
|
}}>{tx.type}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ font: 'var(--text-label-sm)', color: 'var(--color-error)', padding: '10px 14px' }}>{tx.amount}</td>
|
<td style={{ font: 'var(--text-label-sm)', color: 'var(--color-error)', padding: '10px 14px' }}>${tx.amount?.toLocaleString()}</td>
|
||||||
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', padding: '10px 14px' }}>{tx.time}</td>
|
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', padding: '10px 14px' }}>{tx.time}</td>
|
||||||
<td style={{ padding: '10px 14px' }}>
|
<td style={{ padding: '10px 14px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
|
@ -151,13 +192,16 @@ export const RiskCenterPage: React.FC = () => {
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 14px', display: 'flex', gap: 4 }}>
|
<td style={{ padding: '10px 14px', display: 'flex', gap: 4 }}>
|
||||||
<button style={{ padding: '4px 10px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'none', cursor: 'pointer', font: 'var(--text-caption)', color: 'var(--color-text-secondary)' }}>{t('mark')}</button>
|
<button style={{ padding: '4px 10px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'none', cursor: 'pointer', font: 'var(--text-caption)', color: 'var(--color-text-secondary)' }}>{t('mark')}</button>
|
||||||
<button style={{ padding: '4px 10px', border: 'none', borderRadius: 'var(--radius-sm)', background: 'var(--color-error)', cursor: 'pointer', font: 'var(--text-caption)', color: 'white' }}>{t('user_freeze')}</button>
|
<button
|
||||||
|
onClick={() => freezeMutation.mutate({ userId: tx.user })}
|
||||||
|
style={{ padding: '4px 10px', border: 'none', borderRadius: 'var(--radius-sm)', background: 'var(--color-error)', cursor: 'pointer', font: 'var(--text-caption)', color: 'white' }}>{t('user_freeze')}</button>
|
||||||
<button style={{ padding: '4px 10px', border: 'none', borderRadius: 'var(--radius-sm)', background: 'var(--color-warning)', cursor: 'pointer', font: 'var(--text-caption)', color: 'white' }}>SAR</button>
|
<button style={{ padding: '4px 10px', border: 'none', borderRadius: 'var(--radius-sm)', background: 'var(--color-warning)', cursor: 'pointer', font: 'var(--text-caption)', color: 'white' }}>SAR</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { t } from '@/i18n/locales';
|
import { t } from '@/i18n/locales';
|
||||||
|
import { useApi } from '@/lib/use-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D7. 系统管理
|
* D7. 系统管理
|
||||||
|
|
@ -9,9 +10,49 @@ import { t } from '@/i18n/locales';
|
||||||
* 管理员账号(RBAC)、系统配置、合约管理、系统监控
|
* 管理员账号(RBAC)、系统配置、合约管理、系统监控
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
interface Admin {
|
||||||
|
account: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
lastLogin: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigSection {
|
||||||
|
title: string;
|
||||||
|
items: { label: string; value: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceHealth {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
cpu: string;
|
||||||
|
mem: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemHealthResponse {
|
||||||
|
services: ServiceHealth[];
|
||||||
|
contracts: { name: string; address: string; version: string; status: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingBox: React.CSSProperties = {
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||||
|
};
|
||||||
|
|
||||||
export const SystemManagementPage: React.FC = () => {
|
export const SystemManagementPage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<'admins' | 'config' | 'contracts' | 'monitor'>('admins');
|
const [activeTab, setActiveTab] = useState<'admins' | 'config' | 'contracts' | 'monitor'>('admins');
|
||||||
|
|
||||||
|
const { data: adminsData, isLoading: adminsLoading } = useApi<Admin[]>(
|
||||||
|
activeTab === 'admins' ? '/api/v1/admin/system/admins' : null,
|
||||||
|
);
|
||||||
|
const { data: configData, isLoading: configLoading } = useApi<ConfigSection[]>(
|
||||||
|
activeTab === 'config' ? '/api/v1/admin/system/config' : null,
|
||||||
|
);
|
||||||
|
const { data: healthData, isLoading: healthLoading } = useApi<SystemHealthResponse>(
|
||||||
|
activeTab === 'contracts' || activeTab === 'monitor' ? '/api/v1/admin/system/health' : null,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ font: 'var(--text-h1)', marginBottom: 24 }}>{t('system_title')}</h1>
|
<h1 style={{ font: 'var(--text-h1)', marginBottom: 24 }}>{t('system_title')}</h1>
|
||||||
|
|
@ -55,6 +96,9 @@ export const SystemManagementPage: React.FC = () => {
|
||||||
background: 'var(--color-primary)', color: 'white', cursor: 'pointer', font: 'var(--text-label-sm)',
|
background: 'var(--color-primary)', color: 'white', cursor: 'pointer', font: 'var(--text-label-sm)',
|
||||||
}}>{t('system_add_admin')}</button>
|
}}>{t('system_add_admin')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
{adminsLoading ? (
|
||||||
|
<div style={loadingBox}>Loading...</div>
|
||||||
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: 'var(--color-gray-50)' }}>
|
<tr style={{ background: 'var(--color-gray-50)' }}>
|
||||||
|
|
@ -64,12 +108,7 @@ export const SystemManagementPage: React.FC = () => {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{[
|
{(adminsData ?? []).map(admin => (
|
||||||
{ account: 'admin@genex.io', name: '超级管理员', role: '超管', lastLogin: '2026-02-10 14:00', active: true },
|
|
||||||
{ account: 'ops@genex.io', name: '运营', role: '运营管理', lastLogin: '2026-02-10 09:30', active: true },
|
|
||||||
{ account: 'risk@genex.io', name: '风控', role: '风控审核', lastLogin: '2026-02-09 18:00', active: true },
|
|
||||||
{ account: 'cs@genex.io', name: '客服', role: '客服处理', lastLogin: '2026-02-08 14:30', active: false },
|
|
||||||
].map(admin => (
|
|
||||||
<tr key={admin.account} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
<tr key={admin.account} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px' }}>{admin.account}</td>
|
<td style={{ font: 'var(--text-body-sm)', padding: '10px 14px' }}>{admin.account}</td>
|
||||||
<td style={{ font: 'var(--text-label-sm)', padding: '10px 14px' }}>{admin.name}</td>
|
<td style={{ font: 'var(--text-label-sm)', padding: '10px 14px' }}>{admin.name}</td>
|
||||||
|
|
@ -94,18 +133,17 @@ export const SystemManagementPage: React.FC = () => {
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* System Config */}
|
{/* System Config */}
|
||||||
{activeTab === 'config' && (
|
{activeTab === 'config' && (
|
||||||
|
configLoading ? (
|
||||||
|
<div style={loadingBox}>Loading...</div>
|
||||||
|
) : (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16 }}>
|
||||||
{[
|
{(configData ?? []).map(section => (
|
||||||
{ title: t('system_fee_config'), items: [{ label: '一级市场手续费', value: '2.5%' }, { label: '二级市场手续费', value: '3.0%' }, { label: '提现手续费', value: '1.0%' }] },
|
|
||||||
{ title: t('system_kyc_config'), items: [{ label: 'L0每日限额', value: '$100' }, { label: 'L1每日限额', value: '$1,000' }, { label: 'L2每日限额', value: '$10,000' }] },
|
|
||||||
{ title: t('system_trade_limit_config'), items: [{ label: '单笔最大金额', value: '$50,000' }, { label: '每日最大金额', value: '$100,000' }, { label: '大额交易阈值', value: '$10,000' }] },
|
|
||||||
{ title: t('system_params'), items: [{ label: 'Utility Track价格上限', value: '≤面值' }, { label: '最大转售次数', value: '5次' }, { label: 'Breakage阈值', value: '3年' }] },
|
|
||||||
].map(section => (
|
|
||||||
<div key={section.title} style={{
|
<div key={section.title} style={{
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-md)',
|
||||||
|
|
@ -134,6 +172,7 @@ export const SystemManagementPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Contract Management */}
|
{/* Contract Management */}
|
||||||
|
|
@ -145,12 +184,9 @@ export const SystemManagementPage: React.FC = () => {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ font: 'var(--text-h3)', marginBottom: 16 }}>{t('system_contract_status')}</div>
|
<div style={{ font: 'var(--text-h3)', marginBottom: 16 }}>{t('system_contract_status')}</div>
|
||||||
{[
|
{healthLoading ? (
|
||||||
{ name: 'CouponNFT', address: '0x1234...abcd', version: 'v1.2.0', status: t('system_running') },
|
<div style={loadingBox}>Loading...</div>
|
||||||
{ name: 'Settlement', address: '0x5678...efgh', version: 'v1.1.0', status: t('system_running') },
|
) : (healthData?.contracts ?? []).map(c => (
|
||||||
{ name: 'Marketplace', address: '0x9abc...ijkl', version: 'v1.0.0', status: t('system_running') },
|
|
||||||
{ name: 'Oracle', address: '0xdef0...mnop', version: 'v1.0.0', status: t('system_running') },
|
|
||||||
].map(c => (
|
|
||||||
<div key={c.name} style={{
|
<div key={c.name} style={{
|
||||||
display: 'flex', alignItems: 'center', padding: '14px 0',
|
display: 'flex', alignItems: 'center', padding: '14px 0',
|
||||||
borderBottom: '1px solid var(--color-border-light)',
|
borderBottom: '1px solid var(--color-border-light)',
|
||||||
|
|
@ -181,18 +217,14 @@ export const SystemManagementPage: React.FC = () => {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ font: 'var(--text-h3)', marginBottom: 16 }}>{t('system_health_check')}</div>
|
<div style={{ font: 'var(--text-h3)', marginBottom: 16 }}>{t('system_health_check')}</div>
|
||||||
{[
|
{healthLoading ? (
|
||||||
{ name: 'API Gateway', status: 'healthy', cpu: '23%', mem: '45%' },
|
<div style={loadingBox}>Loading...</div>
|
||||||
{ name: 'Auth Service', status: 'healthy', cpu: '12%', mem: '34%' },
|
) : (healthData?.services ?? []).map(s => (
|
||||||
{ name: 'Trading Engine', status: 'healthy', cpu: '56%', mem: '67%' },
|
|
||||||
{ name: 'Genex Chain Node', status: 'healthy', cpu: '34%', mem: '78%' },
|
|
||||||
{ name: 'Redis Cache', status: 'healthy', cpu: '8%', mem: '52%' },
|
|
||||||
].map(s => (
|
|
||||||
<div key={s.name} style={{
|
<div key={s.name} style={{
|
||||||
display: 'flex', alignItems: 'center', padding: '10px 0',
|
display: 'flex', alignItems: 'center', padding: '10px 0',
|
||||||
borderBottom: '1px solid var(--color-border-light)',
|
borderBottom: '1px solid var(--color-border-light)',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--color-success)', marginRight: 10 }} />
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: s.status === 'healthy' ? 'var(--color-success)' : 'var(--color-warning)', marginRight: 10 }} />
|
||||||
<span style={{ flex: 1, font: 'var(--text-body-sm)' }}>{s.name}</span>
|
<span style={{ flex: 1, font: 'var(--text-body-sm)' }}>{s.name}</span>
|
||||||
<span style={{ font: 'var(--text-caption)', color: 'var(--color-text-tertiary)', marginRight: 16 }}>CPU {s.cpu}</span>
|
<span style={{ font: 'var(--text-caption)', color: 'var(--color-text-tertiary)', marginRight: 16 }}>CPU {s.cpu}</span>
|
||||||
<span style={{ font: 'var(--text-caption)', color: 'var(--color-text-tertiary)' }}>MEM {s.mem}</span>
|
<span style={{ font: 'var(--text-caption)', color: 'var(--color-text-tertiary)' }}>MEM {s.mem}</span>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue