feat(web): dashboard gets Dự án + KCN nav; listings pages use list layout
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 5s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m0s
Deploy / Build API Image (push) Failing after 11s
Deploy / Build Web Image (push) Failing after 9s
Deploy / Build AI Services Image (push) Failing after 9s
E2E Tests / Playwright E2E (push) Failing after 7s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 2s
Security Scanning / Trivy Scan — Web Image (push) Failing after 25s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 24s
Security Scanning / Trivy Filesystem Scan (push) Failing after 35s
Deploy / Deploy to Staging (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 0s
Deploy / Rollback Staging (push) Has been skipped
Security Scanning / Trivy Scan — API Image (push) Failing after 26s
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped

Three asks after a walk-through of the dashboard:

1. Dashboard navigation was missing direct entry points to the two
   catalog surfaces (Dự án, Khu Công Nghiệp) even though both exist at
   /du-an and /khu-cong-nghiep. Users landing in the dashboard had to
   go back out to the public header to reach them.

2. The "Tin đăng" (dashboard listings) page defaulted to a 3-column
   grid which shows only a handful of properties per viewport. Scanning
   many listings at once is easier as a vertical list of horizontal
   rows.

3. The public /search results used the same 3-column grid via
   PropertyCard. Asked to flip to list there too.

Changes
- (dashboard)/layout.tsx: new `catalogs` nav group with Building2 +
  Factory icons pointing at /du-an and /khu-cong-nghiep. Primary
  desktop nav also exposes both so they're reachable without opening
  the hamburger. Uses existing `nav.projects` / `nav.industrialParks`
  i18n keys plus a new `dashboard.catalogs` label in vi/en.
- (dashboard)/listings/page.tsx: default viewMode flipped from 'grid'
  to 'list'. The list mode renders a horizontal row per listing
  (thumbnail + title/location + price + badges + engagement counters)
  inside an <ul>. Toggle button relabelled "Danh sách".
- components/search/search-results.tsx + property-card.tsx: add a
  `layout?: 'card' | 'list'` prop to PropertyCard. When `list`, the
  card renders as a horizontal row with 224px thumbnail on sm+,
  stacked on mobile. SearchResults wraps items in a <ul><li> and asks
  for list layout. Default card layout preserved so other callers
  (compare, related, etc.) keep their vertical card view.

No API / DB changes. Typecheck clean for the touched surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-19 09:49:51 +07:00
parent ad8577e2bd
commit 2f07b374d9
7 changed files with 210 additions and 80 deletions

View File

@@ -4,7 +4,9 @@ import {
BarChart3,
Bookmark,
Bot,
Building2,
CreditCard,
Factory,
FileText,
Gem,
Home,
@@ -82,6 +84,13 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
{ href: '/listings/new', label: t('dashboard.createListing'), icon: Plus },
],
},
{
label: t('dashboard.catalogs'),
items: [
{ href: '/du-an', label: t('nav.projects'), icon: Building2 },
{ href: '/khu-cong-nghiep', label: t('nav.industrialParks'), icon: Factory },
],
},
{
label: 'CRM',
items: [
@@ -112,9 +121,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
const primaryNav: NavItem[] = [
{ href: '/dashboard', label: t('dashboard.title'), icon: Home },
{ href: '/listings', label: t('dashboard.listings'), icon: List },
{ href: '/listings/new', label: t('dashboard.createListing'), icon: Plus },
{ href: '/du-an', label: t('nav.projects'), icon: Building2 },
{ href: '/khu-cong-nghiep', label: t('nav.industrialParks'), icon: Factory },
{ href: '/inquiries', label: t('dashboard.inquiries'), icon: MessageSquare },
{ href: '/leads', label: t('dashboard.leads'), icon: Target },
{ href: '/analytics', label: t('dashboard.analytics'), icon: BarChart3 },
];

View File

@@ -23,10 +23,10 @@ function formatDate(dateStr: string | null): string {
});
}
type ViewMode = 'grid' | 'table';
type ViewMode = 'list' | 'table';
export default function ListingsPage() {
const [viewMode, setViewMode] = React.useState<ViewMode>('grid');
const [viewMode, setViewMode] = React.useState<ViewMode>('list');
const [filters, setFilters] = React.useState({
transactionType: '',
propertyType: '',
@@ -147,11 +147,11 @@ export default function ListingsPage() {
<div className="ml-auto flex gap-1">
<Button
variant={viewMode === 'grid' ? 'default' : 'outline'}
variant={viewMode === 'list' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('grid')}
onClick={() => setViewMode('list')}
>
Lưới
Danh sách
</Button>
<Button
variant={viewMode === 'table' ? 'default' : 'outline'}
@@ -177,65 +177,75 @@ export default function ListingsPage() {
</Button>
</Link>
</div>
) : viewMode === 'grid' ? (
/* Grid View */
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
) : viewMode === 'list' ? (
/* List View — each row is a horizontal card (image | content | stats) */
<ul className="flex flex-col gap-3">
{result.data.map((listing) => (
<Link key={listing.id} href={`/listings/${listing.id}`}>
<Card className="h-full overflow-hidden transition-shadow hover:shadow-md">
<div className="relative aspect-[4/3] bg-muted">
{(listing.property.media?.length ?? 0) > 0 ? (
<Image
src={listing.property.media![0]?.url ?? ''}
alt={listing.property.title}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover"
placeholder="blur"
blurDataURL={shimmerBlurDataURL()}
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
Chưa nh
<li key={listing.id}>
<Link href={`/listings/${listing.id}`} className="group block">
<Card className="overflow-hidden transition-shadow hover:shadow-md">
<div className="flex flex-col sm:flex-row">
<div className="relative h-40 w-full shrink-0 bg-muted sm:h-32 sm:w-48">
{(listing.property.media?.length ?? 0) > 0 ? (
<Image
src={listing.property.media![0]?.url ?? ''}
alt={listing.property.title}
fill
sizes="(max-width: 640px) 100vw, 192px"
className="object-cover"
placeholder="blur"
blurDataURL={shimmerBlurDataURL()}
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
Chưa nh
</div>
)}
<div className="absolute left-2 top-2">
<ListingStatusBadge status={listing.status} />
</div>
</div>
)}
<div className="absolute left-2 top-2">
<ListingStatusBadge status={listing.status} />
<CardContent className="flex flex-1 flex-col gap-2 p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h3 className="line-clamp-1 font-semibold group-hover:text-primary">
{listing.property.title}
</h3>
<p className="mt-0.5 line-clamp-1 text-sm text-muted-foreground">
{listing.property.district}, {listing.property.city}
</p>
</div>
<p className="shrink-0 text-lg font-bold text-primary">
{formatPrice(listing.priceVND)} VND
</p>
</div>
<div className="flex flex-wrap gap-1.5">
<Badge variant="secondary" className="text-xs">
{listing.property.areaM2} m²
</Badge>
{listing.property.bedrooms != null && (
<Badge variant="secondary" className="text-xs">
{listing.property.bedrooms} PN
</Badge>
)}
{listing.property.bathrooms != null && listing.property.bathrooms > 0 && (
<Badge variant="secondary" className="text-xs">
{listing.property.bathrooms} PT
</Badge>
)}
</div>
<div className="mt-auto flex flex-wrap gap-3 text-xs text-muted-foreground">
<span>{listing.viewCount} lượt xem</span>
<span>{listing.inquiryCount} liên hệ</span>
<span>{listing.saveCount} đã lưu</span>
</div>
</CardContent>
</div>
</div>
<CardContent className="p-4">
<p className="text-lg font-bold text-primary">
{formatPrice(listing.priceVND)} VND
</p>
<h3 className="mt-1 line-clamp-1 font-medium">{listing.property.title}</h3>
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
{listing.property.district}, {listing.property.city}
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
<Badge variant="secondary" className="text-xs">
{listing.property.areaM2} m²
</Badge>
{listing.property.bedrooms != null && (
<Badge variant="secondary" className="text-xs">
{listing.property.bedrooms} PN
</Badge>
)}
{listing.property.bathrooms != null && listing.property.bathrooms > 0 && (
<Badge variant="secondary" className="text-xs">
{listing.property.bathrooms} PT
</Badge>
)}
</div>
<div className="mt-3 flex gap-3 text-xs text-muted-foreground">
<span>{listing.viewCount} lượt xem</span>
<span>{listing.inquiryCount} liên hệ</span>
<span>{listing.saveCount} đã lưu</span>
</div>
</CardContent>
</Card>
</Link>
</Card>
</Link>
</li>
))}
</div>
</ul>
) : (
/* Table View */
<Card>