diff --git a/apps/api/src/modules/projects/application/commands/create-project/create-project.command.ts b/apps/api/src/modules/projects/application/commands/create-project/create-project.command.ts index 447e19e..c8bb6b9 100644 --- a/apps/api/src/modules/projects/application/commands/create-project/create-project.command.ts +++ b/apps/api/src/modules/projects/application/commands/create-project/create-project.command.ts @@ -27,5 +27,7 @@ export class CreateProjectCommand { public readonly tags: string[], public readonly startDate: Date | null, public readonly completionDate: Date | null, + public readonly suitableFor: string[] = [], + public readonly whyThisLocation: string | null = null, ) {} } diff --git a/apps/api/src/modules/projects/application/commands/create-project/create-project.handler.ts b/apps/api/src/modules/projects/application/commands/create-project/create-project.handler.ts index d8adeee..150ff96 100644 --- a/apps/api/src/modules/projects/application/commands/create-project/create-project.handler.ts +++ b/apps/api/src/modules/projects/application/commands/create-project/create-project.handler.ts @@ -54,6 +54,8 @@ export class CreateProjectHandler implements ICommandHandler[] | null; documents: Record[] | null; tags: string[]; + suitableFor: string[]; + whyThisLocation: string | null; isVerified: boolean; } @@ -62,6 +64,8 @@ export class ProjectDevelopmentEntity extends AggregateRoot { private _media: Record[] | null; private _documents: Record[] | null; private _tags: string[]; + private _suitableFor: string[]; + private _whyThisLocation: string | null; private _isVerified: boolean; constructor(id: string, props: ProjectDevelopmentProps, createdAt: Date, updatedAt: Date) { @@ -94,6 +98,8 @@ export class ProjectDevelopmentEntity extends AggregateRoot { this._media = props.media; this._documents = props.documents; this._tags = props.tags; + this._suitableFor = props.suitableFor; + this._whyThisLocation = props.whyThisLocation; this._isVerified = props.isVerified; } @@ -125,6 +131,8 @@ export class ProjectDevelopmentEntity extends AggregateRoot { get media() { return this._media; } get documents() { return this._documents; } get tags() { return this._tags; } + get suitableFor() { return this._suitableFor; } + get whyThisLocation() { return this._whyThisLocation; } get isVerified() { return this._isVerified; } updateDetails(props: Partial): void { @@ -149,6 +157,8 @@ export class ProjectDevelopmentEntity extends AggregateRoot { if (props.media !== undefined) this._media = props.media; if (props.documents !== undefined) this._documents = props.documents; if (props.tags !== undefined) this._tags = props.tags; + if (props.suitableFor !== undefined) this._suitableFor = props.suitableFor; + if (props.whyThisLocation !== undefined) this._whyThisLocation = props.whyThisLocation; if (props.isVerified !== undefined) this._isVerified = props.isVerified; this.updatedAt = new Date(); } 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 cbbe168..780f675 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 @@ -39,6 +39,8 @@ export interface ProjectListItem { maxPrice: bigint | null; totalArea: number | null; tags: string[]; + suitableFor: string[]; + whyThisLocation: string | null; isVerified: boolean; latitude: number; longitude: number; 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 5082d8d..af43b6d 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 @@ -68,7 +68,7 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository { location, address, ward, district, city, "minPrice", "maxPrice", "pricePerM2Range", "totalArea", "buildingCount", "floorCount", "unitTypes", media, documents, - tags, "isVerified", "createdAt", "updatedAt" + tags, "suitableFor", "whyThisLocation", "isVerified", "createdAt", "updatedAt" ) VALUES ( ${entity.id}, ${entity.name}, ${entity.slug}, ${entity.developer}, ${entity.developerLogo}, ${entity.totalUnits}, ${entity.completedUnits}, @@ -86,6 +86,8 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository { ${entity.media ? JSON.stringify(entity.media) : null}::jsonb, ${entity.documents ? JSON.stringify(entity.documents) : null}::jsonb, ${entity.tags}::text[], + ${entity.suitableFor}::text[], + ${entity.whyThisLocation}, ${entity.isVerified}, ${entity.createdAt}, ${entity.updatedAt} ) `; @@ -114,6 +116,8 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository { media = ${entity.media ? JSON.stringify(entity.media) : null}::jsonb, documents = ${entity.documents ? JSON.stringify(entity.documents) : null}::jsonb, tags = ${entity.tags}::text[], + "suitableFor" = ${entity.suitableFor}::text[], + "whyThisLocation" = ${entity.whyThisLocation}, "isVerified" = ${entity.isVerified}, "updatedAt" = ${entity.updatedAt} WHERE id = ${entity.id} @@ -216,6 +220,8 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository { media: row.media as Record[] | null, documents: row.documents as Record[] | null, tags: row.tags ?? [], + suitableFor: row.suitableFor ?? [], + whyThisLocation: row.whyThisLocation, isVerified: row.isVerified, }, row.createdAt, @@ -241,6 +247,8 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository { maxPrice: row.maxPrice, totalArea: row.totalArea, tags: row.tags ?? [], + suitableFor: row.suitableFor ?? [], + whyThisLocation: row.whyThisLocation, isVerified: row.isVerified, latitude: Number(row.lat), longitude: Number(row.lng), @@ -298,6 +306,8 @@ interface RawProject { media: Prisma.JsonValue; documents: Prisma.JsonValue; tags: string[] | null; + suitableFor: string[] | null; + whyThisLocation: string | null; isVerified: boolean; createdAt: Date; updatedAt: Date; 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 dbc5212..7bb24f6 100644 --- a/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts +++ b/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts @@ -131,6 +131,8 @@ export class ProjectsController { dto.tags ?? [], dto.startDate ? new Date(dto.startDate) : null, dto.completionDate ? new Date(dto.completionDate) : null, + dto.suitableFor ?? [], + dto.whyThisLocation ?? null, ), ); } @@ -170,6 +172,8 @@ export class ProjectsController { dto.isVerified, dto.startDate !== undefined ? (dto.startDate ? new Date(dto.startDate) : null) : undefined, dto.completionDate !== undefined ? (dto.completionDate ? new Date(dto.completionDate) : null) : undefined, + dto.suitableFor, + dto.whyThisLocation, ), ); } diff --git a/apps/api/src/modules/projects/presentation/dto/create-project.dto.ts b/apps/api/src/modules/projects/presentation/dto/create-project.dto.ts index bb8786c..bbd7490 100644 --- a/apps/api/src/modules/projects/presentation/dto/create-project.dto.ts +++ b/apps/api/src/modules/projects/presentation/dto/create-project.dto.ts @@ -134,6 +134,24 @@ export class CreateProjectDto { @IsString({ each: true }) tags?: string[]; + @ApiPropertyOptional({ + example: ['Gia đình trẻ', 'Chuyên gia nước ngoài'], + description: 'Các nhóm khách phù hợp với dự án', + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + suitableFor?: string[]; + + @ApiPropertyOptional({ + description: 'Mô tả ngắn vì sao khu vực này phù hợp', + maxLength: 2000, + }) + @IsOptional() + @IsString() + @MaxLength(2000) + whyThisLocation?: string; + @ApiPropertyOptional({ example: '2020-06-01', description: 'Ngày khởi công' }) @IsOptional() @IsDateString() diff --git a/apps/api/src/modules/projects/presentation/dto/update-project.dto.ts b/apps/api/src/modules/projects/presentation/dto/update-project.dto.ts index 8c0e3f5..0b089bc 100644 --- a/apps/api/src/modules/projects/presentation/dto/update-project.dto.ts +++ b/apps/api/src/modules/projects/presentation/dto/update-project.dto.ts @@ -10,6 +10,7 @@ import { IsObject, IsBoolean, Min, + MaxLength, IsDateString, } from 'class-validator'; @@ -44,6 +45,13 @@ export class UpdateProjectDto { @ApiPropertyOptional() @IsOptional() @IsArray() media?: Record[] | null; @ApiPropertyOptional() @IsOptional() @IsArray() documents?: Record[] | null; @ApiPropertyOptional() @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[]; + + @ApiPropertyOptional({ description: 'Các nhóm khách phù hợp với dự án' }) + @IsOptional() @IsArray() @IsString({ each: true }) suitableFor?: string[]; + + @ApiPropertyOptional({ description: 'Mô tả ngắn vì sao khu vực này phù hợp', maxLength: 2000 }) + @IsOptional() @IsString() @MaxLength(2000) whyThisLocation?: string | null; + @ApiPropertyOptional() @IsOptional() @IsBoolean() isVerified?: boolean; @ApiPropertyOptional() @IsOptional() @IsDateString() startDate?: string | null; @ApiPropertyOptional() @IsOptional() @IsDateString() completionDate?: string | null; diff --git a/apps/web/app/[locale]/(dashboard)/listings/[id]/edit/page.tsx b/apps/web/app/[locale]/(dashboard)/listings/[id]/edit/page.tsx index cfc27ca..38e091f 100644 --- a/apps/web/app/[locale]/(dashboard)/listings/[id]/edit/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/listings/[id]/edit/page.tsx @@ -13,12 +13,23 @@ import { import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; -import { listingsApi, type ListingDetail } from '@/lib/listings-api'; +import { listingsApi, type Direction, type ListingDetail, type UpdateListingPayload } from '@/lib/listings-api'; import { createListingSchema, type CreateListingFormData, } from '@/lib/validations/listings'; +function toNum(v: string | undefined): number | undefined { + if (!v) return undefined; + const n = Number(v); + return Number.isFinite(n) ? n : undefined; +} + +function toStrArr(csv: string | undefined): string[] | undefined { + if (!csv || !csv.trim()) return undefined; + return csv.split(',').map((s) => s.trim()).filter(Boolean); +} + export default function EditListingPage() { const { id } = useParams<{ id: string }>(); const router = useRouter(); @@ -26,15 +37,88 @@ export default function EditListingPage() { const [loading, setLoading] = React.useState(true); const [activeTab, setActiveTab] = React.useState('basic'); + const [saving, setSaving] = React.useState(false); + const [saveMsg, setSaveMsg] = React.useState<{ type: 'success' | 'error'; text: string } | null>(null); + const { register, reset, + handleSubmit, formState: { errors }, } = useForm({ resolver: zodResolver(createListingSchema), mode: 'onTouched', }); + const onSubmit = async (data: CreateListingFormData) => { + setSaving(true); + setSaveMsg(null); + try { + const payload: UpdateListingPayload = { + transactionType: data.transactionType, + propertyType: data.propertyType, + title: data.title, + description: data.description, + address: data.address, + ward: data.ward, + district: data.district, + city: data.city, + priceVND: data.priceVND, + areaM2: Number(data.areaM2), + }; + const lat = toNum(data.latitude); + if (lat != null) payload.latitude = lat; + const lng = toNum(data.longitude); + if (lng != null) payload.longitude = lng; + const usableAreaM2 = toNum(data.usableAreaM2); + if (usableAreaM2 != null) payload.usableAreaM2 = usableAreaM2; + const bedrooms = toNum(data.bedrooms); + if (bedrooms != null) payload.bedrooms = bedrooms; + const bathrooms = toNum(data.bathrooms); + if (bathrooms != null) payload.bathrooms = bathrooms; + const floors = toNum(data.floors); + if (floors != null) payload.floors = floors; + const floor = toNum(data.floor); + if (floor != null) payload.floor = floor; + const totalFloors = toNum(data.totalFloors); + if (totalFloors != null) payload.totalFloors = totalFloors; + if (data.direction) payload.direction = data.direction as Direction; + const yearBuilt = toNum(data.yearBuilt); + if (yearBuilt != null) payload.yearBuilt = yearBuilt; + if (data.legalStatus) payload.legalStatus = data.legalStatus; + if (data.projectName) payload.projectName = data.projectName; + const amenities = toStrArr(data.amenities); + if (amenities) payload.amenities = amenities; + if (data.rentPriceMonthly) payload.rentPriceMonthly = data.rentPriceMonthly; + const commissionPct = toNum(data.commissionPct); + if (commissionPct != null) payload.commissionPct = commissionPct; + // Phase B + if (data.furnishing) payload.furnishing = data.furnishing as UpdateListingPayload['furnishing']; + if (data.propertyCondition) payload.propertyCondition = data.propertyCondition as UpdateListingPayload['propertyCondition']; + if (data.balconyDirection) payload.balconyDirection = data.balconyDirection as Direction; + if (data.maintenanceFeeVND) payload.maintenanceFeeVND = data.maintenanceFeeVND; + const parkingSlots = toNum(data.parkingSlots); + if (parkingSlots != null) payload.parkingSlots = parkingSlots; + const viewType = toStrArr(data.viewType); + if (viewType) payload.viewType = viewType; + if (data.petFriendly === 'true') payload.petFriendly = true; + else if (data.petFriendly === 'false') payload.petFriendly = false; + const suitableFor = toStrArr(data.suitableFor); + if (suitableFor) payload.suitableFor = suitableFor; + if (data.whyThisLocation) payload.whyThisLocation = data.whyThisLocation; + + await listingsApi.update(id, payload); + setSaveMsg({ type: 'success', text: 'Đã lưu thay đổi.' }); + } catch (err) { + setSaveMsg({ + type: 'error', + text: err instanceof Error ? err.message : 'Không thể lưu — vui lòng thử lại.', + }); + } finally { + setSaving(false); + } + }; + React.useEffect(() => { listingsApi .getById(id) @@ -110,36 +194,54 @@ export default function EditListingPage() { -

- Chức năng chỉnh sửa sẽ được hoàn thiện khi backend API hỗ trợ PATCH /listings/:id. - Hiện tại bạn có thể xem lại thông tin đã nhập. -

+ {saveMsg && ( +
+ {saveMsg.text} +
+ )} - - - Cơ bản - Vị trí - Chi tiết - Giá cả - +
+ + + Cơ bản + Vị trí + Chi tiết + Giá cả + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + +
+ + +
+
); } diff --git a/apps/web/app/[locale]/(dashboard)/projects/[id]/edit/page.tsx b/apps/web/app/[locale]/(dashboard)/projects/[id]/edit/page.tsx index 30dbd0b..1bb7f88 100644 --- a/apps/web/app/[locale]/(dashboard)/projects/[id]/edit/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/projects/[id]/edit/page.tsx @@ -73,6 +73,12 @@ const editSchema = z.object({ completionDate: z.string().optional(), tags: z.string().optional(), + + suitableFor: z.string().optional(), + whyThisLocation: z + .string() + .max(2000, 'Tối đa 2000 ký tự') + .optional(), }); type EditFormData = z.infer; @@ -146,6 +152,8 @@ export default function EditProjectPage() { startDate: '', completionDate: toDateInput(project.completionDate), tags: '', + suitableFor: (project.suitableFor ?? []).join(', '), + whyThisLocation: project.whyThisLocation ?? '', }); }, [project, reset]); @@ -185,6 +193,15 @@ export default function EditProjectPage() { .map((s) => s.trim()) .filter(Boolean); } + if (data.suitableFor !== undefined) { + payload.suitableFor = data.suitableFor + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + } + if (data.whyThisLocation !== undefined) { + payload.whyThisLocation = data.whyThisLocation || null; + } await duAnApi.update(id, payload); await queryClient.invalidateQueries({ queryKey: ['admin-projects'] }); @@ -378,6 +395,34 @@ export default function EditProjectPage() { + {/* Phù hợp & lý do khu vực */} + +
+ + +

+ Mỗi nhóm đối tượng là một chip hiển thị trên trang dự án. +

+
+
+ +