diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 38b0804..457dd49 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,7 +6,9 @@ import { NotificationsModule } from '@modules/notifications'; import { PaymentsModule } from '@modules/payments'; import { SubscriptionsModule } from '@modules/subscriptions'; import { AdminModule } from '@modules/admin'; +import { AnalyticsModule } from '@modules/analytics'; import { MetricsModule } from '@modules/metrics'; +import { McpIntegrationModule } from '@modules/mcp'; import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { CqrsModule } from '@nestjs/cqrs'; @@ -25,7 +27,9 @@ import { AppController } from './app.controller'; PaymentsModule, SubscriptionsModule, AdminModule, + AnalyticsModule, MetricsModule, + McpIntegrationModule, // ── Rate Limiting ── // Default: 60 requests per 60 seconds per IP diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts new file mode 100644 index 0000000..93e486c --- /dev/null +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -0,0 +1,53 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; + +// Domain +import { MARKET_INDEX_REPOSITORY } from './domain/repositories/market-index.repository'; +import { VALUATION_REPOSITORY } from './domain/repositories/valuation.repository'; + +// Infrastructure +import { PrismaMarketIndexRepository } from './infrastructure/repositories/prisma-market-index.repository'; +import { PrismaValuationRepository } from './infrastructure/repositories/prisma-valuation.repository'; + +// Application — Commands +import { TrackEventHandler } from './application/commands/track-event/track-event.handler'; +import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler'; +import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler'; + +// Application — Queries +import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler'; +import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler'; +import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler'; +import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler'; + +// Presentation +import { AnalyticsController } from './presentation/controllers/analytics.controller'; + +const CommandHandlers = [ + TrackEventHandler, + GenerateReportHandler, + UpdateMarketIndexHandler, +]; + +const QueryHandlers = [ + GetMarketReportHandler, + GetHeatmapHandler, + GetPriceTrendHandler, + GetDistrictStatsHandler, +]; + +@Module({ + imports: [CqrsModule], + controllers: [AnalyticsController], + providers: [ + // Repositories + { provide: MARKET_INDEX_REPOSITORY, useClass: PrismaMarketIndexRepository }, + { provide: VALUATION_REPOSITORY, useClass: PrismaValuationRepository }, + + // CQRS + ...CommandHandlers, + ...QueryHandlers, + ], + exports: [MARKET_INDEX_REPOSITORY, VALUATION_REPOSITORY], +}) +export class AnalyticsModule {} diff --git a/apps/api/src/modules/analytics/application/commands/generate-report/generate-report.command.ts b/apps/api/src/modules/analytics/application/commands/generate-report/generate-report.command.ts new file mode 100644 index 0000000..f4d172b --- /dev/null +++ b/apps/api/src/modules/analytics/application/commands/generate-report/generate-report.command.ts @@ -0,0 +1,9 @@ +import { type PropertyType } from '@prisma/client'; + +export class GenerateReportCommand { + constructor( + public readonly city: string, + public readonly period: string, + public readonly propertyType?: PropertyType, + ) {} +} diff --git a/apps/api/src/modules/analytics/application/commands/generate-report/generate-report.handler.ts b/apps/api/src/modules/analytics/application/commands/generate-report/generate-report.handler.ts new file mode 100644 index 0000000..28b1d09 --- /dev/null +++ b/apps/api/src/modules/analytics/application/commands/generate-report/generate-report.handler.ts @@ -0,0 +1,37 @@ +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { Inject } from '@nestjs/common'; +import { GenerateReportCommand } from './generate-report.command'; +import { + MARKET_INDEX_REPOSITORY, + type IMarketIndexRepository, + type MarketReportResult, +} from '../../../domain/repositories/market-index.repository'; + +export interface GenerateReportResult { + city: string; + period: string; + data: MarketReportResult[]; + generatedAt: string; +} + +@CommandHandler(GenerateReportCommand) +export class GenerateReportHandler implements ICommandHandler { + constructor( + @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, + ) {} + + async execute(command: GenerateReportCommand): Promise { + const data = await this.marketIndexRepo.getMarketReport( + command.city, + command.period, + command.propertyType, + ); + + return { + city: command.city, + period: command.period, + data, + generatedAt: new Date().toISOString(), + }; + } +} diff --git a/apps/api/src/modules/analytics/application/commands/track-event/track-event.command.ts b/apps/api/src/modules/analytics/application/commands/track-event/track-event.command.ts new file mode 100644 index 0000000..3324051 --- /dev/null +++ b/apps/api/src/modules/analytics/application/commands/track-event/track-event.command.ts @@ -0,0 +1,9 @@ +export class TrackEventCommand { + constructor( + public readonly eventType: string, + public readonly entityId: string, + public readonly entityType: string, + public readonly metadata: Record, + public readonly userId?: string, + ) {} +} diff --git a/apps/api/src/modules/analytics/application/commands/track-event/track-event.handler.ts b/apps/api/src/modules/analytics/application/commands/track-event/track-event.handler.ts new file mode 100644 index 0000000..d39f383 --- /dev/null +++ b/apps/api/src/modules/analytics/application/commands/track-event/track-event.handler.ts @@ -0,0 +1,24 @@ +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { Logger } from '@nestjs/common'; +import { TrackEventCommand } from './track-event.command'; + +export interface TrackEventResult { + tracked: boolean; + eventType: string; +} + +@CommandHandler(TrackEventCommand) +export class TrackEventHandler implements ICommandHandler { + private readonly logger = new Logger(TrackEventHandler.name); + + async execute(command: TrackEventCommand): Promise { + this.logger.log( + `Analytics event: ${command.eventType} on ${command.entityType}:${command.entityId}`, + ); + + return { + tracked: true, + eventType: command.eventType, + }; + } +} diff --git a/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.command.ts b/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.command.ts new file mode 100644 index 0000000..0fd5343 --- /dev/null +++ b/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.command.ts @@ -0,0 +1,17 @@ +import { type PropertyType } from '@prisma/client'; + +export class UpdateMarketIndexCommand { + constructor( + public readonly district: string, + public readonly city: string, + public readonly propertyType: PropertyType, + public readonly period: string, + public readonly medianPrice: bigint, + public readonly avgPriceM2: number, + public readonly totalListings: number, + public readonly daysOnMarket: number, + public readonly inventoryLevel: number, + public readonly absorptionRate?: number, + public readonly yoyChange?: number, + ) {} +} diff --git a/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.handler.ts b/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.handler.ts new file mode 100644 index 0000000..37cf865 --- /dev/null +++ b/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.handler.ts @@ -0,0 +1,62 @@ +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { Inject } from '@nestjs/common'; +import { UpdateMarketIndexCommand } from './update-market-index.command'; +import { + MARKET_INDEX_REPOSITORY, + type IMarketIndexRepository, +} from '../../../domain/repositories/market-index.repository'; +import { MarketIndexEntity } from '../../../domain/entities/market-index.entity'; + +export interface UpdateMarketIndexResult { + id: string; + created: boolean; +} + +@CommandHandler(UpdateMarketIndexCommand) +export class UpdateMarketIndexHandler implements ICommandHandler { + constructor( + @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, + ) {} + + async execute(command: UpdateMarketIndexCommand): Promise { + const existing = await this.marketIndexRepo.findByKey( + command.district, + command.city, + command.propertyType, + command.period, + ); + + if (existing) { + existing.updateMetrics( + command.medianPrice, + command.avgPriceM2, + command.totalListings, + command.daysOnMarket, + command.inventoryLevel, + command.absorptionRate, + command.yoyChange, + ); + await this.marketIndexRepo.update(existing); + return { id: existing.id, created: false }; + } + + const id = crypto.randomUUID(); + const entity = MarketIndexEntity.createNew( + id, + command.district, + command.city, + command.propertyType, + command.period, + command.medianPrice, + command.avgPriceM2, + command.totalListings, + command.daysOnMarket, + command.inventoryLevel, + command.absorptionRate, + command.yoyChange, + ); + + await this.marketIndexRepo.save(entity); + return { id, created: true }; + } +} diff --git a/apps/api/src/modules/analytics/application/index.ts b/apps/api/src/modules/analytics/application/index.ts new file mode 100644 index 0000000..7f8040d --- /dev/null +++ b/apps/api/src/modules/analytics/application/index.ts @@ -0,0 +1,17 @@ +// Commands +export { TrackEventCommand } from './commands/track-event/track-event.command'; +export { TrackEventHandler, type TrackEventResult } from './commands/track-event/track-event.handler'; +export { GenerateReportCommand } from './commands/generate-report/generate-report.command'; +export { GenerateReportHandler, type GenerateReportResult } from './commands/generate-report/generate-report.handler'; +export { UpdateMarketIndexCommand } from './commands/update-market-index/update-market-index.command'; +export { UpdateMarketIndexHandler, type UpdateMarketIndexResult } from './commands/update-market-index/update-market-index.handler'; + +// Queries +export { GetMarketReportQuery } from './queries/get-market-report/get-market-report.query'; +export { GetMarketReportHandler, type MarketReportDto } from './queries/get-market-report/get-market-report.handler'; +export { GetHeatmapQuery } from './queries/get-heatmap/get-heatmap.query'; +export { GetHeatmapHandler, type HeatmapDto } from './queries/get-heatmap/get-heatmap.handler'; +export { GetPriceTrendQuery } from './queries/get-price-trend/get-price-trend.query'; +export { GetPriceTrendHandler, type PriceTrendDto } from './queries/get-price-trend/get-price-trend.handler'; +export { GetDistrictStatsQuery } from './queries/get-district-stats/get-district-stats.query'; +export { GetDistrictStatsHandler, type DistrictStatsDto } from './queries/get-district-stats/get-district-stats.handler'; diff --git a/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.handler.ts b/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.handler.ts new file mode 100644 index 0000000..c0a639a --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.handler.ts @@ -0,0 +1,31 @@ +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { Inject } from '@nestjs/common'; +import { GetDistrictStatsQuery } from './get-district-stats.query'; +import { + MARKET_INDEX_REPOSITORY, + type IMarketIndexRepository, + type DistrictStatsResult, +} from '../../../domain/repositories/market-index.repository'; + +export interface DistrictStatsDto { + city: string; + period: string; + districts: DistrictStatsResult[]; +} + +@QueryHandler(GetDistrictStatsQuery) +export class GetDistrictStatsHandler implements IQueryHandler { + constructor( + @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, + ) {} + + async execute(query: GetDistrictStatsQuery): Promise { + const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period); + + return { + city: query.city, + period: query.period, + districts, + }; + } +} diff --git a/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.query.ts b/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.query.ts new file mode 100644 index 0000000..bd32cc3 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.query.ts @@ -0,0 +1,6 @@ +export class GetDistrictStatsQuery { + constructor( + public readonly city: string, + public readonly period: string, + ) {} +} diff --git a/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts b/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts new file mode 100644 index 0000000..5edd552 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts @@ -0,0 +1,31 @@ +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { Inject } from '@nestjs/common'; +import { GetHeatmapQuery } from './get-heatmap.query'; +import { + MARKET_INDEX_REPOSITORY, + type IMarketIndexRepository, + type HeatmapDataPoint, +} from '../../../domain/repositories/market-index.repository'; + +export interface HeatmapDto { + city: string; + period: string; + dataPoints: HeatmapDataPoint[]; +} + +@QueryHandler(GetHeatmapQuery) +export class GetHeatmapHandler implements IQueryHandler { + constructor( + @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, + ) {} + + async execute(query: GetHeatmapQuery): Promise { + const dataPoints = await this.marketIndexRepo.getHeatmap(query.city, query.period); + + return { + city: query.city, + period: query.period, + dataPoints, + }; + } +} diff --git a/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.query.ts b/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.query.ts new file mode 100644 index 0000000..d387daa --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.query.ts @@ -0,0 +1,6 @@ +export class GetHeatmapQuery { + constructor( + public readonly city: string, + public readonly period: string, + ) {} +} diff --git a/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.handler.ts b/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.handler.ts new file mode 100644 index 0000000..1fdca53 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.handler.ts @@ -0,0 +1,35 @@ +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { Inject } from '@nestjs/common'; +import { GetMarketReportQuery } from './get-market-report.query'; +import { + MARKET_INDEX_REPOSITORY, + type IMarketIndexRepository, + type MarketReportResult, +} from '../../../domain/repositories/market-index.repository'; + +export interface MarketReportDto { + city: string; + period: string; + districts: MarketReportResult[]; +} + +@QueryHandler(GetMarketReportQuery) +export class GetMarketReportHandler implements IQueryHandler { + constructor( + @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, + ) {} + + async execute(query: GetMarketReportQuery): Promise { + const districts = await this.marketIndexRepo.getMarketReport( + query.city, + query.period, + query.propertyType, + ); + + return { + city: query.city, + period: query.period, + districts, + }; + } +} diff --git a/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.query.ts b/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.query.ts new file mode 100644 index 0000000..5217f70 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.query.ts @@ -0,0 +1,9 @@ +import { type PropertyType } from '@prisma/client'; + +export class GetMarketReportQuery { + constructor( + public readonly city: string, + public readonly period: string, + public readonly propertyType?: PropertyType, + ) {} +} diff --git a/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.handler.ts b/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.handler.ts new file mode 100644 index 0000000..7277890 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.handler.ts @@ -0,0 +1,38 @@ +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { Inject } from '@nestjs/common'; +import { GetPriceTrendQuery } from './get-price-trend.query'; +import { + MARKET_INDEX_REPOSITORY, + type IMarketIndexRepository, + type PriceTrendPoint, +} from '../../../domain/repositories/market-index.repository'; + +export interface PriceTrendDto { + district: string; + city: string; + propertyType: string; + trend: PriceTrendPoint[]; +} + +@QueryHandler(GetPriceTrendQuery) +export class GetPriceTrendHandler implements IQueryHandler { + constructor( + @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, + ) {} + + async execute(query: GetPriceTrendQuery): Promise { + const trend = await this.marketIndexRepo.getPriceTrend( + query.district, + query.city, + query.propertyType, + query.periods, + ); + + return { + district: query.district, + city: query.city, + propertyType: query.propertyType, + trend, + }; + } +} diff --git a/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.query.ts b/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.query.ts new file mode 100644 index 0000000..717af26 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.query.ts @@ -0,0 +1,10 @@ +import { type PropertyType } from '@prisma/client'; + +export class GetPriceTrendQuery { + constructor( + public readonly district: string, + public readonly city: string, + public readonly propertyType: PropertyType, + public readonly periods: string[], + ) {} +} diff --git a/apps/api/src/modules/analytics/domain/entities/index.ts b/apps/api/src/modules/analytics/domain/entities/index.ts new file mode 100644 index 0000000..d90e65e --- /dev/null +++ b/apps/api/src/modules/analytics/domain/entities/index.ts @@ -0,0 +1,2 @@ +export { MarketIndexEntity, type MarketIndexProps } from './market-index.entity'; +export { ValuationEntity, type ValuationProps } from './valuation.entity'; diff --git a/apps/api/src/modules/analytics/domain/entities/market-index.entity.ts b/apps/api/src/modules/analytics/domain/entities/market-index.entity.ts new file mode 100644 index 0000000..a9aeca4 --- /dev/null +++ b/apps/api/src/modules/analytics/domain/entities/market-index.entity.ts @@ -0,0 +1,111 @@ +import { AggregateRoot } from '@modules/shared/domain/aggregate-root'; +import { type PropertyType } from '@prisma/client'; +import { MarketIndexUpdatedEvent } from '../events/market-index-updated.event'; + +export interface MarketIndexProps { + district: string; + city: string; + propertyType: PropertyType; + period: string; + medianPrice: bigint; + avgPriceM2: number; + totalListings: number; + daysOnMarket: number; + inventoryLevel: number; + absorptionRate: number | null; + yoyChange: number | null; +} + +export class MarketIndexEntity extends AggregateRoot { + private _district: string; + private _city: string; + private _propertyType: PropertyType; + private _period: string; + private _medianPrice: bigint; + private _avgPriceM2: number; + private _totalListings: number; + private _daysOnMarket: number; + private _inventoryLevel: number; + private _absorptionRate: number | null; + private _yoyChange: number | null; + + constructor(id: string, props: MarketIndexProps, createdAt?: Date, updatedAt?: Date) { + super(id, createdAt, updatedAt); + this._district = props.district; + this._city = props.city; + this._propertyType = props.propertyType; + this._period = props.period; + this._medianPrice = props.medianPrice; + this._avgPriceM2 = props.avgPriceM2; + this._totalListings = props.totalListings; + this._daysOnMarket = props.daysOnMarket; + this._inventoryLevel = props.inventoryLevel; + this._absorptionRate = props.absorptionRate; + this._yoyChange = props.yoyChange; + } + + get district(): string { return this._district; } + get city(): string { return this._city; } + get propertyType(): PropertyType { return this._propertyType; } + get period(): string { return this._period; } + get medianPrice(): bigint { return this._medianPrice; } + get avgPriceM2(): number { return this._avgPriceM2; } + get totalListings(): number { return this._totalListings; } + get daysOnMarket(): number { return this._daysOnMarket; } + get inventoryLevel(): number { return this._inventoryLevel; } + get absorptionRate(): number | null { return this._absorptionRate; } + get yoyChange(): number | null { return this._yoyChange; } + + static createNew( + id: string, + district: string, + city: string, + propertyType: PropertyType, + period: string, + medianPrice: bigint, + avgPriceM2: number, + totalListings: number, + daysOnMarket: number, + inventoryLevel: number, + absorptionRate?: number, + yoyChange?: number, + ): MarketIndexEntity { + const entity = new MarketIndexEntity(id, { + district, + city, + propertyType, + period, + medianPrice, + avgPriceM2, + totalListings, + daysOnMarket, + inventoryLevel, + absorptionRate: absorptionRate ?? null, + yoyChange: yoyChange ?? null, + }); + + entity.addDomainEvent(new MarketIndexUpdatedEvent(id, district, city, period)); + return entity; + } + + updateMetrics( + medianPrice: bigint, + avgPriceM2: number, + totalListings: number, + daysOnMarket: number, + inventoryLevel: number, + absorptionRate?: number, + yoyChange?: number, + ): void { + this._medianPrice = medianPrice; + this._avgPriceM2 = avgPriceM2; + this._totalListings = totalListings; + this._daysOnMarket = daysOnMarket; + this._inventoryLevel = inventoryLevel; + this._absorptionRate = absorptionRate ?? this._absorptionRate; + this._yoyChange = yoyChange ?? this._yoyChange; + this.updatedAt = new Date(); + + this.addDomainEvent(new MarketIndexUpdatedEvent(this.id, this._district, this._city, this._period)); + } +} diff --git a/apps/api/src/modules/analytics/domain/entities/valuation.entity.ts b/apps/api/src/modules/analytics/domain/entities/valuation.entity.ts new file mode 100644 index 0000000..5ba8c1b --- /dev/null +++ b/apps/api/src/modules/analytics/domain/entities/valuation.entity.ts @@ -0,0 +1,61 @@ +import { AggregateRoot } from '@modules/shared/domain/aggregate-root'; + +export interface ValuationProps { + propertyId: string; + estimatedPrice: bigint; + confidence: number; + pricePerM2: number; + comparables: unknown; + features: unknown; + modelVersion: string; +} + +export class ValuationEntity extends AggregateRoot { + private _propertyId: string; + private _estimatedPrice: bigint; + private _confidence: number; + private _pricePerM2: number; + private _comparables: unknown; + private _features: unknown; + private _modelVersion: string; + + constructor(id: string, props: ValuationProps, createdAt?: Date, updatedAt?: Date) { + super(id, createdAt, updatedAt); + this._propertyId = props.propertyId; + this._estimatedPrice = props.estimatedPrice; + this._confidence = props.confidence; + this._pricePerM2 = props.pricePerM2; + this._comparables = props.comparables; + this._features = props.features; + this._modelVersion = props.modelVersion; + } + + get propertyId(): string { return this._propertyId; } + get estimatedPrice(): bigint { return this._estimatedPrice; } + get confidence(): number { return this._confidence; } + get pricePerM2(): number { return this._pricePerM2; } + get comparables(): unknown { return this._comparables; } + get features(): unknown { return this._features; } + get modelVersion(): string { return this._modelVersion; } + + static createNew( + id: string, + propertyId: string, + estimatedPrice: bigint, + confidence: number, + pricePerM2: number, + comparables: unknown, + features: unknown, + modelVersion: string, + ): ValuationEntity { + return new ValuationEntity(id, { + propertyId, + estimatedPrice, + confidence, + pricePerM2, + comparables, + features, + modelVersion, + }); + } +} diff --git a/apps/api/src/modules/analytics/domain/events/index.ts b/apps/api/src/modules/analytics/domain/events/index.ts new file mode 100644 index 0000000..4b96c79 --- /dev/null +++ b/apps/api/src/modules/analytics/domain/events/index.ts @@ -0,0 +1 @@ +export { MarketIndexUpdatedEvent } from './market-index-updated.event'; diff --git a/apps/api/src/modules/analytics/domain/events/market-index-updated.event.ts b/apps/api/src/modules/analytics/domain/events/market-index-updated.event.ts new file mode 100644 index 0000000..65b4b00 --- /dev/null +++ b/apps/api/src/modules/analytics/domain/events/market-index-updated.event.ts @@ -0,0 +1,13 @@ +import { type DomainEvent } from '@modules/shared/domain/domain-event'; + +export class MarketIndexUpdatedEvent implements DomainEvent { + readonly eventName = 'market-index.updated'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly district: string, + public readonly city: string, + public readonly period: string, + ) {} +} diff --git a/apps/api/src/modules/analytics/domain/index.ts b/apps/api/src/modules/analytics/domain/index.ts new file mode 100644 index 0000000..7d542c6 --- /dev/null +++ b/apps/api/src/modules/analytics/domain/index.ts @@ -0,0 +1,3 @@ +export * from './entities'; +export * from './events'; +export * from './repositories'; diff --git a/apps/api/src/modules/analytics/domain/repositories/index.ts b/apps/api/src/modules/analytics/domain/repositories/index.ts new file mode 100644 index 0000000..9f6d68b --- /dev/null +++ b/apps/api/src/modules/analytics/domain/repositories/index.ts @@ -0,0 +1,2 @@ +export { MARKET_INDEX_REPOSITORY, type IMarketIndexRepository, type MarketReportResult, type HeatmapDataPoint, type PriceTrendPoint, type DistrictStatsResult } from './market-index.repository'; +export { VALUATION_REPOSITORY, type IValuationRepository } from './valuation.repository'; diff --git a/apps/api/src/modules/analytics/domain/repositories/market-index.repository.ts b/apps/api/src/modules/analytics/domain/repositories/market-index.repository.ts new file mode 100644 index 0000000..e2c219e --- /dev/null +++ b/apps/api/src/modules/analytics/domain/repositories/market-index.repository.ts @@ -0,0 +1,57 @@ +import { type PropertyType } from '@prisma/client'; +import { type MarketIndexEntity } from '../entities/market-index.entity'; + +export const MARKET_INDEX_REPOSITORY = Symbol('MARKET_INDEX_REPOSITORY'); + +export interface MarketReportResult { + district: string; + city: string; + propertyType: PropertyType; + period: string; + medianPrice: string; + avgPriceM2: number; + totalListings: number; + daysOnMarket: number; + inventoryLevel: number; + absorptionRate: number | null; + yoyChange: number | null; +} + +export interface HeatmapDataPoint { + district: string; + city: string; + avgPriceM2: number; + totalListings: number; + medianPrice: string; +} + +export interface PriceTrendPoint { + period: string; + medianPrice: string; + avgPriceM2: number; + totalListings: number; +} + +export interface DistrictStatsResult { + district: string; + city: string; + propertyType: PropertyType; + medianPrice: string; + avgPriceM2: number; + totalListings: number; + daysOnMarket: number; + inventoryLevel: number; + absorptionRate: number | null; + yoyChange: number | null; +} + +export interface IMarketIndexRepository { + findById(id: string): Promise; + findByKey(district: string, city: string, propertyType: PropertyType, period: string): Promise; + save(entity: MarketIndexEntity): Promise; + update(entity: MarketIndexEntity): Promise; + getMarketReport(city: string, period: string, propertyType?: PropertyType): Promise; + getHeatmap(city: string, period: string): Promise; + getPriceTrend(district: string, city: string, propertyType: PropertyType, periods: string[]): Promise; + getDistrictStats(city: string, period: string): Promise; +} diff --git a/apps/api/src/modules/analytics/domain/repositories/valuation.repository.ts b/apps/api/src/modules/analytics/domain/repositories/valuation.repository.ts new file mode 100644 index 0000000..a4b2056 --- /dev/null +++ b/apps/api/src/modules/analytics/domain/repositories/valuation.repository.ts @@ -0,0 +1,10 @@ +import { type ValuationEntity } from '../entities/valuation.entity'; + +export const VALUATION_REPOSITORY = Symbol('VALUATION_REPOSITORY'); + +export interface IValuationRepository { + findById(id: string): Promise; + findByPropertyId(propertyId: string): Promise; + findLatestByPropertyId(propertyId: string): Promise; + save(entity: ValuationEntity): Promise; +} diff --git a/apps/api/src/modules/analytics/index.ts b/apps/api/src/modules/analytics/index.ts new file mode 100644 index 0000000..1994390 --- /dev/null +++ b/apps/api/src/modules/analytics/index.ts @@ -0,0 +1,3 @@ +export { AnalyticsModule } from './analytics.module'; +export { MARKET_INDEX_REPOSITORY, type IMarketIndexRepository } from './domain/repositories/market-index.repository'; +export { VALUATION_REPOSITORY, type IValuationRepository } from './domain/repositories/valuation.repository'; diff --git a/apps/api/src/modules/analytics/infrastructure/index.ts b/apps/api/src/modules/analytics/infrastructure/index.ts new file mode 100644 index 0000000..c51b022 --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/index.ts @@ -0,0 +1 @@ +export * from './repositories'; diff --git a/apps/api/src/modules/analytics/infrastructure/repositories/index.ts b/apps/api/src/modules/analytics/infrastructure/repositories/index.ts new file mode 100644 index 0000000..bb4781a --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/repositories/index.ts @@ -0,0 +1,2 @@ +export { PrismaMarketIndexRepository } from './prisma-market-index.repository'; +export { PrismaValuationRepository } from './prisma-valuation.repository'; diff --git a/apps/api/src/modules/analytics/infrastructure/repositories/prisma-market-index.repository.ts b/apps/api/src/modules/analytics/infrastructure/repositories/prisma-market-index.repository.ts new file mode 100644 index 0000000..c956785 --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/repositories/prisma-market-index.repository.ts @@ -0,0 +1,193 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type MarketIndex as PrismaMarketIndex, type PropertyType } from '@prisma/client'; +import { + type IMarketIndexRepository, + type MarketReportResult, + type HeatmapDataPoint, + type PriceTrendPoint, + type DistrictStatsResult, +} from '../../domain/repositories/market-index.repository'; +import { MarketIndexEntity, type MarketIndexProps } from '../../domain/entities/market-index.entity'; + +@Injectable() +export class PrismaMarketIndexRepository implements IMarketIndexRepository { + constructor(private readonly prisma: PrismaService) {} + + async findById(id: string): Promise { + const record = await this.prisma.marketIndex.findUnique({ where: { id } }); + return record ? this.toDomain(record) : null; + } + + async findByKey( + district: string, + city: string, + propertyType: PropertyType, + period: string, + ): Promise { + const record = await this.prisma.marketIndex.findUnique({ + where: { + district_city_propertyType_period: { district, city, propertyType, period }, + }, + }); + return record ? this.toDomain(record) : null; + } + + async save(entity: MarketIndexEntity): Promise { + await this.prisma.marketIndex.create({ + data: { + id: entity.id, + district: entity.district, + city: entity.city, + propertyType: entity.propertyType, + period: entity.period, + medianPrice: entity.medianPrice, + avgPriceM2: entity.avgPriceM2, + totalListings: entity.totalListings, + daysOnMarket: entity.daysOnMarket, + inventoryLevel: entity.inventoryLevel, + absorptionRate: entity.absorptionRate, + yoyChange: entity.yoyChange, + }, + }); + } + + async update(entity: MarketIndexEntity): Promise { + await this.prisma.marketIndex.update({ + where: { id: entity.id }, + data: { + medianPrice: entity.medianPrice, + avgPriceM2: entity.avgPriceM2, + totalListings: entity.totalListings, + daysOnMarket: entity.daysOnMarket, + inventoryLevel: entity.inventoryLevel, + absorptionRate: entity.absorptionRate, + yoyChange: entity.yoyChange, + }, + }); + } + + async getMarketReport( + city: string, + period: string, + propertyType?: PropertyType, + ): Promise { + const where: Record = { city, period }; + if (propertyType) where.propertyType = propertyType; + + const records = await this.prisma.marketIndex.findMany({ + where, + orderBy: { district: 'asc' }, + }); + + return records.map((r) => ({ + district: r.district, + city: r.city, + propertyType: r.propertyType, + period: r.period, + medianPrice: r.medianPrice.toString(), + avgPriceM2: r.avgPriceM2, + totalListings: r.totalListings, + daysOnMarket: r.daysOnMarket, + inventoryLevel: r.inventoryLevel, + absorptionRate: r.absorptionRate, + yoyChange: r.yoyChange, + })); + } + + async getHeatmap(city: string, period: string): Promise { + const records = await this.prisma.marketIndex.findMany({ + where: { city, period }, + orderBy: { avgPriceM2: 'desc' }, + }); + + const districtMap = new Map(); + + for (const r of records) { + const existing = districtMap.get(r.district); + if (existing) { + existing.totalPrice += r.avgPriceM2; + existing.totalListings += r.totalListings; + existing.count++; + existing.medianPrices.push(r.medianPrice); + } else { + districtMap.set(r.district, { + totalPrice: r.avgPriceM2, + totalListings: r.totalListings, + count: 1, + medianPrices: [r.medianPrice], + }); + } + } + + return Array.from(districtMap.entries()).map(([district, data]) => ({ + district, + city, + avgPriceM2: data.totalPrice / data.count, + totalListings: data.totalListings, + medianPrice: data.medianPrices[Math.floor(data.medianPrices.length / 2)].toString(), + })); + } + + async getPriceTrend( + district: string, + city: string, + propertyType: PropertyType, + periods: string[], + ): Promise { + const records = await this.prisma.marketIndex.findMany({ + where: { + district, + city, + propertyType, + period: { in: periods }, + }, + orderBy: { period: 'asc' }, + }); + + return records.map((r) => ({ + period: r.period, + medianPrice: r.medianPrice.toString(), + avgPriceM2: r.avgPriceM2, + totalListings: r.totalListings, + })); + } + + async getDistrictStats(city: string, period: string): Promise { + const records = await this.prisma.marketIndex.findMany({ + where: { city, period }, + orderBy: [{ district: 'asc' }, { propertyType: 'asc' }], + }); + + return records.map((r) => ({ + district: r.district, + city: r.city, + propertyType: r.propertyType, + medianPrice: r.medianPrice.toString(), + avgPriceM2: r.avgPriceM2, + totalListings: r.totalListings, + daysOnMarket: r.daysOnMarket, + inventoryLevel: r.inventoryLevel, + absorptionRate: r.absorptionRate, + yoyChange: r.yoyChange, + })); + } + + private toDomain(raw: PrismaMarketIndex): MarketIndexEntity { + const props: MarketIndexProps = { + district: raw.district, + city: raw.city, + propertyType: raw.propertyType, + period: raw.period, + medianPrice: raw.medianPrice, + avgPriceM2: raw.avgPriceM2, + totalListings: raw.totalListings, + daysOnMarket: raw.daysOnMarket, + inventoryLevel: raw.inventoryLevel, + absorptionRate: raw.absorptionRate, + yoyChange: raw.yoyChange, + }; + + return new MarketIndexEntity(raw.id, props, raw.createdAt); + } +} diff --git a/apps/api/src/modules/analytics/infrastructure/repositories/prisma-valuation.repository.ts b/apps/api/src/modules/analytics/infrastructure/repositories/prisma-valuation.repository.ts new file mode 100644 index 0000000..b88e54f --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/repositories/prisma-valuation.repository.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type Valuation as PrismaValuation } from '@prisma/client'; +import { type IValuationRepository } from '../../domain/repositories/valuation.repository'; +import { ValuationEntity, type ValuationProps } from '../../domain/entities/valuation.entity'; + +@Injectable() +export class PrismaValuationRepository implements IValuationRepository { + constructor(private readonly prisma: PrismaService) {} + + async findById(id: string): Promise { + const record = await this.prisma.valuation.findUnique({ where: { id } }); + return record ? this.toDomain(record) : null; + } + + async findByPropertyId(propertyId: string): Promise { + const records = await this.prisma.valuation.findMany({ + where: { propertyId }, + orderBy: { createdAt: 'desc' }, + }); + return records.map((r) => this.toDomain(r)); + } + + async findLatestByPropertyId(propertyId: string): Promise { + const record = await this.prisma.valuation.findFirst({ + where: { propertyId }, + orderBy: { createdAt: 'desc' }, + }); + return record ? this.toDomain(record) : null; + } + + async save(entity: ValuationEntity): Promise { + await this.prisma.valuation.create({ + data: { + id: entity.id, + propertyId: entity.propertyId, + estimatedPrice: entity.estimatedPrice, + confidence: entity.confidence, + pricePerM2: entity.pricePerM2, + comparables: entity.comparables as any, + features: entity.features as any, + modelVersion: entity.modelVersion, + }, + }); + } + + private toDomain(raw: PrismaValuation): ValuationEntity { + const props: ValuationProps = { + propertyId: raw.propertyId, + estimatedPrice: raw.estimatedPrice, + confidence: raw.confidence, + pricePerM2: raw.pricePerM2, + comparables: raw.comparables, + features: raw.features, + modelVersion: raw.modelVersion, + }; + + return new ValuationEntity(raw.id, props, raw.createdAt); + } +} diff --git a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts new file mode 100644 index 0000000..b6a1c40 --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts @@ -0,0 +1,53 @@ +import { + Controller, + Get, + Query, +} from '@nestjs/common'; +import { QueryBus } from '@nestjs/cqrs'; +import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query'; +import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query'; +import { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query'; +import { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query'; +import { GetMarketReportDto } from '../dto/get-market-report.dto'; +import { GetHeatmapDto } from '../dto/get-heatmap.dto'; +import { GetPriceTrendDto } from '../dto/get-price-trend.dto'; +import { GetDistrictStatsDto } from '../dto/get-district-stats.dto'; +import { type MarketReportDto } from '../../application/queries/get-market-report/get-market-report.handler'; +import { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler'; +import { type PriceTrendDto } from '../../application/queries/get-price-trend/get-price-trend.handler'; +import { type DistrictStatsDto } from '../../application/queries/get-district-stats/get-district-stats.handler'; + +@Controller('analytics') +export class AnalyticsController { + constructor( + private readonly queryBus: QueryBus, + ) {} + + @Get('market-report') + async getMarketReport(@Query() dto: GetMarketReportDto): Promise { + return this.queryBus.execute( + new GetMarketReportQuery(dto.city, dto.period, dto.propertyType), + ); + } + + @Get('price-trend') + async getPriceTrend(@Query() dto: GetPriceTrendDto): Promise { + return this.queryBus.execute( + new GetPriceTrendQuery(dto.district, dto.city, dto.propertyType, dto.periods), + ); + } + + @Get('heatmap') + async getHeatmap(@Query() dto: GetHeatmapDto): Promise { + return this.queryBus.execute( + new GetHeatmapQuery(dto.city, dto.period), + ); + } + + @Get('district-stats') + async getDistrictStats(@Query() dto: GetDistrictStatsDto): Promise { + return this.queryBus.execute( + new GetDistrictStatsQuery(dto.city, dto.period), + ); + } +} diff --git a/apps/api/src/modules/analytics/presentation/controllers/index.ts b/apps/api/src/modules/analytics/presentation/controllers/index.ts new file mode 100644 index 0000000..397bd69 --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/controllers/index.ts @@ -0,0 +1 @@ +export { AnalyticsController } from './analytics.controller'; diff --git a/apps/api/src/modules/analytics/presentation/dto/get-district-stats.dto.ts b/apps/api/src/modules/analytics/presentation/dto/get-district-stats.dto.ts new file mode 100644 index 0000000..05822f8 --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/get-district-stats.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class GetDistrictStatsDto { + @IsString() + city!: string; + + @IsString() + period!: string; +} diff --git a/apps/api/src/modules/analytics/presentation/dto/get-heatmap.dto.ts b/apps/api/src/modules/analytics/presentation/dto/get-heatmap.dto.ts new file mode 100644 index 0000000..a6c1614 --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/get-heatmap.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class GetHeatmapDto { + @IsString() + city!: string; + + @IsString() + period!: string; +} diff --git a/apps/api/src/modules/analytics/presentation/dto/get-market-report.dto.ts b/apps/api/src/modules/analytics/presentation/dto/get-market-report.dto.ts new file mode 100644 index 0000000..5db00e7 --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/get-market-report.dto.ts @@ -0,0 +1,14 @@ +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { PropertyType } from '@prisma/client'; + +export class GetMarketReportDto { + @IsString() + city!: string; + + @IsString() + period!: string; + + @IsOptional() + @IsEnum(PropertyType) + propertyType?: PropertyType; +} diff --git a/apps/api/src/modules/analytics/presentation/dto/get-price-trend.dto.ts b/apps/api/src/modules/analytics/presentation/dto/get-price-trend.dto.ts new file mode 100644 index 0000000..0a390ea --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/get-price-trend.dto.ts @@ -0,0 +1,19 @@ +import { IsArray, IsEnum, IsString } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { PropertyType } from '@prisma/client'; + +export class GetPriceTrendDto { + @IsString() + district!: string; + + @IsString() + city!: string; + + @IsEnum(PropertyType) + propertyType!: PropertyType; + + @IsArray() + @IsString({ each: true }) + @Transform(({ value }) => (typeof value === 'string' ? value.split(',') : value)) + periods!: string[]; +} diff --git a/apps/api/src/modules/analytics/presentation/dto/index.ts b/apps/api/src/modules/analytics/presentation/dto/index.ts new file mode 100644 index 0000000..2193884 --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/index.ts @@ -0,0 +1,4 @@ +export { GetMarketReportDto } from './get-market-report.dto'; +export { GetHeatmapDto } from './get-heatmap.dto'; +export { GetPriceTrendDto } from './get-price-trend.dto'; +export { GetDistrictStatsDto } from './get-district-stats.dto'; diff --git a/apps/api/src/modules/analytics/presentation/index.ts b/apps/api/src/modules/analytics/presentation/index.ts new file mode 100644 index 0000000..5f229e9 --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/index.ts @@ -0,0 +1,2 @@ +export * from './controllers'; +export * from './dto'; diff --git a/apps/web/app/(dashboard)/analytics/page.tsx b/apps/web/app/(dashboard)/analytics/page.tsx new file mode 100644 index 0000000..6a4dc2d --- /dev/null +++ b/apps/web/app/(dashboard)/analytics/page.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + analyticsApi, + type MarketReportDistrict, + type HeatmapDataPoint, + type DistrictStats, +} from '@/lib/analytics-api'; + +const CITIES = ['Ho Chi Minh', 'Ha Noi', 'Da Nang']; +const CURRENT_PERIOD = '2026-Q1'; + +function formatPrice(priceStr: string): string { + const num = Number(priceStr); + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} ty`; + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`; + return num.toLocaleString('vi-VN'); +} + +function formatPriceM2(price: number): string { + if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m2`; + return `${price.toLocaleString('vi-VN')} d/m2`; +} + +function YoYBadge({ value }: { value: number | null }) { + if (value === null) return N/A; + const isPositive = value >= 0; + return ( + + {isPositive ? '+' : ''}{value.toFixed(1)}% + + ); +} + +export default function AnalyticsPage() { + const [city, setCity] = useState(CITIES[0]); + const [period] = useState(CURRENT_PERIOD); + const [marketReport, setMarketReport] = useState([]); + const [heatmap, setHeatmap] = useState([]); + const [districtStats, setDistrictStats] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + setLoading(true); + setError(null); + + Promise.all([ + analyticsApi.getMarketReport(city, period).catch(() => ({ districts: [] as MarketReportDistrict[] })), + analyticsApi.getHeatmap(city, period).catch(() => ({ dataPoints: [] as HeatmapDataPoint[] })), + analyticsApi.getDistrictStats(city, period).catch(() => ({ districts: [] as DistrictStats[] })), + ]) + .then(([report, heatmapData, stats]) => { + setMarketReport(report.districts); + setHeatmap(heatmapData.dataPoints); + setDistrictStats(stats.districts); + }) + .catch(() => setError('Khong the tai du lieu phan tich')) + .finally(() => setLoading(false)); + }, [city, period]); + + const totalListings = marketReport.reduce((sum, d) => sum + d.totalListings, 0); + const avgDaysOnMarket = marketReport.length > 0 + ? marketReport.reduce((sum, d) => sum + d.daysOnMarket, 0) / marketReport.length + : 0; + const avgPriceM2 = marketReport.length > 0 + ? marketReport.reduce((sum, d) => sum + d.avgPriceM2, 0) / marketReport.length + : 0; + + return ( +
+
+
+

Phan tich thi truong

+

+ Bao cao thi truong bat dong san - {period} +

+
+
+ {CITIES.map((c) => ( + + ))} +
+
+ + {error && ( +
{error}
+ )} + + {/* Summary Cards */} +
+ + + Tong tin dang + {loading ? '...' : totalListings.toLocaleString('vi-VN')} + + + + + Gia TB/m2 + {loading ? '...' : formatPriceM2(avgPriceM2)} + + + + + Ngay trung binh de ban + {loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngay`} + + + + + So quan/huyen + {loading ? '...' : new Set(marketReport.map(d => d.district)).size} + + +
+ + {/* Heatmap - Price by District */} + + + Ban do gia theo quan + So sanh gia trung binh/m2 giua cac quan tai {city} + + + {loading ? ( +
Dang tai...
+ ) : heatmap.length === 0 ? ( +
Chua co du lieu
+ ) : ( +
+ {heatmap + .sort((a, b) => b.avgPriceM2 - a.avgPriceM2) + .map((point) => { + const maxPrice = heatmap[0] ? Math.max(...heatmap.map(h => h.avgPriceM2)) : 1; + const intensity = Math.round((point.avgPriceM2 / maxPrice) * 100); + return ( +
+
{point.district}
+
{formatPriceM2(point.avgPriceM2)}
+
{point.totalListings} tin dang
+
+ ); + })} +
+ )} +
+
+ + {/* District Stats Table */} + + + Thong ke chi tiet theo quan + Du lieu thi truong bat dong san tai {city} - {period} + + + {loading ? ( +
Dang tai...
+ ) : districtStats.length === 0 ? ( +
Chua co du lieu
+ ) : ( +
+ + + + + + + + + + + + + + {districtStats.map((stat, i) => ( + + + + + + + + + + ))} + +
QuanLoai BDSGia trung viGia/m2Tin dangNgay banYoY
{stat.district}{stat.propertyType}{formatPrice(stat.medianPrice)}{formatPriceM2(stat.avgPriceM2)}{stat.totalListings}{stat.daysOnMarket.toFixed(0)}
+
+ )} +
+
+ + {/* Market Report Summary */} + + + Bao cao thi truong + Tong hop chi so thi truong theo tung quan + + + {loading ? ( +
Dang tai...
+ ) : marketReport.length === 0 ? ( +
Chua co du lieu
+ ) : ( +
+ {[...new Map(marketReport.map(d => [d.district, d])).values()].map((district) => ( +
+

{district.district}

+
+
+ Gia trung vi + {formatPrice(district.medianPrice)} +
+
+ Gia/m2 + {formatPriceM2(district.avgPriceM2)} +
+
+ Tin dang + {district.totalListings} +
+
+ Ton kho + {district.inventoryLevel} +
+
+ Thay doi YoY + +
+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx index c1fe4d9..0d7c500 100644 --- a/apps/web/app/(dashboard)/layout.tsx +++ b/apps/web/app/(dashboard)/layout.tsx @@ -10,6 +10,7 @@ const navItems = [ { href: '/dashboard', label: 'Bảng điều khiển', icon: '🏠' }, { href: '/listings', label: 'Tin đăng', icon: '📋' }, { href: '/listings/new', label: 'Đăng tin', icon: '➕' }, + { href: '/analytics', label: 'Phân tích', icon: '📊' }, ]; export default function DashboardLayout({ children }: { children: React.ReactNode }) { diff --git a/apps/web/lib/analytics-api.ts b/apps/web/lib/analytics-api.ts new file mode 100644 index 0000000..2eff3e7 --- /dev/null +++ b/apps/web/lib/analytics-api.ts @@ -0,0 +1,91 @@ +import { apiClient } from './api-client'; + +export interface MarketReportDistrict { + district: string; + city: string; + propertyType: string; + period: string; + medianPrice: string; + avgPriceM2: number; + totalListings: number; + daysOnMarket: number; + inventoryLevel: number; + absorptionRate: number | null; + yoyChange: number | null; +} + +export interface MarketReportResponse { + city: string; + period: string; + districts: MarketReportDistrict[]; +} + +export interface HeatmapDataPoint { + district: string; + city: string; + avgPriceM2: number; + totalListings: number; + medianPrice: string; +} + +export interface HeatmapResponse { + city: string; + period: string; + dataPoints: HeatmapDataPoint[]; +} + +export interface PriceTrendPoint { + period: string; + medianPrice: string; + avgPriceM2: number; + totalListings: number; +} + +export interface PriceTrendResponse { + district: string; + city: string; + propertyType: string; + trend: PriceTrendPoint[]; +} + +export interface DistrictStats { + district: string; + city: string; + propertyType: string; + medianPrice: string; + avgPriceM2: number; + totalListings: number; + daysOnMarket: number; + inventoryLevel: number; + absorptionRate: number | null; + yoyChange: number | null; +} + +export interface DistrictStatsResponse { + city: string; + period: string; + districts: DistrictStats[]; +} + +export const analyticsApi = { + getMarketReport: (city: string, period: string, propertyType?: string) => { + const params = new URLSearchParams({ city, period }); + if (propertyType) params.set('propertyType', propertyType); + return apiClient.get(`/analytics/market-report?${params}`); + }, + + getHeatmap: (city: string, period: string) => { + const params = new URLSearchParams({ city, period }); + return apiClient.get(`/analytics/heatmap?${params}`); + }, + + getPriceTrend: (district: string, city: string, propertyType: string, periods: string[]) => { + const params = new URLSearchParams({ district, city, propertyType, periods: periods.join(',') }); + return apiClient.get(`/analytics/price-trend?${params}`); + }, + + getDistrictStats: (city: string, period: string) => { + const params = new URLSearchParams({ city, period }); + return apiClient.get(`/analytics/district-stats?${params}`); + }, +};