From ba0bf97426f35bbef3b8a5ec1d7189f3dd9a1c94 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sun, 19 Apr 2026 10:37:33 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20dashboard=20CRUD=20for=20Projects=20+?= =?UTF-8?q?=20Industrial=20Parks,=20listings=20delete,=20B=C4=90S=20homepa?= =?UTF-8?q?ge=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend — DELETE endpoints (hard delete, ADMIN or owner): - DELETE /projects/:id (Admin) — new DeleteProjectCommand/Handler, repository.delete() adapter, module wiring. - DELETE /industrial/parks/:id (Admin) — same pattern. - DELETE /listings/:id (JWT + owner-or-Admin check in handler). Frontend — API clients: - lib/du-an-api.ts: add create/update/delete + CreateProjectPayload, UpdateProjectPayload types. - lib/khu-cong-nghiep-api.ts: add createPark/updatePark/deletePark + Create/Update payload types. - lib/listings-api.ts: add delete(). Dashboard pages — new: - /projects (Quản lý dự án): list with filters + edit/delete actions, /projects/new form (sectioned Cards, zod-validated), /projects/[id]/edit with danger-zone delete. - /industrial-parks (Quản lý KCN): same triad. Fix occupancy-rate display (percentage already 0-100, no need to *100). Dashboard listings page: - Add Edit/Delete row actions with confirm + useMutation; error banner on mutation failure. Table view gains a "Thao tác" column; list view gains a footer action bar below each card. Dashboard nav: - Catalog group: /du-an → /projects (Quản lý dự án), /khu-cong-nghiep → /industrial-parks (Quản lý KCN). Desktop primaryNav updated too. Public homepage: - Add "Bất động sản" as a 5th feature card/tab → /search, using listingsApi for the "Featured listings" section. - Bump grid to lg:grid-cols-5, update features subtitle copy ("Năm/Five core services"). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../delete-industrial-park.command.ts | 3 + .../delete-industrial-park.handler.ts | 25 + .../industrial-park.repository.ts | 1 + .../modules/industrial/industrial.module.ts | 2 + .../prisma-industrial-park.repository.ts | 4 + .../industrial-parks.controller.ts | 17 +- .../delete-listing/delete-listing.command.ts | 7 + .../delete-listing/delete-listing.handler.ts | 29 + .../domain/repositories/listing.repository.ts | 1 + .../repositories/prisma-listing.repository.ts | 4 + .../src/modules/listings/listings.module.ts | 2 + .../controllers/listings.controller.ts | 19 + .../delete-project/delete-project.command.ts | 3 + .../delete-project/delete-project.handler.ts | 25 + .../project-development.repository.ts | 1 + .../prisma-project-development.repository.ts | 4 + .../controllers/projects.controller.ts | 17 +- .../src/modules/projects/projects.module.ts | 3 +- .../industrial-parks/[id]/edit/page.tsx | 551 ++++++++++++++++++ .../(dashboard)/industrial-parks/new/page.tsx | 461 +++++++++++++++ .../(dashboard)/industrial-parks/page.tsx | 279 +++++++++ apps/web/app/[locale]/(dashboard)/layout.tsx | 8 +- .../[locale]/(dashboard)/listings/page.tsx | 83 ++- .../(dashboard)/projects/[id]/edit/page.tsx | 426 ++++++++++++++ .../(dashboard)/projects/new/page.tsx | 436 ++++++++++++++ .../[locale]/(dashboard)/projects/page.tsx | 273 +++++++++ apps/web/app/[locale]/(public)/page.tsx | 38 +- apps/web/lib/du-an-api.ts | 62 ++ apps/web/lib/khu-cong-nghiep-api.ts | 62 ++ apps/web/lib/listings-api.ts | 3 + apps/web/messages/en.json | 8 +- apps/web/messages/vi.json | 8 +- 32 files changed, 2843 insertions(+), 22 deletions(-) create mode 100644 apps/api/src/modules/industrial/application/commands/delete-industrial-park/delete-industrial-park.command.ts create mode 100644 apps/api/src/modules/industrial/application/commands/delete-industrial-park/delete-industrial-park.handler.ts create mode 100644 apps/api/src/modules/listings/application/commands/delete-listing/delete-listing.command.ts create mode 100644 apps/api/src/modules/listings/application/commands/delete-listing/delete-listing.handler.ts create mode 100644 apps/api/src/modules/projects/application/commands/delete-project/delete-project.command.ts create mode 100644 apps/api/src/modules/projects/application/commands/delete-project/delete-project.handler.ts create mode 100644 apps/web/app/[locale]/(dashboard)/industrial-parks/[id]/edit/page.tsx create mode 100644 apps/web/app/[locale]/(dashboard)/industrial-parks/new/page.tsx create mode 100644 apps/web/app/[locale]/(dashboard)/industrial-parks/page.tsx create mode 100644 apps/web/app/[locale]/(dashboard)/projects/[id]/edit/page.tsx create mode 100644 apps/web/app/[locale]/(dashboard)/projects/new/page.tsx create mode 100644 apps/web/app/[locale]/(dashboard)/projects/page.tsx diff --git a/apps/api/src/modules/industrial/application/commands/delete-industrial-park/delete-industrial-park.command.ts b/apps/api/src/modules/industrial/application/commands/delete-industrial-park/delete-industrial-park.command.ts new file mode 100644 index 0000000..d51a3a8 --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/delete-industrial-park/delete-industrial-park.command.ts @@ -0,0 +1,3 @@ +export class DeleteIndustrialParkCommand { + constructor(public readonly id: string) {} +} diff --git a/apps/api/src/modules/industrial/application/commands/delete-industrial-park/delete-industrial-park.handler.ts b/apps/api/src/modules/industrial/application/commands/delete-industrial-park/delete-industrial-park.handler.ts new file mode 100644 index 0000000..c5f2fbf --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/delete-industrial-park/delete-industrial-park.handler.ts @@ -0,0 +1,25 @@ +import { Inject } from '@nestjs/common'; +import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs'; +import { NotFoundException } from '@modules/shared'; +import { + INDUSTRIAL_PARK_REPOSITORY, + type IIndustrialParkRepository, +} from '../../../domain/repositories/industrial-park.repository'; +import { DeleteIndustrialParkCommand } from './delete-industrial-park.command'; + +@CommandHandler(DeleteIndustrialParkCommand) +export class DeleteIndustrialParkHandler implements ICommandHandler { + constructor( + @Inject(INDUSTRIAL_PARK_REPOSITORY) + private readonly repo: IIndustrialParkRepository, + ) {} + + async execute(cmd: DeleteIndustrialParkCommand): Promise { + const entity = await this.repo.findById(cmd.id); + if (!entity) { + throw new NotFoundException('Khu công nghiệp', cmd.id); + } + + await this.repo.delete(cmd.id); + } +} diff --git a/apps/api/src/modules/industrial/domain/repositories/industrial-park.repository.ts b/apps/api/src/modules/industrial/domain/repositories/industrial-park.repository.ts index e45fc09..6c71ea3 100644 --- a/apps/api/src/modules/industrial/domain/repositories/industrial-park.repository.ts +++ b/apps/api/src/modules/industrial/domain/repositories/industrial-park.repository.ts @@ -110,6 +110,7 @@ export interface IIndustrialParkRepository { findDetailById(id: string): Promise; save(entity: IndustrialParkEntity): Promise; update(entity: IndustrialParkEntity): Promise; + delete(id: string): Promise; search(params: IndustrialParkSearchParams): Promise>; compareParks(ids: string[]): Promise; getStats(): Promise; diff --git a/apps/api/src/modules/industrial/industrial.module.ts b/apps/api/src/modules/industrial/industrial.module.ts index d084730..b1e739b 100644 --- a/apps/api/src/modules/industrial/industrial.module.ts +++ b/apps/api/src/modules/industrial/industrial.module.ts @@ -4,6 +4,7 @@ import { SearchModule } from '@modules/search'; import { CreateIndustrialListingHandler } from './application/commands/create-industrial-listing/create-industrial-listing.handler'; import { CreateIndustrialParkHandler } from './application/commands/create-industrial-park/create-industrial-park.handler'; import { DeleteIndustrialListingHandler } from './application/commands/delete-industrial-listing/delete-industrial-listing.handler'; +import { DeleteIndustrialParkHandler } from './application/commands/delete-industrial-park/delete-industrial-park.handler'; import { UpdateIndustrialListingHandler } from './application/commands/update-industrial-listing/update-industrial-listing.handler'; import { UpdateIndustrialParkHandler } from './application/commands/update-industrial-park/update-industrial-park.handler'; import { AnalyzeIndustrialLocationHandler } from './application/queries/analyze-industrial-location/analyze-industrial-location.handler'; @@ -26,6 +27,7 @@ import { IndustrialParksController } from './presentation/controllers/industrial const CommandHandlers = [ CreateIndustrialParkHandler, UpdateIndustrialParkHandler, + DeleteIndustrialParkHandler, CreateIndustrialListingHandler, UpdateIndustrialListingHandler, DeleteIndustrialListingHandler, diff --git a/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts b/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts index 8fcb8fa..2adbc60 100644 --- a/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts +++ b/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts @@ -95,6 +95,10 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository `; } + async delete(id: string): Promise { + await this.prisma.industrialPark.delete({ where: { id } }); + } + async update(entity: IndustrialParkEntity): Promise { await this.prisma.$executeRaw` UPDATE "IndustrialPark" SET diff --git a/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts b/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts index 01e8907..8a97d11 100644 --- a/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts +++ b/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { UserRole } from '@prisma/client'; @@ -6,6 +6,7 @@ import { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth'; import { NotFoundException } from '@modules/shared'; import { AnalyzeIndustrialLocationQuery } from '../../application/queries/analyze-industrial-location/analyze-industrial-location.query'; import { CreateIndustrialParkCommand } from '../../application/commands/create-industrial-park/create-industrial-park.command'; +import { DeleteIndustrialParkCommand } from '../../application/commands/delete-industrial-park/delete-industrial-park.command'; import { EstimateIndustrialRentQuery } from '../../application/queries/estimate-industrial-rent/estimate-industrial-rent.query'; import { UpdateIndustrialParkCommand } from '../../application/commands/update-industrial-park/update-industrial-park.command'; import { CompareIndustrialParksQuery } from '../../application/queries/compare-industrial-parks/compare-industrial-parks.query'; @@ -195,4 +196,18 @@ export class IndustrialParksController { ), ); } + + @ApiOperation({ summary: 'Xóa KCN (admin)', description: 'Xóa vĩnh viễn khu công nghiệp' }) + @ApiResponse({ status: 200, description: 'KCN đã xóa' }) + @ApiResponse({ status: 401, description: 'Chưa xác thực' }) + @ApiResponse({ status: 403, description: 'Không có quyền' }) + @ApiResponse({ status: 404, description: 'Không tìm thấy tài nguyên' }) + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Delete('parks/:id') + async deletePark(@Param('id') id: string): Promise<{ success: true }> { + await this.commandBus.execute(new DeleteIndustrialParkCommand(id)); + return { success: true }; + } } diff --git a/apps/api/src/modules/listings/application/commands/delete-listing/delete-listing.command.ts b/apps/api/src/modules/listings/application/commands/delete-listing/delete-listing.command.ts new file mode 100644 index 0000000..48cec47 --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/delete-listing/delete-listing.command.ts @@ -0,0 +1,7 @@ +export class DeleteListingCommand { + constructor( + public readonly listingId: string, + public readonly userId: string, + public readonly userRole?: string, + ) {} +} diff --git a/apps/api/src/modules/listings/application/commands/delete-listing/delete-listing.handler.ts b/apps/api/src/modules/listings/application/commands/delete-listing/delete-listing.handler.ts new file mode 100644 index 0000000..5956fe5 --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/delete-listing/delete-listing.handler.ts @@ -0,0 +1,29 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { ForbiddenException, NotFoundException } from '@modules/shared'; +import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository'; +import { DeleteListingCommand } from './delete-listing.command'; + +@CommandHandler(DeleteListingCommand) +export class DeleteListingHandler implements ICommandHandler { + constructor( + @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, + ) {} + + async execute(command: DeleteListingCommand): Promise { + const listing = await this.listingRepo.findById(command.listingId); + if (!listing) { + throw new NotFoundException('Listing', command.listingId); + } + + const isOwner = listing.sellerId === command.userId; + const isAdmin = command.userRole === 'ADMIN'; + if (!isOwner && !isAdmin) { + throw new ForbiddenException( + 'Chỉ người bán hoặc quản trị viên mới có thể xóa tin đăng', + ); + } + + await this.listingRepo.delete(command.listingId); + } +} diff --git a/apps/api/src/modules/listings/domain/repositories/listing.repository.ts b/apps/api/src/modules/listings/domain/repositories/listing.repository.ts index 8390dd8..022a043 100644 --- a/apps/api/src/modules/listings/domain/repositories/listing.repository.ts +++ b/apps/api/src/modules/listings/domain/repositories/listing.repository.ts @@ -32,6 +32,7 @@ export interface IListingRepository { findByIdWithProperty(id: string): Promise; save(listing: ListingEntity): Promise; update(listing: ListingEntity): Promise; + delete(id: string): Promise; search(params: ListingSearchParams): Promise>; findByStatus(status: ListingStatus, page: number, limit: number): Promise>; findBySellerId(sellerId: string, page: number, limit: number): Promise>; diff --git a/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts b/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts index ce02350..fc59786 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts @@ -47,6 +47,10 @@ export class PrismaListingRepository implements IListingRepository { }); } + async delete(id: string): Promise { + await this.prisma.listing.delete({ where: { id } }); + } + async update(entity: ListingEntity): Promise { await this.prisma.listing.update({ where: { id: entity.id }, diff --git a/apps/api/src/modules/listings/listings.module.ts b/apps/api/src/modules/listings/listings.module.ts index 1b10f8f..333735c 100644 --- a/apps/api/src/modules/listings/listings.module.ts +++ b/apps/api/src/modules/listings/listings.module.ts @@ -3,6 +3,7 @@ import { CqrsModule } from '@nestjs/cqrs'; import { MulterModule } from '@nestjs/platform-express'; import { AdminFeatureListingHandler } from './application/commands/admin-feature-listing/admin-feature-listing.handler'; import { CreateListingHandler } from './application/commands/create-listing/create-listing.handler'; +import { DeleteListingHandler } from './application/commands/delete-listing/delete-listing.handler'; import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler'; import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-listing.handler'; import { PromoteFeaturedListingHandler } from './application/commands/promote-featured-listing/promote-featured-listing.handler'; @@ -36,6 +37,7 @@ const CommandHandlers = [ UpdateListingStatusHandler, UploadMediaHandler, ModerateListingHandler, + DeleteListingHandler, ]; const QueryHandlers = [ diff --git a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts index 8a6f8ce..928595a 100644 --- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, Ip, Param, @@ -31,6 +32,7 @@ import { NotFoundException, EndpointRateLimit, EndpointRateLimitGuard, FileValid import { RequireQuota, QuotaGuard } from '@modules/subscriptions'; import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command'; import type { CreateListingResult } from '../../application/commands/create-listing/create-listing.handler'; +import { DeleteListingCommand } from '../../application/commands/delete-listing/delete-listing.command'; import { FeatureListingCommand } from '../../application/commands/feature-listing/feature-listing.command'; import type { FeatureListingResult } from '../../application/commands/feature-listing/feature-listing.handler'; import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command'; @@ -352,6 +354,23 @@ export class ListingsController { ); } + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'Delete a listing (owner or admin)' }) + @ApiParam({ name: 'id', description: 'Listing UUID' }) + @ApiResponse({ status: 200, description: 'Listing deleted successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden — not the seller or admin' }) + @ApiResponse({ status: 404, description: 'Listing not found' }) + @UseGuards(JwtAuthGuard) + @Delete(':id') + async deleteListing( + @Param('id') id: string, + @CurrentUser() user: JwtPayload, + ): Promise<{ success: true }> { + await this.commandBus.execute(new DeleteListingCommand(id, user.sub, user.role)); + return { success: true }; + } + @ApiBearerAuth('JWT') @ApiOperation({ summary: 'Promote a listing via subscription entitlement (no payment)', diff --git a/apps/api/src/modules/projects/application/commands/delete-project/delete-project.command.ts b/apps/api/src/modules/projects/application/commands/delete-project/delete-project.command.ts new file mode 100644 index 0000000..e3bb035 --- /dev/null +++ b/apps/api/src/modules/projects/application/commands/delete-project/delete-project.command.ts @@ -0,0 +1,3 @@ +export class DeleteProjectCommand { + constructor(public readonly id: string) {} +} diff --git a/apps/api/src/modules/projects/application/commands/delete-project/delete-project.handler.ts b/apps/api/src/modules/projects/application/commands/delete-project/delete-project.handler.ts new file mode 100644 index 0000000..832d3c4 --- /dev/null +++ b/apps/api/src/modules/projects/application/commands/delete-project/delete-project.handler.ts @@ -0,0 +1,25 @@ +import { Inject } from '@nestjs/common'; +import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs'; +import { NotFoundException } from '@modules/shared'; +import { + PROJECT_REPOSITORY, + type IProjectRepository, +} from '../../../domain/repositories/project-development.repository'; +import { DeleteProjectCommand } from './delete-project.command'; + +@CommandHandler(DeleteProjectCommand) +export class DeleteProjectHandler implements ICommandHandler { + constructor( + @Inject(PROJECT_REPOSITORY) + private readonly repo: IProjectRepository, + ) {} + + async execute(cmd: DeleteProjectCommand): Promise { + const entity = await this.repo.findById(cmd.id); + if (!entity) { + throw new NotFoundException('Dự án', cmd.id); + } + + await this.repo.delete(cmd.id); + } +} diff --git a/apps/api/src/modules/projects/domain/repositories/project-development.repository.ts b/apps/api/src/modules/projects/domain/repositories/project-development.repository.ts index e9dc2c1..cbbe168 100644 --- a/apps/api/src/modules/projects/domain/repositories/project-development.repository.ts +++ b/apps/api/src/modules/projects/domain/repositories/project-development.repository.ts @@ -68,5 +68,6 @@ export interface IProjectRepository { findDetailById(id: string): Promise; save(entity: ProjectDevelopmentEntity): Promise; update(entity: ProjectDevelopmentEntity): Promise; + delete(id: string): Promise; search(params: ProjectSearchParams): Promise>; } diff --git a/apps/api/src/modules/projects/infrastructure/repositories/prisma-project-development.repository.ts b/apps/api/src/modules/projects/infrastructure/repositories/prisma-project-development.repository.ts index a41ef31..5082d8d 100644 --- a/apps/api/src/modules/projects/infrastructure/repositories/prisma-project-development.repository.ts +++ b/apps/api/src/modules/projects/infrastructure/repositories/prisma-project-development.repository.ts @@ -91,6 +91,10 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository { `; } + async delete(id: string): Promise { + await this.prisma.projectDevelopment.delete({ where: { id } }); + } + async update(entity: ProjectDevelopmentEntity): Promise { await this.prisma.$executeRaw` UPDATE "ProjectDevelopment" SET diff --git a/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts b/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts index b2cc5cb..dbc5212 100644 --- a/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts +++ b/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts @@ -1,10 +1,11 @@ -import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { UserRole } from '@prisma/client'; import { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth'; import { NotFoundException } from '@modules/shared'; import { CreateProjectCommand } from '../../application/commands/create-project/create-project.command'; +import { DeleteProjectCommand } from '../../application/commands/delete-project/delete-project.command'; import { UpdateProjectCommand } from '../../application/commands/update-project/update-project.command'; import { GetProjectQuery } from '../../application/queries/get-project/get-project.query'; import { ListProjectsQuery } from '../../application/queries/list-projects/list-projects.query'; @@ -172,4 +173,18 @@ export class ProjectsController { ), ); } + + @ApiOperation({ summary: 'Xóa dự án (admin)', description: 'Xóa vĩnh viễn dự án bất động sản' }) + @ApiResponse({ status: 200, description: 'Dự án đã xóa' }) + @ApiResponse({ status: 401, description: 'Chưa xác thực' }) + @ApiResponse({ status: 403, description: 'Không có quyền' }) + @ApiResponse({ status: 404, description: 'Không tìm thấy tài nguyên' }) + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Delete(':id') + async deleteProject(@Param('id') id: string): Promise<{ success: true }> { + await this.commandBus.execute(new DeleteProjectCommand(id)); + return { success: true }; + } } diff --git a/apps/api/src/modules/projects/projects.module.ts b/apps/api/src/modules/projects/projects.module.ts index bee7011..c15d9a2 100644 --- a/apps/api/src/modules/projects/projects.module.ts +++ b/apps/api/src/modules/projects/projects.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { CreateProjectHandler } from './application/commands/create-project/create-project.handler'; +import { DeleteProjectHandler } from './application/commands/delete-project/delete-project.handler'; import { UpdateProjectHandler } from './application/commands/update-project/update-project.handler'; import { GetProjectHandler } from './application/queries/get-project/get-project.handler'; import { ListProjectsHandler } from './application/queries/list-projects/list-projects.handler'; @@ -8,7 +9,7 @@ import { PROJECT_REPOSITORY } from './domain/repositories/project-development.re import { PrismaProjectDevelopmentRepository } from './infrastructure/repositories/prisma-project-development.repository'; import { ProjectsController } from './presentation/controllers/projects.controller'; -const CommandHandlers = [CreateProjectHandler, UpdateProjectHandler]; +const CommandHandlers = [CreateProjectHandler, UpdateProjectHandler, DeleteProjectHandler]; const QueryHandlers = [GetProjectHandler, ListProjectsHandler]; @Module({ diff --git a/apps/web/app/[locale]/(dashboard)/industrial-parks/[id]/edit/page.tsx b/apps/web/app/[locale]/(dashboard)/industrial-parks/[id]/edit/page.tsx new file mode 100644 index 0000000..66d108d --- /dev/null +++ b/apps/web/app/[locale]/(dashboard)/industrial-parks/[id]/edit/page.tsx @@ -0,0 +1,551 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import * as React from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select } from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { + industrialApi, + PARK_STATUS_LABELS, + REGION_LABELS, + type IndustrialParkStatus, + type UpdateIndustrialParkPayload, + type VietnamRegion, +} from '@/lib/khu-cong-nghiep-api'; + +const STATUS_OPTIONS: IndustrialParkStatus[] = [ + 'PLANNING', + 'UNDER_CONSTRUCTION', + 'OPERATIONAL', + 'FULL', +]; +const REGION_OPTIONS: VietnamRegion[] = ['NORTH', 'CENTRAL', 'SOUTH']; + +const optionalString = z + .string() + .optional() + .transform((v) => (v && v.trim() !== '' ? v.trim() : undefined)); + +const optionalNonNegativeNumber = z + .string() + .optional() + .refine( + (v) => v === undefined || v === '' || (!isNaN(Number(v)) && Number(v) >= 0), + 'Phải là số không âm', + ); + +const editSchema = z.object({ + name: optionalString, + nameEn: optionalString, + slug: optionalString, + developer: optionalString, + operator: optionalString, + status: z.enum(['PLANNING', 'UNDER_CONSTRUCTION', 'OPERATIONAL', 'FULL']), + + address: optionalString, + district: optionalString, + province: optionalString, + region: z.enum(['NORTH', 'CENTRAL', 'SOUTH']), + latitude: z + .string() + .optional() + .refine( + (v) => v === undefined || v === '' || (!isNaN(Number(v)) && Number(v) >= -90 && Number(v) <= 90), + 'Vĩ độ từ -90 đến 90', + ), + longitude: z + .string() + .optional() + .refine( + (v) => + v === undefined || v === '' || (!isNaN(Number(v)) && Number(v) >= -180 && Number(v) <= 180), + 'Kinh độ từ -180 đến 180', + ), + + totalAreaHa: optionalNonNegativeNumber, + leasableAreaHa: optionalNonNegativeNumber, + + landRentUsdM2Year: optionalNonNegativeNumber, + rbfRentUsdM2Month: optionalNonNegativeNumber, + rbwRentUsdM2Month: optionalNonNegativeNumber, + managementFeeUsd: optionalNonNegativeNumber, + + targetIndustries: optionalString, + infrastructure: optionalString, + + establishedYear: optionalNonNegativeNumber, + tenantCount: optionalNonNegativeNumber, + + description: optionalString, + descriptionEn: optionalString, +}); + +type EditFormValues = z.input; + +function infraToText(infra: Record | null | undefined): string { + if (!infra) return ''; + return Object.entries(infra) + .map(([k, v]) => { + if (v === true) return k; + return `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`; + }) + .join('\n'); +} + +function parseInfrastructure(text: string | undefined): Record | undefined { + if (!text) return undefined; + const lines = text.split('\n').map((l) => l.trim()).filter(Boolean); + if (lines.length === 0) return undefined; + const out: Record = {}; + for (const line of lines) { + const idx = line.indexOf(':'); + if (idx > 0) { + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (key) out[key] = value; + } else { + out[line] = true; + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function toNumOrUndef(v: string | undefined): number | undefined { + if (v === undefined || v === '') return undefined; + const n = Number(v); + return isNaN(n) ? undefined : n; +} + +export default function EditIndustrialParkPage() { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const queryClient = useQueryClient(); + const [error, setError] = React.useState(null); + + const { + data: park, + isLoading, + isError, + } = useQuery({ + queryKey: ['admin-industrial-park', id], + queryFn: () => industrialApi.getBySlug(id), + enabled: Boolean(id), + }); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(editSchema), + mode: 'onTouched', + defaultValues: { + status: 'PLANNING', + region: 'NORTH', + }, + }); + + React.useEffect(() => { + if (!park) return; + reset({ + name: park.name, + nameEn: park.nameEn ?? '', + slug: park.slug, + developer: park.developer, + operator: park.operator ?? '', + status: park.status, + address: park.address, + district: park.district, + province: park.province, + region: park.region, + latitude: String(park.latitude), + longitude: String(park.longitude), + totalAreaHa: String(park.totalAreaHa), + leasableAreaHa: String(park.leasableAreaHa), + landRentUsdM2Year: park.landRentUsdM2Year != null ? String(park.landRentUsdM2Year) : '', + rbfRentUsdM2Month: park.rbfRentUsdM2Month != null ? String(park.rbfRentUsdM2Month) : '', + rbwRentUsdM2Month: park.rbwRentUsdM2Month != null ? String(park.rbwRentUsdM2Month) : '', + managementFeeUsd: park.managementFeeUsd != null ? String(park.managementFeeUsd) : '', + targetIndustries: park.targetIndustries.join(', '), + infrastructure: infraToText(park.infrastructure), + establishedYear: park.establishedYear != null ? String(park.establishedYear) : '', + tenantCount: String(park.tenantCount), + description: park.description ?? '', + descriptionEn: park.descriptionEn ?? '', + }); + }, [park, reset]); + + const updateMutation = useMutation({ + mutationFn: (payload: UpdateIndustrialParkPayload) => + industrialApi.updatePark(park?.id ?? id, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-industrial-parks'] }); + queryClient.invalidateQueries({ queryKey: ['admin-industrial-park', id] }); + router.push('/industrial-parks'); + }, + onError: (err: unknown) => { + setError(err instanceof Error ? err.message : 'Có lỗi xảy ra'); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: () => industrialApi.deletePark(park?.id ?? id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-industrial-parks'] }); + router.push('/industrial-parks'); + }, + onError: (err: unknown) => { + setError(err instanceof Error ? err.message : 'Không thể xoá KCN'); + }, + }); + + const onSubmit = (data: EditFormValues) => { + setError(null); + const payload: UpdateIndustrialParkPayload = { + status: data.status, + }; + + if (data.name) payload.name = data.name; + if (data.nameEn !== undefined) payload.nameEn = data.nameEn; + if (data.developer) payload.developer = data.developer; + if (data.operator !== undefined) payload.operator = data.operator; + + const landRent = toNumOrUndef(data.landRentUsdM2Year); + if (landRent != null) payload.landRentUsdM2Year = landRent; + const rbfRent = toNumOrUndef(data.rbfRentUsdM2Month); + if (rbfRent != null) payload.rbfRentUsdM2Month = rbfRent; + const rbwRent = toNumOrUndef(data.rbwRentUsdM2Month); + if (rbwRent != null) payload.rbwRentUsdM2Month = rbwRent; + const mgmtFee = toNumOrUndef(data.managementFeeUsd); + if (mgmtFee != null) payload.managementFeeUsd = mgmtFee; + + const tenantCount = toNumOrUndef(data.tenantCount); + if (tenantCount != null) payload.tenantCount = tenantCount; + + if (data.targetIndustries !== undefined) { + payload.targetIndustries = data.targetIndustries + ? data.targetIndustries + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : []; + } + + const infra = parseInfrastructure(data.infrastructure); + if (infra) payload.infrastructure = infra; + + if (data.description !== undefined) payload.description = data.description; + if (data.descriptionEn !== undefined) payload.descriptionEn = data.descriptionEn; + + updateMutation.mutate(payload); + }; + + const handleDelete = () => { + if (!park) return; + if (!window.confirm(`Xoá KCN "${park.name}"? Thao tác này không thể hoàn tác.`)) return; + deleteMutation.mutate(); + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (isError || !park) { + return ( +
+

Không tìm thấy KCN

+ +
+ ); + } + + return ( +
+
+
+ + ← Danh sách KCN + +

Chỉnh sửa KCN

+
+ + + +
+ + {error && ( +
+ {error} + +
+ )} + +
+ {/* Thông tin cơ bản */} + + + Thông tin cơ bản + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +