feat: dashboard CRUD for Projects + Industrial Parks, listings delete, BĐS homepage card
Some checks failed
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m15s
Deploy / Build API Image (push) Failing after 20s
Deploy / Build AI Services Image (push) Failing after 12s
E2E Tests / Playwright E2E (push) Failing after 16s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 35s
Security Scanning / Trivy Filesystem Scan (push) Failing after 30s
Backup Verification / Backup Restore Verification (push) Failing after 14m37s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m4s
Security Scanning / Trivy Scan — Web Image (push) Failing after 36s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 11m6s
Deploy / Build Web Image (push) Failing after 12s
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 8s
CI / E2E Tests (push) Has been skipped
Security Scanning / Security Gate (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-19 10:37:33 +07:00
parent d2488b1cc1
commit ba0bf97426
32 changed files with 2843 additions and 22 deletions

View File

@@ -22,10 +22,12 @@ import { Link, useRouter } from '@/i18n/navigation';
import { transferApi, type TransferListingListItem } from '@/lib/chuyen-nhuong-api';
import { duAnApi, type ProjectSummary } from '@/lib/du-an-api';
import { industrialApi, type IndustrialParkListItem } from '@/lib/khu-cong-nghiep-api';
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
type FeatureKey = 'projects' | 'industrial' | 'transfer' | 'valuation';
type FeatureKey = 'listings' | 'projects' | 'industrial' | 'transfer' | 'valuation';
const FEATURES: { key: FeatureKey; href: string; icon: LucideIcon }[] = [
{ key: 'listings', href: '/search', icon: Home },
{ key: 'projects', href: '/du-an', icon: Building2 },
{ key: 'industrial', href: '/khu-cong-nghiep', icon: Factory },
{ key: 'transfer', href: '/chuyen-nhuong', icon: ArrowRightLeft },
@@ -56,6 +58,7 @@ type FeaturedItem = {
};
const VIEW_ALL_HREFS: Record<FeatureKey, string> = {
listings: '/search',
projects: '/du-an',
industrial: '/khu-cong-nghiep',
transfer: '/chuyen-nhuong',
@@ -81,6 +84,7 @@ export default function LandingPage() {
const [projects, setProjects] = React.useState<ProjectSummary[]>([]);
const [parks, setParks] = React.useState<IndustrialParkListItem[]>([]);
const [transfers, setTransfers] = React.useState<TransferListingListItem[]>([]);
const [listings, setListings] = React.useState<ListingDetail[]>([]);
const [loadingFeatured, setLoadingFeatured] = React.useState(true);
const [featuredError, setFeaturedError] = React.useState(false);
@@ -93,11 +97,13 @@ export default function LandingPage() {
setLoadingFeatured(true);
setFeaturedError(false);
const request =
feature === 'projects'
? duAnApi.search({ limit: 4 }).then((res) => setProjects(res.data))
: feature === 'industrial'
? industrialApi.search({ limit: 4 }).then((res) => setParks(res.data))
: transferApi.search({ limit: 4 }).then((res) => setTransfers(res.data));
feature === 'listings'
? listingsApi.search({ limit: 4, status: 'ACTIVE' }).then((res) => setListings(res.data))
: feature === 'projects'
? duAnApi.search({ limit: 4 }).then((res) => setProjects(res.data))
: feature === 'industrial'
? industrialApi.search({ limit: 4 }).then((res) => setParks(res.data))
: transferApi.search({ limit: 4 }).then((res) => setTransfers(res.data));
request
.catch(() => setFeaturedError(true))
.finally(() => setLoadingFeatured(false));
@@ -108,6 +114,22 @@ export default function LandingPage() {
}, [activeFeature, fetchFeatured]);
const featuredItems: FeaturedItem[] = React.useMemo(() => {
if (activeFeature === 'listings') {
return listings.map((l) => ({
id: l.id,
href: `/listings/${l.id}`,
imageUrl: l.property.media?.[0]?.url ?? null,
fallbackIcon: Home,
title: l.property.title,
location: `${l.property.district}, ${l.property.city}`,
priceLabel: `${formatVND(l.priceVND)} VNĐ`,
meta: [
`${l.property.areaM2}`,
l.property.bedrooms != null ? `${l.property.bedrooms} PN` : null,
l.transactionType === 'SALE' ? 'Bán' : 'Cho thuê',
].filter(Boolean) as string[],
}));
}
if (activeFeature === 'projects') {
return projects.map((p) => ({
id: p.id,
@@ -145,7 +167,7 @@ export default function LandingPage() {
}));
}
return [];
}, [activeFeature, projects, parks, transfers]);
}, [activeFeature, projects, parks, transfers, listings]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
@@ -244,7 +266,7 @@ export default function LandingPage() {
</p>
</div>
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{FEATURES.map((feature) => (
<Link key={feature.key} href={feature.href} className="group">
<div className="flex h-full flex-col rounded-xl border bg-card p-6 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-md">