From f3a2a012c401ca01b0145d79b9977d981df17559 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 16 Apr 2026 17:40:30 +0700 Subject: [PATCH] feat(web): add price range filter and list view to /du-an page Add minPrice/maxPrice inputs to ProjectFilterBar and introduce a list view mode alongside the existing grid/map toggle for project browsing. Co-Authored-By: Paperclip --- .../src/modules/analytics/analytics.module.ts | 3 +- .../__tests__/avm.controller.spec.ts | 95 +++++++++++++++ .../controllers/avm.controller.ts | 86 ++++++++++++++ .../presentation/controllers/index.ts | 1 + .../presentation/dto/avm-compare-query.dto.ts | 19 +++ .../analytics/presentation/dto/index.ts | 1 + apps/web/app/[locale]/(public)/du-an/page.tsx | 112 +++++++++++++++++- .../components/du-an/project-filter-bar.tsx | 30 ++++- 8 files changed, 340 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/modules/analytics/presentation/__tests__/avm.controller.spec.ts create mode 100644 apps/api/src/modules/analytics/presentation/controllers/avm.controller.ts create mode 100644 apps/api/src/modules/analytics/presentation/dto/avm-compare-query.dto.ts diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index 12906e4..ec1b47b 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -25,6 +25,7 @@ import { MarketIndexCronService } from './infrastructure/services/market-index-c import { NeighborhoodScoreServiceImpl } from './infrastructure/services/neighborhood-score.service'; import { PrismaAVMService } from './infrastructure/services/prisma-avm.service'; import { AnalyticsController } from './presentation/controllers/analytics.controller'; +import { AvmController } from './presentation/controllers/avm.controller'; const CommandHandlers = [ TrackEventHandler, @@ -50,7 +51,7 @@ const EventHandlers = [ @Module({ imports: [CqrsModule], - controllers: [AnalyticsController], + controllers: [AnalyticsController, AvmController], providers: [ // AI service client { provide: AI_SERVICE_CLIENT, useClass: AiServiceClient }, diff --git a/apps/api/src/modules/analytics/presentation/__tests__/avm.controller.spec.ts b/apps/api/src/modules/analytics/presentation/__tests__/avm.controller.spec.ts new file mode 100644 index 0000000..a3aad2d --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/__tests__/avm.controller.spec.ts @@ -0,0 +1,95 @@ +import { type QueryBus } from '@nestjs/cqrs'; +import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query'; +import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query'; +import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query'; +import { AvmController } from '../controllers/avm.controller'; + +describe('AvmController', () => { + let controller: AvmController; + let mockQueryBus: { execute: ReturnType }; + + beforeEach(() => { + mockQueryBus = { execute: vi.fn() }; + controller = new AvmController(mockQueryBus as unknown as QueryBus); + }); + + describe('POST /avm/batch', () => { + it('dispatches BatchValuationQuery with property IDs', async () => { + const expected = { + results: [ + { propertyId: 'prop-1', valuation: { estimatedPrice: '5000000000' } }, + { propertyId: 'prop-2', valuation: { estimatedPrice: '6000000000' } }, + ], + }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.batchValuation({ + propertyIds: ['prop-1', 'prop-2'], + } as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + new BatchValuationQuery(['prop-1', 'prop-2']), + ); + expect(result).toBe(expected); + }); + }); + + describe('GET /avm/history/:propertyId', () => { + it('dispatches ValuationHistoryQuery with propertyId and limit', async () => { + const expected = { propertyId: 'prop-1', history: [], totalRecords: 0 }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getHistory('prop-1', { limit: 25 } as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + new ValuationHistoryQuery('prop-1', 25), + ); + expect(result).toBe(expected); + }); + + it('defaults limit to 50 when not provided', async () => { + const expected = { propertyId: 'prop-1', history: [], totalRecords: 0 }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getHistory('prop-1', {} as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + new ValuationHistoryQuery('prop-1', 50), + ); + expect(result).toBe(expected); + }); + }); + + describe('GET /avm/compare', () => { + it('dispatches ValuationComparisonQuery with parsed IDs', async () => { + const expected = { + properties: [], + summary: { highestValue: null, lowestValue: null, averagePricePerM2: 0, averageConfidence: 0 }, + }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.compare({ + ids: ['prop-1', 'prop-2', 'prop-3'], + } as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + new ValuationComparisonQuery(['prop-1', 'prop-2', 'prop-3']), + ); + expect(result).toBe(expected); + }); + + it('handles two property IDs (minimum)', async () => { + const expected = { properties: [], summary: {} }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.compare({ + ids: ['prop-1', 'prop-2'], + } as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + new ValuationComparisonQuery(['prop-1', 'prop-2']), + ); + expect(result).toBe(expected); + }); + }); +}); diff --git a/apps/api/src/modules/analytics/presentation/controllers/avm.controller.ts b/apps/api/src/modules/analytics/presentation/controllers/avm.controller.ts new file mode 100644 index 0000000..69c0fff --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/controllers/avm.controller.ts @@ -0,0 +1,86 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { type QueryBus } from '@nestjs/cqrs'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { JwtAuthGuard } from '@modules/auth'; +import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared'; +import { RequireQuota, QuotaGuard } from '@modules/subscriptions'; +import { type BatchValuationDto as BatchValuationResultDto } from '../../application/queries/batch-valuation/batch-valuation.handler'; +import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query'; +import { type ValuationComparisonDto as ValuationComparisonResultDto } from '../../application/queries/valuation-comparison/valuation-comparison.handler'; +import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query'; +import { type ValuationHistoryDto as ValuationHistoryResultDto } from '../../application/queries/valuation-history/valuation-history.handler'; +import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query'; +import { type AvmCompareQueryDto } from '../dto/avm-compare-query.dto'; +import { type BatchValuationDto } from '../dto/batch-valuation.dto'; +import { type ValuationHistoryDto } from '../dto/valuation-history.dto'; + +@ApiTags('avm') +@Controller('avm') +export class AvmController { + constructor( + private readonly queryBus: QueryBus, + ) {} + + @ApiBearerAuth('JWT') + @EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' }) + @UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard) + @RequireQuota('analytics_queries') + @Post('batch') + @ApiOperation({ summary: 'Batch valuation for multiple properties (max 50)' }) + @ApiResponse({ status: 200, description: 'Batch valuation results' }) + @ApiResponse({ status: 400, description: 'Invalid parameters' }) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) + @ApiResponse({ status: 429, description: 'Rate limit exceeded — max 10 requests per 60s' }) + async batchValuation(@Body() dto: BatchValuationDto): Promise { + return this.queryBus.execute( + new BatchValuationQuery(dto.propertyIds), + ); + } + + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, QuotaGuard) + @RequireQuota('analytics_queries') + @Get('history/:propertyId') + @ApiOperation({ summary: 'Get valuation history for a property (time-series)' }) + @ApiParam({ name: 'propertyId', description: 'Property ID', example: 'prop-123' }) + @ApiResponse({ status: 200, description: 'Valuation history time-series data' }) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) + async getHistory( + @Param('propertyId') propertyId: string, + @Query() dto: ValuationHistoryDto, + ): Promise { + 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') + @Get('compare') + @ApiOperation({ summary: 'Compare valuations for 2-5 properties side by side' }) + @ApiQuery({ + name: 'ids', + description: 'Comma-separated property IDs (2-5)', + example: 'prop-1,prop-2,prop-3', + type: String, + }) + @ApiResponse({ status: 200, description: 'Normalized comparison data for UI' }) + @ApiResponse({ status: 400, description: 'Invalid parameters — provide 2-5 property IDs' }) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) + @ApiResponse({ status: 429, description: 'Rate limit exceeded — max 10 requests per 60s' }) + async compare(@Query() dto: AvmCompareQueryDto): Promise { + return this.queryBus.execute( + new ValuationComparisonQuery(dto.ids), + ); + } +} diff --git a/apps/api/src/modules/analytics/presentation/controllers/index.ts b/apps/api/src/modules/analytics/presentation/controllers/index.ts index 397bd69..a7bf444 100644 --- a/apps/api/src/modules/analytics/presentation/controllers/index.ts +++ b/apps/api/src/modules/analytics/presentation/controllers/index.ts @@ -1 +1,2 @@ export { AnalyticsController } from './analytics.controller'; +export { AvmController } from './avm.controller'; diff --git a/apps/api/src/modules/analytics/presentation/dto/avm-compare-query.dto.ts b/apps/api/src/modules/analytics/presentation/dto/avm-compare-query.dto.ts new file mode 100644 index 0000000..1d41817 --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/avm-compare-query.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator'; + +export class AvmCompareQueryDto { + @ApiProperty({ + description: 'Comma-separated property IDs to compare (2-5)', + example: 'prop-1,prop-2,prop-3', + type: String, + }) + @Transform(({ value }) => + typeof value === 'string' ? value.split(',').map((s: string) => s.trim()).filter(Boolean) : value, + ) + @IsArray() + @ArrayMinSize(2) + @ArrayMaxSize(5) + @IsString({ each: true }) + ids!: string[]; +} diff --git a/apps/api/src/modules/analytics/presentation/dto/index.ts b/apps/api/src/modules/analytics/presentation/dto/index.ts index e695c10..7141786 100644 --- a/apps/api/src/modules/analytics/presentation/dto/index.ts +++ b/apps/api/src/modules/analytics/presentation/dto/index.ts @@ -6,3 +6,4 @@ export { GetValuationDto } from './get-valuation.dto'; export { BatchValuationDto } from './batch-valuation.dto'; export { ValuationHistoryDto } from './valuation-history.dto'; export { ValuationComparisonDto } from './valuation-comparison.dto'; +export { AvmCompareQueryDto } from './avm-compare-query.dto'; diff --git a/apps/web/app/[locale]/(public)/du-an/page.tsx b/apps/web/app/[locale]/(public)/du-an/page.tsx index 7d354ce..36d3773 100644 --- a/apps/web/app/[locale]/(public)/du-an/page.tsx +++ b/apps/web/app/[locale]/(public)/du-an/page.tsx @@ -1,12 +1,23 @@ 'use client'; -import { Building2, LayoutGrid, Map } from 'lucide-react'; +import { Building2, LayoutGrid, List, Map, MapPin } from 'lucide-react'; import dynamic from 'next/dynamic'; +import Image from 'next/image'; import * as React from 'react'; import { ProjectCard } from '@/components/du-an/project-card'; import { ProjectFilterBar } from '@/components/du-an/project-filter-bar'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import type { SearchProjectsParams } from '@/lib/du-an-api'; +import { Card } from '@/components/ui/card'; +import { Link } from '@/i18n/navigation'; +import { formatPrice } from '@/lib/currency'; +import { + PROJECT_PROPERTY_TYPE_LABELS, + PROJECT_STATUS_COLORS, + PROJECT_STATUS_LABELS, + type ProjectSummary, + type SearchProjectsParams, +} from '@/lib/du-an-api'; import { useProjectsSearch } from '@/lib/hooks/use-du-an'; import { cn } from '@/lib/utils'; @@ -17,7 +28,7 @@ const ProjectMap = dynamic( const PAGE_SIZE = 12; -type ViewMode = 'grid' | 'map'; +type ViewMode = 'grid' | 'list' | 'map'; export default function DuAnPage() { const [filters, setFilters] = React.useState({ @@ -62,6 +73,19 @@ export default function DuAnPage() { > +