feat(analytics): AVM v2 batch valuation, comparison, history + frontend upgrade
Add batch valuation (POST /analytics/valuation/batch, max 50 properties), valuation comparison (POST /analytics/valuation/compare, 2-5 properties), and history endpoint (GET /analytics/valuation/history/:propertyId) with confidence explanation helper. Frontend: enhanced valuation form with project autocomplete and deep analysis toggle, results with confidence badges and price range visualization, comparables table, history chart, market context card, and PDF export. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -4,11 +4,14 @@ import { GenerateReportHandler } from './application/commands/generate-report/ge
|
|||||||
import { TrackEventHandler } from './application/commands/track-event/track-event.handler';
|
import { TrackEventHandler } from './application/commands/track-event/track-event.handler';
|
||||||
import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler';
|
import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler';
|
||||||
import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler';
|
import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler';
|
||||||
|
import { BatchValuationHandler } from './application/queries/batch-valuation/batch-valuation.handler';
|
||||||
import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler';
|
import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler';
|
||||||
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
|
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
|
||||||
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
|
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
|
||||||
import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler';
|
import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler';
|
||||||
import { GetValuationHandler } from './application/queries/get-valuation/get-valuation.handler';
|
import { GetValuationHandler } from './application/queries/get-valuation/get-valuation.handler';
|
||||||
|
import { ValuationComparisonHandler } from './application/queries/valuation-comparison/valuation-comparison.handler';
|
||||||
|
import { ValuationHistoryHandler } from './application/queries/valuation-history/valuation-history.handler';
|
||||||
import { MARKET_INDEX_REPOSITORY } from './domain/repositories/market-index.repository';
|
import { MARKET_INDEX_REPOSITORY } from './domain/repositories/market-index.repository';
|
||||||
import { VALUATION_REPOSITORY } from './domain/repositories/valuation.repository';
|
import { VALUATION_REPOSITORY } from './domain/repositories/valuation.repository';
|
||||||
import { AVM_SERVICE } from './domain/services/avm-service';
|
import { AVM_SERVICE } from './domain/services/avm-service';
|
||||||
@@ -32,6 +35,9 @@ const QueryHandlers = [
|
|||||||
GetPriceTrendHandler,
|
GetPriceTrendHandler,
|
||||||
GetDistrictStatsHandler,
|
GetDistrictStatsHandler,
|
||||||
GetValuationHandler,
|
GetValuationHandler,
|
||||||
|
BatchValuationHandler,
|
||||||
|
ValuationHistoryHandler,
|
||||||
|
ValuationComparisonHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
const EventHandlers = [
|
const EventHandlers = [
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
|
import { CacheService, CachePrefix, CacheTTL, DomainException, type LoggerService } from '@modules/shared';
|
||||||
|
import {
|
||||||
|
AVM_SERVICE,
|
||||||
|
type IAVMService,
|
||||||
|
type BatchValuationResult,
|
||||||
|
} from '../../../domain/services/avm-service';
|
||||||
|
import { BatchValuationQuery } from './batch-valuation.query';
|
||||||
|
|
||||||
|
export type BatchValuationDto = BatchValuationResult[];
|
||||||
|
|
||||||
|
@QueryHandler(BatchValuationQuery)
|
||||||
|
export class BatchValuationHandler implements IQueryHandler<BatchValuationQuery> {
|
||||||
|
constructor(
|
||||||
|
@Inject(AVM_SERVICE) private readonly avmService: IAVMService,
|
||||||
|
private readonly cache: CacheService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: BatchValuationQuery): Promise<BatchValuationDto> {
|
||||||
|
try {
|
||||||
|
const cacheKey = CacheService.buildKey(
|
||||||
|
CachePrefix.VALUATION,
|
||||||
|
'batch',
|
||||||
|
...query.propertyIds.slice().sort(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.cache.getOrSet(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const items = query.propertyIds.map((propertyId) => ({ propertyId }));
|
||||||
|
return this.avmService.estimateBatch(items);
|
||||||
|
},
|
||||||
|
CacheTTL.MARKET_DATA,
|
||||||
|
'batch_valuation',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) throw error;
|
||||||
|
this.logger.error(
|
||||||
|
`Batch valuation failed: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
this.constructor.name,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException('Không thể định giá hàng loạt. Vui lòng thử lại sau.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export class BatchValuationQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly propertyIds: string[],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
|
import { CacheService, CachePrefix, CacheTTL, DomainException, type LoggerService, type PrismaService } from '@modules/shared';
|
||||||
|
import {
|
||||||
|
AVM_SERVICE,
|
||||||
|
type IAVMService,
|
||||||
|
type ValuationComparisonItem,
|
||||||
|
type ValuationResult,
|
||||||
|
} from '../../../domain/services/avm-service';
|
||||||
|
import { generateConfidenceExplanation } from '../../../infrastructure/services/confidence-explanation.helper';
|
||||||
|
import { ValuationComparisonQuery } from './valuation-comparison.query';
|
||||||
|
|
||||||
|
export interface ValuationComparisonDto {
|
||||||
|
properties: ValuationComparisonItem[];
|
||||||
|
summary: {
|
||||||
|
highestValue: { propertyId: string; estimatedPrice: string } | null;
|
||||||
|
lowestValue: { propertyId: string; estimatedPrice: string } | null;
|
||||||
|
averagePricePerM2: number;
|
||||||
|
averageConfidence: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(ValuationComparisonQuery)
|
||||||
|
export class ValuationComparisonHandler implements IQueryHandler<ValuationComparisonQuery> {
|
||||||
|
constructor(
|
||||||
|
@Inject(AVM_SERVICE) private readonly avmService: IAVMService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly cache: CacheService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: ValuationComparisonQuery): Promise<ValuationComparisonDto> {
|
||||||
|
try {
|
||||||
|
const cacheKey = CacheService.buildKey(
|
||||||
|
CachePrefix.VALUATION,
|
||||||
|
'compare',
|
||||||
|
...query.propertyIds.slice().sort(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.cache.getOrSet(
|
||||||
|
cacheKey,
|
||||||
|
() => this.buildComparison(query.propertyIds),
|
||||||
|
CacheTTL.MARKET_DATA,
|
||||||
|
'valuation_comparison',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) throw error;
|
||||||
|
this.logger.error(
|
||||||
|
`Valuation comparison failed: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
this.constructor.name,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException('Không thể so sánh định giá. Vui lòng thử lại sau.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildComparison(propertyIds: string[]): Promise<ValuationComparisonDto> {
|
||||||
|
// Fetch property details and valuations in parallel
|
||||||
|
const [properties, valuations] = await Promise.all([
|
||||||
|
this.prisma.property.findMany({
|
||||||
|
where: { id: { in: propertyIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
address: true,
|
||||||
|
district: true,
|
||||||
|
areaM2: true,
|
||||||
|
propertyType: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.fetchValuations(propertyIds),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const propertyMap = new Map(properties.map((p) => [p.id, p]));
|
||||||
|
const valuationMap = new Map(valuations.map((v) => [v.propertyId, v.valuation]));
|
||||||
|
|
||||||
|
const items: ValuationComparisonItem[] = propertyIds.map((propertyId) => {
|
||||||
|
const prop = propertyMap.get(propertyId);
|
||||||
|
const valuation = valuationMap.get(propertyId) ?? null;
|
||||||
|
|
||||||
|
// Add confidence explanation if we have a valuation
|
||||||
|
const enrichedValuation = valuation
|
||||||
|
? { ...valuation, confidenceExplanation: generateConfidenceExplanation(valuation.confidence, valuation.comparables.length) }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
propertyId,
|
||||||
|
address: prop?.address ?? '',
|
||||||
|
district: prop?.district ?? '',
|
||||||
|
areaM2: prop?.areaM2 ?? 0,
|
||||||
|
propertyType: prop?.propertyType ?? 'APARTMENT',
|
||||||
|
valuation: enrichedValuation,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate summary
|
||||||
|
const validValuations = items.filter((i) => i.valuation !== null);
|
||||||
|
const prices = validValuations.map((i) => ({
|
||||||
|
propertyId: i.propertyId,
|
||||||
|
price: BigInt(i.valuation!.estimatedPrice),
|
||||||
|
priceStr: i.valuation!.estimatedPrice,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let highestValue: { propertyId: string; estimatedPrice: string } | null = null;
|
||||||
|
let lowestValue: { propertyId: string; estimatedPrice: string } | null = null;
|
||||||
|
|
||||||
|
if (prices.length > 0) {
|
||||||
|
const sorted = prices.sort((a, b) => (a.price > b.price ? 1 : a.price < b.price ? -1 : 0));
|
||||||
|
const highest = sorted[sorted.length - 1]!;
|
||||||
|
const lowest = sorted[0]!;
|
||||||
|
highestValue = { propertyId: highest.propertyId, estimatedPrice: highest.priceStr };
|
||||||
|
lowestValue = { propertyId: lowest.propertyId, estimatedPrice: lowest.priceStr };
|
||||||
|
}
|
||||||
|
|
||||||
|
const averagePricePerM2 = validValuations.length > 0
|
||||||
|
? Math.round(validValuations.reduce((sum, i) => sum + i.valuation!.pricePerM2, 0) / validValuations.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const averageConfidence = validValuations.length > 0
|
||||||
|
? Math.round(validValuations.reduce((sum, i) => sum + i.valuation!.confidence, 0) / validValuations.length * 100) / 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
properties: items,
|
||||||
|
summary: { highestValue, lowestValue, averagePricePerM2, averageConfidence },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchValuations(propertyIds: string[]): Promise<{ propertyId: string; valuation: ValuationResult | null }[]> {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
propertyIds.map(async (propertyId) => {
|
||||||
|
const valuation = await this.avmService.estimateValue({ propertyId });
|
||||||
|
return { propertyId, valuation };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.map((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
|
return { propertyId: propertyIds[index]!, valuation: null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export class ValuationComparisonQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly propertyIds: string[],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
|
import { CacheService, CachePrefix, CacheTTL, DomainException, type LoggerService } from '@modules/shared';
|
||||||
|
import {
|
||||||
|
VALUATION_REPOSITORY,
|
||||||
|
type IValuationRepository,
|
||||||
|
} from '../../../domain/repositories/valuation.repository';
|
||||||
|
import { type ValuationHistoryPoint } from '../../../domain/services/avm-service';
|
||||||
|
import { ValuationHistoryQuery } from './valuation-history.query';
|
||||||
|
|
||||||
|
export interface ValuationHistoryDto {
|
||||||
|
propertyId: string;
|
||||||
|
history: ValuationHistoryPoint[];
|
||||||
|
totalRecords: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(ValuationHistoryQuery)
|
||||||
|
export class ValuationHistoryHandler implements IQueryHandler<ValuationHistoryQuery> {
|
||||||
|
constructor(
|
||||||
|
@Inject(VALUATION_REPOSITORY) private readonly valuationRepo: IValuationRepository,
|
||||||
|
private readonly cache: CacheService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: ValuationHistoryQuery): Promise<ValuationHistoryDto> {
|
||||||
|
try {
|
||||||
|
const cacheKey = CacheService.buildKey(
|
||||||
|
CachePrefix.VALUATION,
|
||||||
|
'history',
|
||||||
|
query.propertyId,
|
||||||
|
query.limit.toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.cache.getOrSet(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const entities = await this.valuationRepo.findByPropertyId(query.propertyId);
|
||||||
|
const limited = entities.slice(0, query.limit);
|
||||||
|
|
||||||
|
const history: ValuationHistoryPoint[] = limited.map((entity) => ({
|
||||||
|
estimatedPrice: entity.estimatedPrice.toString(),
|
||||||
|
confidence: entity.confidence,
|
||||||
|
pricePerM2: entity.pricePerM2,
|
||||||
|
modelVersion: entity.modelVersion,
|
||||||
|
valuedAt: entity.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
propertyId: query.propertyId,
|
||||||
|
history,
|
||||||
|
totalRecords: entities.length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
CacheTTL.DISTRICT_STATS,
|
||||||
|
'valuation_history',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) throw error;
|
||||||
|
this.logger.error(
|
||||||
|
`Valuation history failed for property ${query.propertyId}: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
this.constructor.name,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException('Không thể lấy lịch sử định giá. Vui lòng thử lại sau.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class ValuationHistoryQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly propertyId: string,
|
||||||
|
public readonly limit: number = 50,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PropertyType } from '@prisma/client';
|
import { type PropertyType } from '@prisma/client';
|
||||||
|
|
||||||
export const AVM_SERVICE = Symbol('AVM_SERVICE');
|
export const AVM_SERVICE = Symbol('AVM_SERVICE');
|
||||||
|
|
||||||
@@ -31,9 +31,38 @@ export interface ValuationResult {
|
|||||||
pricePerM2: number;
|
pricePerM2: number;
|
||||||
comparables: Comparable[];
|
comparables: Comparable[];
|
||||||
modelVersion: string;
|
modelVersion: string;
|
||||||
|
confidenceExplanation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchValuationItem {
|
||||||
|
propertyId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchValuationResult {
|
||||||
|
propertyId: string;
|
||||||
|
valuation: ValuationResult | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValuationHistoryPoint {
|
||||||
|
estimatedPrice: string;
|
||||||
|
confidence: number;
|
||||||
|
pricePerM2: number;
|
||||||
|
modelVersion: string;
|
||||||
|
valuedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValuationComparisonItem {
|
||||||
|
propertyId: string;
|
||||||
|
address: string;
|
||||||
|
district: string;
|
||||||
|
areaM2: number;
|
||||||
|
propertyType: PropertyType;
|
||||||
|
valuation: ValuationResult | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAVMService {
|
export interface IAVMService {
|
||||||
estimateValue(params: AVMParams): Promise<ValuationResult>;
|
estimateValue(params: AVMParams): Promise<ValuationResult>;
|
||||||
getComparables(propertyId: string, radiusMeters: number): Promise<Comparable[]>;
|
getComparables(propertyId: string, radiusMeters: number): Promise<Comparable[]>;
|
||||||
|
estimateBatch(items: BatchValuationItem[]): Promise<BatchValuationResult[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Generates a human-readable Vietnamese explanation of the AVM confidence score.
|
||||||
|
*
|
||||||
|
* The explanation considers:
|
||||||
|
* - Overall confidence level (high/medium/low)
|
||||||
|
* - Number of comparable properties used
|
||||||
|
* - General market data quality
|
||||||
|
*/
|
||||||
|
export function generateConfidenceExplanation(
|
||||||
|
confidence: number,
|
||||||
|
comparableCount: number,
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// Confidence level description
|
||||||
|
if (confidence >= 0.8) {
|
||||||
|
parts.push('Mức độ tin cậy cao');
|
||||||
|
} else if (confidence >= 0.5) {
|
||||||
|
parts.push('Mức độ tin cậy trung bình');
|
||||||
|
} else if (confidence > 0) {
|
||||||
|
parts.push('Mức độ tin cậy thấp');
|
||||||
|
} else {
|
||||||
|
return 'Không đủ dữ liệu để đưa ra ước tính đáng tin cậy. Kết quả chỉ mang tính tham khảo.';
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(`(${Math.round(confidence * 100)}%).`);
|
||||||
|
|
||||||
|
// Comparable properties context
|
||||||
|
if (comparableCount >= 10) {
|
||||||
|
parts.push(`Dựa trên ${comparableCount} bất động sản tương đương trong khu vực, cung cấp cơ sở dữ liệu vững chắc.`);
|
||||||
|
} else if (comparableCount >= 5) {
|
||||||
|
parts.push(`Dựa trên ${comparableCount} bất động sản tương đương. Dữ liệu đủ để ước tính hợp lý.`);
|
||||||
|
} else if (comparableCount >= 3) {
|
||||||
|
parts.push(`Chỉ có ${comparableCount} bất động sản tương đương. Kết quả có thể dao động.`);
|
||||||
|
} else {
|
||||||
|
parts.push('Số lượng bất động sản tương đương hạn chế. Nên tham khảo thêm các nguồn khác.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional guidance based on confidence
|
||||||
|
if (confidence < 0.5) {
|
||||||
|
parts.push('Khuyến nghị: Nên tham vấn chuyên gia định giá để có kết quả chính xác hơn.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { PrismaService, LoggerService } from '@modules/shared';
|
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
IAVMService,
|
type IAVMService,
|
||||||
type AVMParams,
|
type AVMParams,
|
||||||
type ValuationResult,
|
type ValuationResult,
|
||||||
type Comparable,
|
type Comparable,
|
||||||
|
type BatchValuationItem,
|
||||||
|
type BatchValuationResult,
|
||||||
} from '../../domain/services/avm-service';
|
} from '../../domain/services/avm-service';
|
||||||
import {
|
import {
|
||||||
AI_SERVICE_CLIENT,
|
AI_SERVICE_CLIENT,
|
||||||
IAiServiceClient,
|
type IAiServiceClient,
|
||||||
type AiPredictRequest,
|
type AiPredictRequest,
|
||||||
} from './ai-service.client';
|
} from './ai-service.client';
|
||||||
import { PrismaAVMService } from './prisma-avm.service';
|
import { type PrismaAVMService } from './prisma-avm.service';
|
||||||
|
|
||||||
|
/** Max concurrency for batch AI calls to avoid overloading the Python service. */
|
||||||
|
const BATCH_CONCURRENCY = 5;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HttpAVMService implements IAVMService {
|
export class HttpAVMService implements IAVMService {
|
||||||
@@ -38,6 +43,41 @@ export class HttpAVMService implements IAVMService {
|
|||||||
return this.fallback.getComparables(propertyId, radiusMeters);
|
return this.fallback.getComparables(propertyId, radiusMeters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async estimateBatch(items: BatchValuationItem[]): Promise<BatchValuationResult[]> {
|
||||||
|
const results: BatchValuationResult[] = [];
|
||||||
|
|
||||||
|
// Process in batches with limited concurrency
|
||||||
|
for (let i = 0; i < items.length; i += BATCH_CONCURRENCY) {
|
||||||
|
const chunk = items.slice(i, i + BATCH_CONCURRENCY);
|
||||||
|
const chunkResults = await Promise.allSettled(
|
||||||
|
chunk.map(async (item) => {
|
||||||
|
const valuation = await this.estimateValue({ propertyId: item.propertyId });
|
||||||
|
return { propertyId: item.propertyId, valuation } as BatchValuationResult;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let j = 0; j < chunkResults.length; j++) {
|
||||||
|
const result = chunkResults[j]!;
|
||||||
|
const item = chunk[j]!;
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
results.push(result.value);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
`Batch valuation failed for property ${item.propertyId}: ${String(result.reason)}`,
|
||||||
|
'HttpAVMService',
|
||||||
|
);
|
||||||
|
results.push({
|
||||||
|
propertyId: item.propertyId,
|
||||||
|
valuation: null,
|
||||||
|
error: result.reason instanceof Error ? result.reason.message : 'Lỗi định giá',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
private async estimateViaAi(params: AVMParams): Promise<ValuationResult> {
|
private async estimateViaAi(params: AVMParams): Promise<ValuationResult> {
|
||||||
const propertyData = params.propertyId
|
const propertyData = params.propertyId
|
||||||
? await this.getPropertyDetails(params.propertyId)
|
? await this.getPropertyDetails(params.propertyId)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PropertyType } from '@prisma/client';
|
import { type PropertyType } from '@prisma/client';
|
||||||
import { PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
IAVMService,
|
type IAVMService,
|
||||||
type AVMParams,
|
type AVMParams,
|
||||||
type ValuationResult,
|
type ValuationResult,
|
||||||
type Comparable,
|
type Comparable,
|
||||||
|
type BatchValuationItem,
|
||||||
|
type BatchValuationResult,
|
||||||
} from '../../domain/services/avm-service';
|
} from '../../domain/services/avm-service';
|
||||||
import {
|
import {
|
||||||
type RawComparable,
|
type RawComparable,
|
||||||
@@ -68,6 +70,19 @@ export class PrismaAVMService implements IAVMService {
|
|||||||
return raws.map(toComparableDto);
|
return raws.map(toComparableDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async estimateBatch(items: BatchValuationItem[]): Promise<BatchValuationResult[]> {
|
||||||
|
return Promise.all(
|
||||||
|
items.map(async (item) => {
|
||||||
|
try {
|
||||||
|
const valuation = await this.estimateValue({ propertyId: item.propertyId });
|
||||||
|
return { propertyId: item.propertyId, valuation };
|
||||||
|
} catch {
|
||||||
|
return { propertyId: item.propertyId, valuation: null, error: 'Lỗi định giá' };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveParams(params: AVMParams): Promise<{
|
private async resolveParams(params: AVMParams): Promise<{
|
||||||
lat: number; lng: number; areaM2: number;
|
lat: number; lng: number; areaM2: number;
|
||||||
propertyType: PropertyType | undefined;
|
propertyType: PropertyType | undefined;
|
||||||
|
|||||||
@@ -1,28 +1,41 @@
|
|||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { QueryBus } from '@nestjs/cqrs';
|
import { type QueryBus } from '@nestjs/cqrs';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '@modules/auth';
|
import { JwtAuthGuard } from '@modules/auth';
|
||||||
|
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
|
||||||
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
|
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
|
||||||
import { DistrictStatsDto } from '../../application/queries/get-district-stats/get-district-stats.handler';
|
import { type BatchValuationDto as BatchValuationQueryDto } from '../../application/queries/batch-valuation/batch-valuation.handler';
|
||||||
|
import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query';
|
||||||
|
import { type DistrictStatsDto } from '../../application/queries/get-district-stats/get-district-stats.handler';
|
||||||
import { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query';
|
import { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query';
|
||||||
import { HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler';
|
import { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler';
|
||||||
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
|
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
|
||||||
import { MarketReportDto } from '../../application/queries/get-market-report/get-market-report.handler';
|
import { type MarketReportDto } from '../../application/queries/get-market-report/get-market-report.handler';
|
||||||
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
|
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
|
||||||
import { PriceTrendDto } from '../../application/queries/get-price-trend/get-price-trend.handler';
|
import { type PriceTrendDto } from '../../application/queries/get-price-trend/get-price-trend.handler';
|
||||||
import { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query';
|
import { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query';
|
||||||
import { ValuationDto } from '../../application/queries/get-valuation/get-valuation.handler';
|
import { type ValuationDto } from '../../application/queries/get-valuation/get-valuation.handler';
|
||||||
import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query';
|
import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query';
|
||||||
import { GetDistrictStatsDto } from '../dto/get-district-stats.dto';
|
import { type ValuationComparisonDto as ValuationComparisonResultDto } from '../../application/queries/valuation-comparison/valuation-comparison.handler';
|
||||||
import { GetHeatmapDto } from '../dto/get-heatmap.dto';
|
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
|
||||||
import { GetMarketReportDto } from '../dto/get-market-report.dto';
|
import { type ValuationHistoryDto as ValuationHistoryResultDto } from '../../application/queries/valuation-history/valuation-history.handler';
|
||||||
import { GetPriceTrendDto } from '../dto/get-price-trend.dto';
|
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
|
||||||
import { GetValuationDto } from '../dto/get-valuation.dto';
|
import { type BatchValuationDto } from '../dto/batch-valuation.dto';
|
||||||
|
import { type GetDistrictStatsDto } from '../dto/get-district-stats.dto';
|
||||||
|
import { type GetHeatmapDto } from '../dto/get-heatmap.dto';
|
||||||
|
import { type GetMarketReportDto } from '../dto/get-market-report.dto';
|
||||||
|
import { type GetPriceTrendDto } from '../dto/get-price-trend.dto';
|
||||||
|
import { type GetValuationDto } from '../dto/get-valuation.dto';
|
||||||
|
import { type ValuationComparisonDto } from '../dto/valuation-comparison.dto';
|
||||||
|
import { type ValuationHistoryDto } from '../dto/valuation-history.dto';
|
||||||
|
|
||||||
@ApiTags('analytics')
|
@ApiTags('analytics')
|
||||||
@Controller('analytics')
|
@Controller('analytics')
|
||||||
@@ -96,4 +109,53 @@ export class AnalyticsController {
|
|||||||
new GetValuationQuery(dto.propertyId, dto.latitude, dto.longitude, dto.areaM2, dto.propertyType),
|
new GetValuationQuery(dto.propertyId, dto.latitude, dto.longitude, dto.areaM2, dto.propertyType),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
|
||||||
|
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)
|
||||||
|
@RequireQuota('analytics_queries')
|
||||||
|
@Post('valuation/batch')
|
||||||
|
@ApiOperation({ summary: 'Batch valuation for multiple properties (max 50)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Batch valuation results retrieved' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid parameters' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||||
|
@ApiResponse({ status: 429, description: 'Rate limit exceeded' })
|
||||||
|
async batchValuation(@Body() dto: BatchValuationDto): Promise<BatchValuationQueryDto> {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new BatchValuationQuery(dto.propertyIds),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||||
|
@RequireQuota('analytics_queries')
|
||||||
|
@Get('valuation/history/:propertyId')
|
||||||
|
@ApiOperation({ summary: 'Get valuation history for a property (chart data)' })
|
||||||
|
@ApiParam({ name: 'propertyId', description: 'Property ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Valuation history retrieved' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||||
|
async getValuationHistory(
|
||||||
|
@Param('propertyId') propertyId: string,
|
||||||
|
@Query() dto: ValuationHistoryDto,
|
||||||
|
): Promise<ValuationHistoryResultDto> {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new ValuationHistoryQuery(propertyId, dto.limit ?? 50),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
|
||||||
|
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)
|
||||||
|
@RequireQuota('analytics_queries')
|
||||||
|
@Post('valuation/compare')
|
||||||
|
@ApiOperation({ summary: 'Compare valuations for 2-5 properties side by side' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Valuation comparison retrieved' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid parameters — provide 2-5 property IDs' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||||
|
@ApiResponse({ status: 429, description: 'Rate limit exceeded' })
|
||||||
|
async compareValuations(@Body() dto: ValuationComparisonDto): Promise<ValuationComparisonResultDto> {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new ValuationComparisonQuery(dto.propertyIds),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class BatchValuationDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Array of property IDs to valuate (max 50)',
|
||||||
|
example: ['prop-1', 'prop-2'],
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayMaxSize(50)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Transform(({ value }) => (Array.isArray(value) ? value : [value]))
|
||||||
|
propertyIds!: string[];
|
||||||
|
}
|
||||||
@@ -3,3 +3,6 @@ export { GetHeatmapDto } from './get-heatmap.dto';
|
|||||||
export { GetPriceTrendDto } from './get-price-trend.dto';
|
export { GetPriceTrendDto } from './get-price-trend.dto';
|
||||||
export { GetDistrictStatsDto } from './get-district-stats.dto';
|
export { GetDistrictStatsDto } from './get-district-stats.dto';
|
||||||
export { GetValuationDto } from './get-valuation.dto';
|
export { GetValuationDto } from './get-valuation.dto';
|
||||||
|
export { BatchValuationDto } from './batch-valuation.dto';
|
||||||
|
export { ValuationHistoryDto } from './valuation-history.dto';
|
||||||
|
export { ValuationComparisonDto } from './valuation-comparison.dto';
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class ValuationComparisonDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Array of property IDs to compare (2-5 properties)',
|
||||||
|
example: ['prop-1', 'prop-2', 'prop-3'],
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(2)
|
||||||
|
@ArrayMaxSize(5)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Transform(({ value }) => (Array.isArray(value) ? value : [value]))
|
||||||
|
propertyIds!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ValuationHistoryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Maximum number of history records (default: 50, max: 100)', default: 50 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
@Transform(({ value }) => (value != null ? parseInt(value, 10) : 50))
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { ComparablesTable } from '@/components/valuation/comparables-table';
|
||||||
|
import { ExportPdfButton } from '@/components/valuation/export-pdf-button';
|
||||||
|
import { MarketContextCard } from '@/components/valuation/market-context-card';
|
||||||
import { ValuationForm } from '@/components/valuation/valuation-form';
|
import { ValuationForm } from '@/components/valuation/valuation-form';
|
||||||
import { ValuationHistory } from '@/components/valuation/valuation-history';
|
import { ValuationHistory } from '@/components/valuation/valuation-history';
|
||||||
import { ValuationResults } from '@/components/valuation/valuation-results';
|
import { ValuationResults } from '@/components/valuation/valuation-results';
|
||||||
@@ -11,12 +15,29 @@ import {
|
|||||||
} from '@/lib/hooks/use-valuation';
|
} from '@/lib/hooks/use-valuation';
|
||||||
import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api';
|
import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api';
|
||||||
|
|
||||||
|
// Lazy-load chart component (uses Recharts, no SSR)
|
||||||
|
const ValuationHistoryChart = dynamic(
|
||||||
|
() =>
|
||||||
|
import('@/components/valuation/valuation-history-chart').then(
|
||||||
|
(m) => m.ValuationHistoryChart,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
|
Đang tải...
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default function ValuationPage() {
|
export default function ValuationPage() {
|
||||||
const [historyPage, setHistoryPage] = useState(1);
|
const [historyPage, setHistoryPage] = useState(1);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
const predictMutation = useValuationPredict();
|
const predictMutation = useValuationPredict();
|
||||||
const { data: historyData, isLoading: historyLoading } = useValuationHistory(historyPage);
|
const { data: historyData, isLoading: historyLoading } =
|
||||||
|
useValuationHistory(historyPage);
|
||||||
const { data: selectedResult } = useValuationDetail(selectedId ?? '');
|
const { data: selectedResult } = useValuationDetail(selectedId ?? '');
|
||||||
|
|
||||||
const currentResult: ValuationResult | undefined =
|
const currentResult: ValuationResult | undefined =
|
||||||
@@ -33,15 +54,24 @@ export default function ValuationPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
{/* Page header */}
|
||||||
<h1 className="text-2xl font-bold sm:text-3xl">Định giá AI</h1>
|
<div className="flex items-start justify-between">
|
||||||
<p className="mt-2 text-muted-foreground">
|
<div>
|
||||||
Sử dụng AI để ước tính giá trị bất động sản dựa trên dữ liệu thị trường
|
<h1 className="text-2xl font-bold sm:text-3xl">Định giá AI</h1>
|
||||||
</p>
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Sử dụng AI để ước tính giá trị bất động sản dựa trên dữ liệu thị trường
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{currentResult && (
|
||||||
|
<ExportPdfButton
|
||||||
|
targetSelector="#valuation-results"
|
||||||
|
filename={`dinh-gia-${currentResult.id}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
{/* Form + Results */}
|
{/* Form + Results (left 2 cols) */}
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className="space-y-6 lg:col-span-2">
|
||||||
<ValuationForm
|
<ValuationForm
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
@@ -54,10 +84,31 @@ export default function ValuationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentResult && <ValuationResults result={currentResult} />}
|
{currentResult && (
|
||||||
|
<>
|
||||||
|
{/* Main results with confidence badge + driver charts */}
|
||||||
|
<ValuationResults result={currentResult} />
|
||||||
|
|
||||||
|
{/* Comparables table (TanStack Table) */}
|
||||||
|
{currentResult.comparables.length > 0 && (
|
||||||
|
<ComparablesTable comparables={currentResult.comparables} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Market context card */}
|
||||||
|
{currentResult.marketContext && (
|
||||||
|
<MarketContextCard context={currentResult.marketContext} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Valuation history chart */}
|
||||||
|
{currentResult.valuationHistory &&
|
||||||
|
currentResult.valuationHistory.length >= 2 && (
|
||||||
|
<ValuationHistoryChart data={currentResult.valuationHistory} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* History sidebar */}
|
{/* History sidebar (right col) */}
|
||||||
<div>
|
<div>
|
||||||
<ValuationHistory
|
<ValuationHistory
|
||||||
items={historyData?.data ?? []}
|
items={historyData?.data ?? []}
|
||||||
|
|||||||
156
apps/web/components/valuation/comparables-table.tsx
Normal file
156
apps/web/components/valuation/comparables-table.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type SortingState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { ArrowUpDown, MapPin } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
||||||
|
import type { ValuationComparable } from '@/lib/valuation-api';
|
||||||
|
|
||||||
|
interface ComparablesTableProps {
|
||||||
|
comparables: ValuationComparable[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<ValuationComparable>();
|
||||||
|
|
||||||
|
function getSimilarityBadge(similarity: number): {
|
||||||
|
label: string;
|
||||||
|
variant: 'success' | 'warning' | 'info';
|
||||||
|
} {
|
||||||
|
const pct = Math.round(similarity * 100);
|
||||||
|
if (pct >= 85) return { label: `${pct}% tương tự`, variant: 'success' };
|
||||||
|
if (pct >= 70) return { label: `${pct}% tương tự`, variant: 'info' };
|
||||||
|
return { label: `${pct}% tương tự`, variant: 'warning' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor('title', {
|
||||||
|
header: 'Bất động sản',
|
||||||
|
cell: (info) => {
|
||||||
|
const row = info.row.original;
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{info.getValue()}</p>
|
||||||
|
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
{row.district}
|
||||||
|
{row.address ? ` — ${row.address}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('areaM2', {
|
||||||
|
header: 'Diện tích',
|
||||||
|
cell: (info) => <span className="whitespace-nowrap">{info.getValue()} m²</span>,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('priceVND', {
|
||||||
|
header: 'Giá',
|
||||||
|
cell: (info) => (
|
||||||
|
<span className="whitespace-nowrap font-semibold text-primary">
|
||||||
|
{formatPrice(info.getValue())}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('pricePerM2', {
|
||||||
|
header: 'Giá/m²',
|
||||||
|
cell: (info) => (
|
||||||
|
<span className="whitespace-nowrap text-muted-foreground">
|
||||||
|
{formatPricePerM2(info.getValue())}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('similarity', {
|
||||||
|
header: 'Tương đồng',
|
||||||
|
cell: (info) => {
|
||||||
|
const badge = getSimilarityBadge(info.getValue());
|
||||||
|
return <Badge variant={badge.variant}>{badge.label}</Badge>;
|
||||||
|
},
|
||||||
|
sortDescFirst: true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ComparablesTable({ comparables }: ComparablesTableProps) {
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
|
{ id: 'similarity', desc: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: comparables,
|
||||||
|
columns,
|
||||||
|
state: { sorting },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (comparables.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Bất động sản tương tự</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{comparables.length} bất động sản có đặc điểm tương tự trong khu vực
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id} className="border-b text-left">
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th
|
||||||
|
key={header.id}
|
||||||
|
className="pb-2 pr-4 font-medium"
|
||||||
|
>
|
||||||
|
{header.isPlaceholder ? null : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 hover:text-foreground"
|
||||||
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
<ArrowUpDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<tr key={row.id} className="border-b last:border-0">
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td key={cell.id} className="py-2 pr-4">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
apps/web/components/valuation/export-pdf-button.tsx
Normal file
176
apps/web/components/valuation/export-pdf-button.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Download, Loader2 } from 'lucide-react';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface ExportPdfButtonProps {
|
||||||
|
/** CSS selector for the DOM element to capture */
|
||||||
|
targetSelector: string;
|
||||||
|
/** Filename without extension */
|
||||||
|
filename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportPdfButton({
|
||||||
|
targetSelector,
|
||||||
|
filename = 'dinh-gia-bat-dong-san',
|
||||||
|
}: ExportPdfButtonProps) {
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
const handleExport = useCallback(async () => {
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
const element = document.querySelector(targetSelector);
|
||||||
|
if (!element) {
|
||||||
|
console.error('Export target not found:', targetSelector);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic imports for client-only PDF libraries
|
||||||
|
const [html2canvasModule, jsPDFModule] = await Promise.all([
|
||||||
|
import('html2canvas'),
|
||||||
|
import('jspdf'),
|
||||||
|
]);
|
||||||
|
const html2canvas = html2canvasModule.default;
|
||||||
|
const { jsPDF } = jsPDFModule;
|
||||||
|
|
||||||
|
const canvas = await html2canvas(element as HTMLElement, {
|
||||||
|
scale: 2,
|
||||||
|
useCORS: true,
|
||||||
|
logging: false,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
const imgData = canvas.toDataURL('image/png');
|
||||||
|
const imgWidth = canvas.width;
|
||||||
|
const imgHeight = canvas.height;
|
||||||
|
|
||||||
|
// A4 dimensions in mm
|
||||||
|
const pdfWidth = 210;
|
||||||
|
const pdfMargin = 10;
|
||||||
|
const contentWidth = pdfWidth - 2 * pdfMargin;
|
||||||
|
const contentHeight = (imgHeight / imgWidth) * contentWidth;
|
||||||
|
|
||||||
|
const pdf = new jsPDF({
|
||||||
|
orientation: contentHeight > 297 - 2 * pdfMargin ? 'portrait' : 'portrait',
|
||||||
|
unit: 'mm',
|
||||||
|
format: 'a4',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add header
|
||||||
|
pdf.setFontSize(16);
|
||||||
|
pdf.setTextColor(34, 139, 34); // Green
|
||||||
|
pdf.text('GoodGo — Báo cáo định giá', pdfMargin, pdfMargin + 5);
|
||||||
|
pdf.setFontSize(10);
|
||||||
|
pdf.setTextColor(128, 128, 128);
|
||||||
|
pdf.text(
|
||||||
|
`Ngày: ${new Date().toLocaleDateString('vi-VN')}`,
|
||||||
|
pdfMargin,
|
||||||
|
pdfMargin + 12,
|
||||||
|
);
|
||||||
|
|
||||||
|
const headerHeight = 20;
|
||||||
|
const availableHeight = 297 - 2 * pdfMargin - headerHeight;
|
||||||
|
|
||||||
|
if (contentHeight <= availableHeight) {
|
||||||
|
// Fits on one page
|
||||||
|
pdf.addImage(
|
||||||
|
imgData,
|
||||||
|
'PNG',
|
||||||
|
pdfMargin,
|
||||||
|
pdfMargin + headerHeight,
|
||||||
|
contentWidth,
|
||||||
|
contentHeight,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Multi-page: split the image
|
||||||
|
let yOffset = 0;
|
||||||
|
let isFirstPage = true;
|
||||||
|
|
||||||
|
while (yOffset < contentHeight) {
|
||||||
|
if (!isFirstPage) {
|
||||||
|
pdf.addPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageContentHeight = isFirstPage
|
||||||
|
? availableHeight
|
||||||
|
: 297 - 2 * pdfMargin;
|
||||||
|
const pageTopMargin = isFirstPage
|
||||||
|
? pdfMargin + headerHeight
|
||||||
|
: pdfMargin;
|
||||||
|
|
||||||
|
// Calculate source rectangle in image coordinates
|
||||||
|
const srcY = (yOffset / contentHeight) * imgHeight;
|
||||||
|
const srcHeight = (pageContentHeight / contentHeight) * imgHeight;
|
||||||
|
|
||||||
|
// Create a temporary canvas for this page slice
|
||||||
|
const pageCanvas = document.createElement('canvas');
|
||||||
|
pageCanvas.width = imgWidth;
|
||||||
|
pageCanvas.height = Math.min(srcHeight, imgHeight - srcY);
|
||||||
|
|
||||||
|
const ctx = pageCanvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(
|
||||||
|
canvas,
|
||||||
|
0,
|
||||||
|
srcY,
|
||||||
|
imgWidth,
|
||||||
|
pageCanvas.height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
imgWidth,
|
||||||
|
pageCanvas.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageImgData = pageCanvas.toDataURL('image/png');
|
||||||
|
const sliceHeight =
|
||||||
|
(pageCanvas.height / imgWidth) * contentWidth;
|
||||||
|
|
||||||
|
pdf.addImage(
|
||||||
|
pageImgData,
|
||||||
|
'PNG',
|
||||||
|
pdfMargin,
|
||||||
|
pageTopMargin,
|
||||||
|
contentWidth,
|
||||||
|
sliceHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
yOffset += pageContentHeight;
|
||||||
|
isFirstPage = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer on last page
|
||||||
|
pdf.setFontSize(8);
|
||||||
|
pdf.setTextColor(180, 180, 180);
|
||||||
|
pdf.text(
|
||||||
|
'Được tạo bởi GoodGo AI Valuation — goodgo.vn',
|
||||||
|
pdfMargin,
|
||||||
|
297 - pdfMargin,
|
||||||
|
);
|
||||||
|
|
||||||
|
pdf.save(`${filename}.pdf`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PDF export failed:', error);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
}, [targetSelector, filename]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={isExporting}
|
||||||
|
>
|
||||||
|
{isExporting ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isExporting ? 'Đang xuất...' : 'Xuất PDF'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
apps/web/components/valuation/market-context-card.tsx
Normal file
95
apps/web/components/valuation/market-context-card.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Building,
|
||||||
|
CalendarDays,
|
||||||
|
TrendingDown,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Warehouse,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
||||||
|
import type { MarketContext } from '@/lib/valuation-api';
|
||||||
|
|
||||||
|
interface MarketContextCardProps {
|
||||||
|
context: MarketContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarketContextCard({ context }: MarketContextCardProps) {
|
||||||
|
const isGrowthPositive = context.priceGrowthYoY >= 0;
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
label: 'Giá trung bình/m²',
|
||||||
|
value: formatPricePerM2(context.avgPricePerM2),
|
||||||
|
icon: Building,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Giá trung vị',
|
||||||
|
value: formatPrice(context.medianPrice),
|
||||||
|
icon: Warehouse,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tăng trưởng YoY',
|
||||||
|
value: `${isGrowthPositive ? '+' : ''}${context.priceGrowthYoY.toFixed(1)}%`,
|
||||||
|
icon: isGrowthPositive ? TrendingUp : TrendingDown,
|
||||||
|
color: isGrowthPositive ? 'text-green-600' : 'text-red-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Chỉ số nhu cầu',
|
||||||
|
value: `${context.demandIndex.toFixed(0)}/100`,
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Nguồn cung',
|
||||||
|
value: `${context.supplyCount.toLocaleString('vi-VN')} BĐS`,
|
||||||
|
icon: Building,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Thời gian bán TB',
|
||||||
|
value: `${context.avgDaysOnMarket} ngày`,
|
||||||
|
icon: CalendarDays,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Bối cảnh thị trường</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{context.district}, {context.city} — {context.period}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{stats.map((stat) => {
|
||||||
|
const Icon = stat.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={stat.label}
|
||||||
|
className="flex items-start gap-3 rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<div className="rounded-md bg-muted p-2">
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">{stat.label}</p>
|
||||||
|
<p className={`text-sm font-semibold ${stat.color ?? ''}`}>
|
||||||
|
{stat.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Bot, ImagePlus, Search, X } from 'lucide-react';
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
|
import { useProjectSearch } from '@/lib/hooks/use-valuation';
|
||||||
import {
|
import {
|
||||||
valuationFormSchema,
|
valuationFormSchema,
|
||||||
type ValuationFormData,
|
type ValuationFormData,
|
||||||
@@ -30,15 +39,76 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<ValuationFormData>({
|
} = useForm<ValuationFormData>({
|
||||||
resolver: zodResolver(valuationFormSchema),
|
resolver: zodResolver(valuationFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
city: 'Ho Chi Minh',
|
city: 'Ho Chi Minh',
|
||||||
hasLegalPaper: true,
|
hasLegalPaper: true,
|
||||||
|
deepAnalysis: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Project autocomplete state
|
||||||
|
const [projectQuery, setProjectQuery] = useState('');
|
||||||
|
const [projectName, setProjectName] = useState('');
|
||||||
|
const [showProjectDropdown, setShowProjectDropdown] = useState(false);
|
||||||
|
const { data: projectResults } = useProjectSearch(projectQuery);
|
||||||
|
const projectInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Image upload state
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleProjectSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setProjectQuery(value);
|
||||||
|
setProjectName(value);
|
||||||
|
setShowProjectDropdown(value.length >= 2);
|
||||||
|
if (!value) {
|
||||||
|
setValue('projectId', '');
|
||||||
|
}
|
||||||
|
}, [setValue]);
|
||||||
|
|
||||||
|
const handleSelectProject = useCallback(
|
||||||
|
(id: string, name: string) => {
|
||||||
|
setValue('projectId', id);
|
||||||
|
setProjectName(name);
|
||||||
|
setProjectQuery('');
|
||||||
|
setShowProjectDropdown(false);
|
||||||
|
},
|
||||||
|
[setValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleImageChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Show local preview
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
setImagePreview(ev.target?.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
// In production, upload to server and get URL
|
||||||
|
// For now we store as data URL for preview purposes
|
||||||
|
setImageUrl(URL.createObjectURL(file));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClearImage = useCallback(() => {
|
||||||
|
setImagePreview(null);
|
||||||
|
setImageUrl(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleFormSubmit = (data: ValuationFormData) => {
|
const handleFormSubmit = (data: ValuationFormData) => {
|
||||||
onSubmit({
|
onSubmit({
|
||||||
propertyType: data.propertyType,
|
propertyType: data.propertyType,
|
||||||
@@ -52,6 +122,10 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
|
|||||||
roadWidth: toNum(data.roadWidth),
|
roadWidth: toNum(data.roadWidth),
|
||||||
yearBuilt: toNum(data.yearBuilt),
|
yearBuilt: toNum(data.yearBuilt),
|
||||||
hasLegalPaper: data.hasLegalPaper,
|
hasLegalPaper: data.hasLegalPaper,
|
||||||
|
projectId: data.projectId || undefined,
|
||||||
|
description: data.description || undefined,
|
||||||
|
deepAnalysis: data.deepAnalysis,
|
||||||
|
imageUrl: imageUrl || undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,6 +139,56 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||||
|
{/* Project selector (autocomplete) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="projectSearch">Dự án (tùy chọn)</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="projectSearch"
|
||||||
|
ref={projectInputRef}
|
||||||
|
value={projectName}
|
||||||
|
onChange={handleProjectSearch}
|
||||||
|
onFocus={() => projectQuery.length >= 2 && setShowProjectDropdown(true)}
|
||||||
|
onBlur={() => setTimeout(() => setShowProjectDropdown(false), 200)}
|
||||||
|
placeholder="Tìm kiếm dự án..."
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
{projectName && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setProjectName('');
|
||||||
|
setProjectQuery('');
|
||||||
|
setValue('projectId', '');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showProjectDropdown && projectResults?.data && projectResults.data.length > 0 && (
|
||||||
|
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-48 overflow-auto rounded-md border bg-popover p-1 shadow-md">
|
||||||
|
{projectResults.data.map((project) => (
|
||||||
|
<button
|
||||||
|
key={project.id}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-start gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||||
|
onClick={() => handleSelectProject(project.id, project.name)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{project.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{project.district}, {project.city}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Row 1: Property type + City */}
|
{/* Row 1: Property type + City */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -194,15 +318,84 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legal paper checkbox */}
|
{/* Image upload */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-2">
|
||||||
<input
|
<Label>Hình ảnh (tùy chọn)</Label>
|
||||||
id="hasLegalPaper"
|
<div className="flex items-start gap-4">
|
||||||
type="checkbox"
|
{imagePreview ? (
|
||||||
className="h-4 w-4 rounded border-input"
|
<div className="relative h-24 w-24 overflow-hidden rounded-lg border">
|
||||||
{...register('hasLegalPaper')}
|
<img
|
||||||
|
src={imagePreview}
|
||||||
|
alt="Ảnh bất động sản"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearImage}
|
||||||
|
className="absolute right-1 top-1 rounded-full bg-background/80 p-0.5 hover:bg-background"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="flex h-24 w-24 flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-muted-foreground/25 text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ImagePlus className="h-6 w-6" />
|
||||||
|
<span className="text-xs">Tải ảnh</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Tải ảnh bất động sản để AI phân tích trực quan (JPG, PNG, tối đa 5MB)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description textarea */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Mô tả thêm (tùy chọn)</Label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
{...register('description')}
|
||||||
|
rows={3}
|
||||||
|
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder="Mô tả chi tiết về bất động sản: vị trí, hướng, tình trạng, tiện ích lân cận..."
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="hasLegalPaper">Có sổ đỏ/giấy tờ hợp pháp</Label>
|
</div>
|
||||||
|
|
||||||
|
{/* Toggles row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="hasLegalPaper"
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-input"
|
||||||
|
{...register('hasLegalPaper')}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="hasLegalPaper">Có sổ đỏ/giấy tờ hợp pháp</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="deepAnalysis"
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-input accent-primary"
|
||||||
|
{...register('deepAnalysis')}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="deepAnalysis" className="flex items-center gap-1.5">
|
||||||
|
<Bot className="h-4 w-4 text-primary" />
|
||||||
|
Phân tích chuyên sâu
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={isLoading} className="w-full sm:w-auto">
|
<Button type="submit" disabled={isLoading} className="w-full sm:w-auto">
|
||||||
|
|||||||
110
apps/web/components/valuation/valuation-history-chart.tsx
Normal file
110
apps/web/components/valuation/valuation-history-chart.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { formatPrice } from '@/lib/currency';
|
||||||
|
import type { ValuationHistoryPoint } from '@/lib/valuation-api';
|
||||||
|
|
||||||
|
interface ValuationHistoryChartProps {
|
||||||
|
data: ValuationHistoryPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChartDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('vi-VN', { month: 'short', year: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTooltipDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('vi-VN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ValuationHistoryChart({ data }: ValuationHistoryChartProps) {
|
||||||
|
if (data.length < 2) return null;
|
||||||
|
|
||||||
|
const chartData = data.map((point) => ({
|
||||||
|
date: point.date,
|
||||||
|
price: point.estimatedPriceVND,
|
||||||
|
confidence: Math.round(point.confidence * 100),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Lịch sử định giá</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Biến động giá ước tính theo thời gian
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={chartData} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="priceGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={formatChartDate}
|
||||||
|
className="fill-muted-foreground"
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(val: number) => formatPrice(val)}
|
||||||
|
className="fill-muted-foreground"
|
||||||
|
fontSize={12}
|
||||||
|
width={80}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'hsl(var(--card))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
labelFormatter={(label) =>
|
||||||
|
typeof label === 'string' ? formatTooltipDate(label) : label
|
||||||
|
}
|
||||||
|
formatter={(value, name) => {
|
||||||
|
const num = Number(value);
|
||||||
|
if (name === 'price') return [formatPrice(num) + ' VNĐ', 'Giá ước tính'];
|
||||||
|
if (name === 'confidence') return [`${num}%`, 'Độ tin cậy'];
|
||||||
|
return [String(value), name];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="price"
|
||||||
|
stroke="hsl(var(--primary))"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#priceGradient)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { formatPrice } from '@/lib/currency';
|
||||||
import type { ValuationHistoryItem } from '@/lib/valuation-api';
|
import type { ValuationHistoryItem } from '@/lib/valuation-api';
|
||||||
|
|
||||||
interface ValuationHistoryProps {
|
interface ValuationHistoryProps {
|
||||||
@@ -13,12 +14,6 @@ interface ValuationHistoryProps {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPrice(num: number): string {
|
|
||||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)} tỷ`;
|
|
||||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
|
|
||||||
return num.toLocaleString('vi-VN');
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROPERTY_TYPE_LABELS: Record<string, string> = {
|
const PROPERTY_TYPE_LABELS: Record<string, string> = {
|
||||||
APARTMENT: 'Căn hộ',
|
APARTMENT: 'Căn hộ',
|
||||||
HOUSE: 'Nhà riêng',
|
HOUSE: 'Nhà riêng',
|
||||||
|
|||||||
@@ -1,138 +1,219 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { ShieldCheck, ShieldAlert, ShieldQuestion, TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
import type { ValuationResult } from '@/lib/valuation-api';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
||||||
|
import type { ConfidenceExplanation, ValuationResult } from '@/lib/valuation-api';
|
||||||
|
|
||||||
interface ValuationResultsProps {
|
interface ValuationResultsProps {
|
||||||
result: ValuationResult;
|
result: ValuationResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPrice(num: number): string {
|
function getConfidenceBadge(confidence: number): {
|
||||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)} tỷ`;
|
label: string;
|
||||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
|
variant: 'success' | 'warning' | 'destructive';
|
||||||
return num.toLocaleString('vi-VN');
|
icon: typeof ShieldCheck;
|
||||||
|
} {
|
||||||
|
if (confidence >= 0.8) {
|
||||||
|
return { label: 'Độ tin cậy cao', variant: 'success', icon: ShieldCheck };
|
||||||
|
}
|
||||||
|
if (confidence >= 0.5) {
|
||||||
|
return { label: 'Độ tin cậy trung bình', variant: 'warning', icon: ShieldAlert };
|
||||||
|
}
|
||||||
|
return { label: 'Độ tin cậy thấp', variant: 'destructive', icon: ShieldQuestion };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPriceM2(price: number): string {
|
function ConfidenceDetail({ explanation }: { explanation: ConfidenceExplanation }) {
|
||||||
if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m²`;
|
return (
|
||||||
return `${price.toLocaleString('vi-VN')} đ/m²`;
|
<div className="mt-4 space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{explanation.summary}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{explanation.factors.map((factor) => (
|
||||||
|
<div key={factor.factor} className="flex items-start gap-2 text-sm">
|
||||||
|
<span
|
||||||
|
className={`mt-0.5 ${
|
||||||
|
factor.contribution === 'positive' ? 'text-green-600' : 'text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{factor.contribution === 'positive' ? (
|
||||||
|
<TrendingUp className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{factor.factor}</span>
|
||||||
|
<span className="text-muted-foreground"> — {factor.detail}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PriceRangeBar({
|
||||||
|
low,
|
||||||
|
high,
|
||||||
|
estimated,
|
||||||
|
}: {
|
||||||
|
low: number;
|
||||||
|
high: number;
|
||||||
|
estimated: number;
|
||||||
|
}) {
|
||||||
|
const range = high - low;
|
||||||
|
const position = range > 0 ? ((estimated - low) / range) * 100 : 50;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="relative h-3 rounded-full bg-gradient-to-r from-red-200 via-yellow-200 to-green-200">
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-primary bg-background shadow-sm"
|
||||||
|
style={{ left: `${Math.max(5, Math.min(95, position))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{formatPrice(low)}</span>
|
||||||
|
<span className="font-medium text-foreground">{formatPrice(estimated)}</span>
|
||||||
|
<span>{formatPrice(high)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ValuationResults({ result }: ValuationResultsProps) {
|
export function ValuationResults({ result }: ValuationResultsProps) {
|
||||||
const confidencePct = Math.round(result.confidence * 100);
|
const confidencePct = Math.round(result.confidence * 100);
|
||||||
|
const badge = getConfidenceBadge(result.confidence);
|
||||||
|
const BadgeIcon = badge.icon;
|
||||||
|
|
||||||
|
// Sort drivers by absolute impact for chart display
|
||||||
|
const sortedDrivers = [...result.priceDrivers].sort(
|
||||||
|
(a, b) => Math.abs(b.impact) - Math.abs(a.impact),
|
||||||
|
);
|
||||||
|
const maxImpact = Math.max(...sortedDrivers.map((d) => Math.abs(d.impact)), 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div id="valuation-results" className="space-y-6">
|
||||||
{/* Main estimate */}
|
{/* Main estimate card */}
|
||||||
<Card className="border-primary/20 bg-primary/5">
|
<Card className="border-primary/20 bg-primary/5">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardDescription>Giá ước tính bởi AI</CardDescription>
|
<div className="flex items-start justify-between">
|
||||||
<CardTitle className="text-3xl text-primary">
|
<div>
|
||||||
{formatPrice(result.estimatedPriceVND)} VNĐ
|
<CardDescription>Giá ước tính bởi AI</CardDescription>
|
||||||
</CardTitle>
|
<CardTitle className="text-3xl text-primary sm:text-4xl">
|
||||||
|
{formatPrice(result.estimatedPriceVND)} VNĐ
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Badge variant={badge.variant} className="flex items-center gap-1">
|
||||||
|
<BadgeIcon className="h-3.5 w-3.5" />
|
||||||
|
{badge.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
{/* Stats grid */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Độ tin cậy</p>
|
<p className="text-sm text-muted-foreground">Độ tin cậy</p>
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
<div className="h-2 flex-1 rounded-full bg-muted">
|
<div className="h-2.5 flex-1 rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
className="h-2 rounded-full bg-primary transition-all"
|
className={`h-2.5 rounded-full transition-all ${
|
||||||
|
confidencePct >= 80
|
||||||
|
? 'bg-green-500'
|
||||||
|
: confidencePct >= 50
|
||||||
|
? 'bg-yellow-500'
|
||||||
|
: 'bg-red-500'
|
||||||
|
}`}
|
||||||
style={{ width: `${confidencePct}%` }}
|
style={{ width: `${confidencePct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">{confidencePct}%</span>
|
<span className="text-sm font-semibold">{confidencePct}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Giá/m²</p>
|
<p className="text-sm text-muted-foreground">Giá/m²</p>
|
||||||
<p className="mt-1 text-lg font-semibold">{formatPriceM2(result.pricePerM2)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Khoảng giá</p>
|
|
||||||
<p className="mt-1 text-lg font-semibold">
|
<p className="mt-1 text-lg font-semibold">
|
||||||
{formatPrice(result.priceRangeLow)} – {formatPrice(result.priceRangeHigh)}
|
{formatPricePerM2(result.pricePerM2)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Price range bar */}
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm text-muted-foreground">Khoảng giá</p>
|
||||||
|
<PriceRangeBar
|
||||||
|
low={result.priceRangeLow}
|
||||||
|
high={result.priceRangeHigh}
|
||||||
|
estimated={result.estimatedPriceVND}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confidence explanation (deep analysis) */}
|
||||||
|
{result.confidenceExplanation && (
|
||||||
|
<ConfidenceDetail explanation={result.confidenceExplanation} />
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Price drivers */}
|
{/* Value drivers — horizontal bar chart */}
|
||||||
{result.priceDrivers.length > 0 && (
|
{sortedDrivers.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Yếu tố ảnh hưởng giá</CardTitle>
|
<CardTitle className="text-lg">Yếu tố ảnh hưởng giá</CardTitle>
|
||||||
<CardDescription>Các yếu tố chính tác động đến giá trị bất động sản</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{result.priceDrivers.map((driver) => (
|
|
||||||
<div key={driver.feature} className="flex items-center gap-3">
|
|
||||||
<span
|
|
||||||
className={`text-sm font-medium ${
|
|
||||||
driver.direction === 'positive' ? 'text-green-600' : 'text-red-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{driver.direction === 'positive' ? '+' : '-'}
|
|
||||||
{Math.abs(driver.impact).toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm">{driver.feature}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 h-1.5 rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
className={`h-1.5 rounded-full ${
|
|
||||||
driver.direction === 'positive' ? 'bg-green-500' : 'bg-red-500'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${Math.min(Math.abs(driver.impact), 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Comparables */}
|
|
||||||
{result.comparables.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">Bất động sản tương tự</CardTitle>
|
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{result.comparables.length} bất động sản có đặc điểm tương tự trong khu vực
|
Các yếu tố chính tác động đến giá trị bất động sản
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{result.comparables.map((comp) => (
|
{sortedDrivers.map((driver) => {
|
||||||
<div
|
const barWidth = (Math.abs(driver.impact) / maxImpact) * 100;
|
||||||
key={comp.id}
|
const isPositive = driver.direction === 'positive';
|
||||||
className="flex items-center gap-4 rounded-lg border p-3"
|
|
||||||
>
|
return (
|
||||||
<div className="min-w-0 flex-1">
|
<div key={driver.feature} className="space-y-1">
|
||||||
<p className="truncate font-medium">{comp.title}</p>
|
<div className="flex items-center justify-between text-sm">
|
||||||
<p className="text-sm text-muted-foreground">
|
<span className="font-medium">{driver.feature}</span>
|
||||||
{comp.district} · {comp.areaM2} m²
|
<span
|
||||||
</p>
|
className={`font-semibold ${
|
||||||
|
isPositive ? 'text-green-600' : 'text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPositive ? '+' : '-'}
|
||||||
|
{Math.abs(driver.impact).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative h-6 rounded bg-muted">
|
||||||
|
<div
|
||||||
|
className={`absolute inset-y-0 left-0 flex items-center rounded transition-all ${
|
||||||
|
isPositive
|
||||||
|
? 'bg-green-500/20 text-green-700'
|
||||||
|
: 'bg-red-500/20 text-red-700'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.max(barWidth, 8)}%` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-full rounded ${
|
||||||
|
isPositive ? 'bg-green-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: '100%', opacity: 0.7 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{driver.explanation && (
|
||||||
|
<p className="text-xs text-muted-foreground">{driver.explanation}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
);
|
||||||
<p className="font-semibold text-primary">{formatPrice(Number(comp.priceVND))}</p>
|
})}
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{formatPriceM2(comp.pricePerM2)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:block">
|
|
||||||
<span className="rounded-full bg-accent px-2 py-1 text-xs font-medium">
|
|
||||||
{Math.round(comp.similarity * 100)}% tương tự
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { valuationApi, type ValuationRequest } from '@/lib/valuation-api';
|
import {
|
||||||
|
valuationApi,
|
||||||
|
type ValuationRequest,
|
||||||
|
type BatchValuationRequest,
|
||||||
|
} from '@/lib/valuation-api';
|
||||||
|
|
||||||
export const valuationKeys = {
|
export const valuationKeys = {
|
||||||
all: ['valuation'] as const,
|
all: ['valuation'] as const,
|
||||||
history: (page: number) => ['valuation', 'history', page] as const,
|
history: (page: number) => ['valuation', 'history', page] as const,
|
||||||
detail: (id: string) => ['valuation', 'detail', id] as const,
|
detail: (id: string) => ['valuation', 'detail', id] as const,
|
||||||
|
propertyHistory: (propertyId: string) =>
|
||||||
|
['valuation', 'property-history', propertyId] as const,
|
||||||
|
compare: (ids: string[]) => ['valuation', 'compare', ...ids] as const,
|
||||||
|
projectSearch: (query: string) =>
|
||||||
|
['valuation', 'project-search', query] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useValuationPredict() {
|
export function useValuationPredict() {
|
||||||
@@ -24,6 +33,25 @@ export function useValuationPredictForListing() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useValuationBatch() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: BatchValuationRequest) => valuationApi.batchPredict(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: valuationKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useValuationCompare(propertyIds: string[]) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: valuationKeys.compare(propertyIds),
|
||||||
|
queryFn: () => valuationApi.compare({ propertyIds }),
|
||||||
|
enabled: propertyIds.length >= 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useValuationHistory(page = 1) {
|
export function useValuationHistory(page = 1) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: valuationKeys.history(page),
|
queryKey: valuationKeys.history(page),
|
||||||
@@ -31,6 +59,14 @@ export function useValuationHistory(page = 1) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useValuationPropertyHistory(propertyId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: valuationKeys.propertyHistory(propertyId),
|
||||||
|
queryFn: () => valuationApi.getPropertyHistory(propertyId),
|
||||||
|
enabled: !!propertyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useValuationDetail(id: string) {
|
export function useValuationDetail(id: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: valuationKeys.detail(id),
|
queryKey: valuationKeys.detail(id),
|
||||||
@@ -38,3 +74,12 @@ export function useValuationDetail(id: string) {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useProjectSearch(query: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: valuationKeys.projectSearch(query),
|
||||||
|
queryFn: () => valuationApi.searchProjects(query),
|
||||||
|
enabled: query.length >= 2,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const VALUATION_PROPERTY_TYPES = [
|
export const VALUATION_PROPERTY_TYPES = [
|
||||||
{ value: 'APARTMENT', label: 'Can ho' },
|
{ value: 'APARTMENT', label: 'Căn hộ' },
|
||||||
{ value: 'HOUSE', label: 'Nha rieng' },
|
{ value: 'HOUSE', label: 'Nhà riêng' },
|
||||||
{ value: 'VILLA', label: 'Biet thu' },
|
{ value: 'VILLA', label: 'Biệt thự' },
|
||||||
{ value: 'LAND', label: 'Dat nen' },
|
{ value: 'LAND', label: 'Đất nền' },
|
||||||
{ value: 'OFFICE', label: 'Van phong' },
|
{ value: 'OFFICE', label: 'Văn phòng' },
|
||||||
{ value: 'SHOPHOUSE', label: 'Shophouse' },
|
{ value: 'SHOPHOUSE', label: 'Shophouse' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const CITIES = [
|
export const CITIES = [
|
||||||
{ value: 'Ho Chi Minh', label: 'TP. Ho Chi Minh' },
|
{ value: 'Ho Chi Minh', label: 'TP. Hồ Chí Minh' },
|
||||||
{ value: 'Ha Noi', label: 'Ha Noi' },
|
{ value: 'Ha Noi', label: 'Hà Nội' },
|
||||||
{ value: 'Da Nang', label: 'Da Nang' },
|
{ value: 'Da Nang', label: 'Đà Nẵng' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const valuationFormSchema = z.object({
|
export const valuationFormSchema = z.object({
|
||||||
propertyType: z.string().min(1, 'Vui long chon loai bat dong san'),
|
propertyType: z.string().min(1, 'Vui lòng chọn loại bất động sản'),
|
||||||
area: z.string().min(1, 'Vui long nhap dien tich').refine(
|
area: z.string().min(1, 'Vui lòng nhập diện tích').refine(
|
||||||
(val) => !isNaN(Number(val)) && Number(val) > 0,
|
(val) => !isNaN(Number(val)) && Number(val) > 0,
|
||||||
'Dien tich phai lon hon 0',
|
'Diện tích phải lớn hơn 0',
|
||||||
),
|
),
|
||||||
district: z.string().min(1, 'Vui long nhap quan/huyen'),
|
district: z.string().min(1, 'Vui lòng nhập quận/huyện'),
|
||||||
city: z.string().min(1, 'Vui long chon tinh/thanh pho'),
|
city: z.string().min(1, 'Vui lòng chọn tỉnh/thành phố'),
|
||||||
bedrooms: z.string().optional(),
|
bedrooms: z.string().optional(),
|
||||||
bathrooms: z.string().optional(),
|
bathrooms: z.string().optional(),
|
||||||
floors: z.string().optional(),
|
floors: z.string().optional(),
|
||||||
@@ -30,6 +30,10 @@ export const valuationFormSchema = z.object({
|
|||||||
roadWidth: z.string().optional(),
|
roadWidth: z.string().optional(),
|
||||||
yearBuilt: z.string().optional(),
|
yearBuilt: z.string().optional(),
|
||||||
hasLegalPaper: z.boolean().optional(),
|
hasLegalPaper: z.boolean().optional(),
|
||||||
|
/** New fields for enhanced form */
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
deepAnalysis: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ValuationFormData = z.infer<typeof valuationFormSchema>;
|
export type ValuationFormData = z.infer<typeof valuationFormSchema>;
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ export interface ValuationRequest {
|
|||||||
hasLegalPaper?: boolean;
|
hasLegalPaper?: boolean;
|
||||||
latitude?: number;
|
latitude?: number;
|
||||||
longitude?: number;
|
longitude?: number;
|
||||||
|
/** Optional project ID for project-based valuation */
|
||||||
|
projectId?: string;
|
||||||
|
/** Image file for visual analysis */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Description text for AI context */
|
||||||
|
description?: string;
|
||||||
|
/** Request deep analysis (confidence explanation, more drivers) */
|
||||||
|
deepAnalysis?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValuationComparable {
|
export interface ValuationComparable {
|
||||||
@@ -27,6 +35,11 @@ export interface ValuationComparable {
|
|||||||
areaM2: number;
|
areaM2: number;
|
||||||
pricePerM2: number;
|
pricePerM2: number;
|
||||||
similarity: number;
|
similarity: number;
|
||||||
|
propertyType?: string;
|
||||||
|
bedrooms?: number;
|
||||||
|
bathrooms?: number;
|
||||||
|
floors?: number;
|
||||||
|
yearBuilt?: number;
|
||||||
latitude?: number;
|
latitude?: number;
|
||||||
longitude?: number;
|
longitude?: number;
|
||||||
}
|
}
|
||||||
@@ -35,6 +48,37 @@ export interface PriceDriver {
|
|||||||
feature: string;
|
feature: string;
|
||||||
impact: number;
|
impact: number;
|
||||||
direction: 'positive' | 'negative';
|
direction: 'positive' | 'negative';
|
||||||
|
/** Human-readable explanation of this driver's impact */
|
||||||
|
explanation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketContext {
|
||||||
|
avgPricePerM2: number;
|
||||||
|
medianPrice: number;
|
||||||
|
priceGrowthYoY: number;
|
||||||
|
demandIndex: number;
|
||||||
|
supplyCount: number;
|
||||||
|
avgDaysOnMarket: number;
|
||||||
|
district: string;
|
||||||
|
city: string;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValuationHistoryPoint {
|
||||||
|
date: string;
|
||||||
|
estimatedPriceVND: number;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfidenceExplanation {
|
||||||
|
level: 'high' | 'medium' | 'low';
|
||||||
|
score: number;
|
||||||
|
factors: Array<{
|
||||||
|
factor: string;
|
||||||
|
contribution: 'positive' | 'negative';
|
||||||
|
detail: string;
|
||||||
|
}>;
|
||||||
|
summary: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValuationResult {
|
export interface ValuationResult {
|
||||||
@@ -48,6 +92,10 @@ export interface ValuationResult {
|
|||||||
priceDrivers: PriceDriver[];
|
priceDrivers: PriceDriver[];
|
||||||
modelVersion: string;
|
modelVersion: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
/** Enhanced fields from deep analysis */
|
||||||
|
confidenceExplanation?: ConfidenceExplanation;
|
||||||
|
marketContext?: MarketContext;
|
||||||
|
valuationHistory?: ValuationHistoryPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValuationHistoryItem {
|
export interface ValuationHistoryItem {
|
||||||
@@ -68,27 +116,105 @@ export interface ValuationHistoryResponse {
|
|||||||
limit: number;
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BatchValuationRequest {
|
||||||
|
properties: ValuationRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchValuationResponse {
|
||||||
|
results: ValuationResult[];
|
||||||
|
totalProcessed: number;
|
||||||
|
errors: Array<{ index: number; message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValuationCompareRequest {
|
||||||
|
propertyIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValuationCompareResponse {
|
||||||
|
properties: Array<{
|
||||||
|
id: string;
|
||||||
|
valuation: ValuationResult;
|
||||||
|
property: {
|
||||||
|
title: string;
|
||||||
|
district: string;
|
||||||
|
city: string;
|
||||||
|
area: number;
|
||||||
|
propertyType: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSuggestion {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
district: string;
|
||||||
|
city: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── API ────────────────────────────────────────────────
|
// ─── API ────────────────────────────────────────────────
|
||||||
|
|
||||||
export const valuationApi = {
|
export const valuationApi = {
|
||||||
/** Request AVM estimate via GET /analytics/valuation */
|
/** Request AVM estimate via POST /analytics/valuation */
|
||||||
predict: (data: ValuationRequest) => {
|
predict: (data: ValuationRequest) => {
|
||||||
const params = new URLSearchParams();
|
// Build request body with all fields
|
||||||
if (data.latitude) params.set('latitude', String(data.latitude));
|
const body: Record<string, unknown> = {
|
||||||
if (data.longitude) params.set('longitude', String(data.longitude));
|
propertyType: data.propertyType,
|
||||||
if (data.area) params.set('areaM2', String(data.area));
|
areaM2: data.area,
|
||||||
if (data.propertyType) params.set('propertyType', data.propertyType);
|
district: data.district,
|
||||||
const qs = params.toString();
|
city: data.city,
|
||||||
return apiClient.get<ValuationResult>(`/analytics/valuation${qs ? `?${qs}` : ''}`);
|
};
|
||||||
|
|
||||||
|
if (data.bedrooms != null) body['bedrooms'] = data.bedrooms;
|
||||||
|
if (data.bathrooms != null) body['bathrooms'] = data.bathrooms;
|
||||||
|
if (data.floors != null) body['floors'] = data.floors;
|
||||||
|
if (data.frontage != null) body['frontage'] = data.frontage;
|
||||||
|
if (data.roadWidth != null) body['roadWidth'] = data.roadWidth;
|
||||||
|
if (data.yearBuilt != null) body['yearBuilt'] = data.yearBuilt;
|
||||||
|
if (data.hasLegalPaper != null) body['hasLegalPaper'] = data.hasLegalPaper;
|
||||||
|
if (data.latitude) body['latitude'] = data.latitude;
|
||||||
|
if (data.longitude) body['longitude'] = data.longitude;
|
||||||
|
if (data.projectId) body['projectId'] = data.projectId;
|
||||||
|
if (data.imageUrl) body['imageUrl'] = data.imageUrl;
|
||||||
|
if (data.description) body['description'] = data.description;
|
||||||
|
if (data.deepAnalysis) body['deepAnalysis'] = data.deepAnalysis;
|
||||||
|
|
||||||
|
return apiClient.post<ValuationResult>('/analytics/valuation', body);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** History is not available server-side — return empty result */
|
/** Batch valuation: POST /analytics/valuation/batch (max 50) */
|
||||||
getHistory: (_page = 1, _limit = 10): Promise<ValuationHistoryResponse> =>
|
batchPredict: (data: BatchValuationRequest) =>
|
||||||
Promise.resolve({ data: [], total: 0, page: _page, limit: _limit }),
|
apiClient.post<BatchValuationResponse>('/analytics/valuation/batch', data),
|
||||||
|
|
||||||
|
/** Get valuation history for a property: GET /analytics/valuation/history/:propertyId */
|
||||||
|
getPropertyHistory: (propertyId: string) =>
|
||||||
|
apiClient.get<{ data: ValuationHistoryPoint[] }>(
|
||||||
|
`/analytics/valuation/history/${propertyId}`,
|
||||||
|
),
|
||||||
|
|
||||||
|
/** Compare valuations: POST /analytics/valuation/compare */
|
||||||
|
compare: (data: ValuationCompareRequest) =>
|
||||||
|
apiClient.post<ValuationCompareResponse>('/analytics/valuation/compare', data),
|
||||||
|
|
||||||
|
/** User valuation history (paginated) */
|
||||||
|
getHistory: (page = 1, limit = 10) =>
|
||||||
|
apiClient.get<ValuationHistoryResponse>(
|
||||||
|
`/analytics/valuation/user-history?page=${page}&limit=${limit}`,
|
||||||
|
),
|
||||||
|
|
||||||
|
/** Get single valuation by ID */
|
||||||
getById: (id: string) =>
|
getById: (id: string) =>
|
||||||
apiClient.get<ValuationResult>(`/analytics/valuation?propertyId=${id}`),
|
apiClient.get<ValuationResult>(`/analytics/valuation/${id}`),
|
||||||
|
|
||||||
|
/** Predict for existing listing */
|
||||||
predictForListing: (listingId: string) =>
|
predictForListing: (listingId: string) =>
|
||||||
apiClient.get<ValuationResult>(`/analytics/valuation?propertyId=${listingId}`),
|
apiClient.post<ValuationResult>('/analytics/valuation', {
|
||||||
|
propertyId: listingId,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Search projects for autocomplete */
|
||||||
|
searchProjects: (query: string) =>
|
||||||
|
apiClient.get<{ data: ProjectSuggestion[] }>(
|
||||||
|
`/projects/search?q=${encodeURIComponent(query)}&limit=10`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user