- Import TickerStrip vào dashboard layout, truyền vào DashboardLayout.ticker - Thêm placeholder top-8 quận với TODO comment chờ /analytics/districts API - Thêm role="status" aria-live="polite" vào status bar div trong DashboardLayout - 8 Vitest unit tests cho DashboardLayout: role=banner, role=status, ticker, sidebar collapse/expand width, main content (tất cả pass) Note: listings.spec.tsx failure là pre-existing trên HEAD, không liên quan TEC-3047. Co-Authored-By: Paperclip <noreply@paperclip.ing>
342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
BarChart3,
|
|
Bookmark,
|
|
Bot,
|
|
Building2,
|
|
CreditCard,
|
|
Factory,
|
|
FileText,
|
|
Gem,
|
|
Home,
|
|
List,
|
|
LogOut,
|
|
MessageSquare,
|
|
Moon,
|
|
Plus,
|
|
Search,
|
|
Sun,
|
|
Target,
|
|
User,
|
|
type LucideIcon,
|
|
} from 'lucide-react';
|
|
import Image from 'next/image';
|
|
import { usePathname } from 'next/navigation';
|
|
import { useTranslations } from 'next-intl';
|
|
import { useEffect, useState } from 'react';
|
|
import { DashboardLayout } from '@/components/design-system/dashboard-layout';
|
|
import { CompactHeader } from '@/components/design-system/compact-header';
|
|
import { TickerStrip } from '@/components/design-system/ticker-strip';
|
|
import type { TickerItem } from '@/components/design-system/ticker-strip';
|
|
import { NotificationBell } from '@/components/notifications/notification-bell';
|
|
import { useTheme } from '@/components/providers/theme-provider';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
|
import { Link } from '@/i18n/navigation';
|
|
import { useRouter } from '@/i18n/navigation';
|
|
import { useAuthStore } from '@/lib/auth-store';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface NavItem {
|
|
href: string;
|
|
label: string;
|
|
icon: LucideIcon;
|
|
}
|
|
|
|
interface NavGroup {
|
|
label: string;
|
|
items: NavItem[];
|
|
}
|
|
|
|
/** Icon-only sidebar button with tooltip. */
|
|
function SidebarNavItem({
|
|
item,
|
|
active,
|
|
onClick,
|
|
}: {
|
|
item: NavItem;
|
|
active: boolean;
|
|
onClick?: () => void;
|
|
}) {
|
|
return (
|
|
<Link
|
|
href={item.href}
|
|
onClick={onClick}
|
|
aria-label={item.label}
|
|
title={item.label}
|
|
className={cn(
|
|
'group relative flex h-10 w-10 items-center justify-center rounded-md transition-colors',
|
|
active
|
|
? 'bg-primary/10 text-primary'
|
|
: 'text-foreground-muted hover:bg-background-surface hover:text-foreground',
|
|
)}
|
|
>
|
|
<item.icon className="h-[18px] w-[18px] shrink-0" aria-hidden="true" />
|
|
{/* Tooltip */}
|
|
<span className="pointer-events-none absolute left-full ml-2 whitespace-nowrap rounded bg-background-elevated px-2 py-1 text-xs text-foreground opacity-0 shadow-elevation-2 transition-opacity group-hover:opacity-100">
|
|
{item.label}
|
|
</span>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export default function AppDashboardLayout({ children }: { children: React.ReactNode }) {
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const { user, isAuthenticated, isInitialized, logout } = useAuthStore();
|
|
const { theme, toggleTheme } = useTheme();
|
|
const t = useTranslations();
|
|
const [now, setNow] = useState<Date | null>(null);
|
|
|
|
// Auth guard
|
|
useEffect(() => {
|
|
if (isInitialized && !isAuthenticated) {
|
|
const next = encodeURIComponent(pathname);
|
|
router.replace(`/login?next=${next}`);
|
|
}
|
|
}, [isInitialized, isAuthenticated, pathname, router]);
|
|
|
|
// Live clock for status bar
|
|
useEffect(() => {
|
|
setNow(new Date());
|
|
const id = setInterval(() => setNow(new Date()), 30_000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
if (!isInitialized || !isAuthenticated) {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center text-sm text-foreground-muted">
|
|
{t('common.loading')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const role = user?.role;
|
|
const isDeveloper = role === 'DEVELOPER';
|
|
const isParkOperator = role === 'PARK_OPERATOR';
|
|
const showListings = !isDeveloper && !isParkOperator;
|
|
const showProjects = !isParkOperator;
|
|
const showParks = !isDeveloper;
|
|
|
|
const navGroups: NavGroup[] = [
|
|
{
|
|
label: t('dashboard.title'),
|
|
items: [
|
|
{ href: '/dashboard', label: t('dashboard.title'), icon: Home },
|
|
...(showListings
|
|
? [
|
|
{ href: '/listings', label: t('dashboard.listings'), icon: List },
|
|
{ href: '/listings/new', label: t('dashboard.createListing'), icon: Plus },
|
|
]
|
|
: []),
|
|
],
|
|
},
|
|
{
|
|
label: t('dashboard.catalogs'),
|
|
items: [
|
|
...(showProjects
|
|
? [
|
|
{
|
|
href: '/projects',
|
|
label: isDeveloper ? 'Dự án của tôi' : t('dashboard.manageProjects'),
|
|
icon: Building2,
|
|
},
|
|
]
|
|
: []),
|
|
...(showParks
|
|
? [
|
|
{
|
|
href: '/industrial-parks',
|
|
label: isParkOperator ? 'KCN của tôi' : t('dashboard.manageIndustrialParks'),
|
|
icon: Factory,
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
},
|
|
{
|
|
label: 'CRM',
|
|
items: [
|
|
{ href: '/inquiries', label: t('dashboard.inquiries'), icon: MessageSquare },
|
|
...(showListings
|
|
? [{ href: '/leads', label: t('dashboard.leads'), icon: Target }]
|
|
: []),
|
|
],
|
|
},
|
|
...(showListings
|
|
? [
|
|
{
|
|
label: t('dashboard.analytics'),
|
|
items: [
|
|
{ href: '/analytics', label: t('dashboard.analytics'), icon: BarChart3 },
|
|
{ href: '/dashboard/reports', label: t('dashboard.reports'), icon: FileText },
|
|
{
|
|
href: '/dashboard/saved-searches',
|
|
label: t('dashboard.savedSearches'),
|
|
icon: Bookmark,
|
|
},
|
|
{ href: '/dashboard/valuation', label: t('dashboard.aiValuation'), icon: Bot },
|
|
],
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
label: t('dashboard.profile'),
|
|
items: [
|
|
{ href: '/dashboard/profile', label: t('dashboard.profile'), icon: User },
|
|
...(showListings
|
|
? [
|
|
{
|
|
href: '/dashboard/subscription',
|
|
label: t('dashboard.subscription'),
|
|
icon: Gem,
|
|
},
|
|
{
|
|
href: '/dashboard/payments',
|
|
label: t('dashboard.payments'),
|
|
icon: CreditCard,
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
},
|
|
].filter((g) => g.items.length > 0);
|
|
|
|
const allNavItems = navGroups.flatMap((g) => g.items);
|
|
|
|
const isActive = (href: string) =>
|
|
pathname === href || (href !== '/dashboard' && pathname.startsWith(href));
|
|
|
|
// ── Sidebar (icon-only 56px, mobile uses sheet drawer) ──────────────────
|
|
const sidebar = (
|
|
<div className="flex h-full flex-col items-center gap-1 py-3">
|
|
{/* Logo mark */}
|
|
<Link
|
|
href="/dashboard"
|
|
className="mb-2 flex h-10 w-10 items-center justify-center rounded-md text-primary"
|
|
aria-label={t('common.goodgo')}
|
|
title={t('common.goodgo')}
|
|
>
|
|
<span className="text-sm font-bold leading-none">GG</span>
|
|
</Link>
|
|
|
|
<div className="h-px w-8 bg-border" />
|
|
|
|
{/* Nav items */}
|
|
<nav className="flex flex-1 flex-col items-center gap-1 pt-2" aria-label={t('nav.dashboardNav')}>
|
|
{allNavItems.map((item) => (
|
|
<SidebarNavItem key={item.href} item={item} active={isActive(item.href)} />
|
|
))}
|
|
</nav>
|
|
|
|
{/* Bottom: logout */}
|
|
<div className="flex flex-col items-center gap-1 pb-1">
|
|
<button
|
|
onClick={() => logout()}
|
|
aria-label={t('common.logout')}
|
|
title={t('common.logout')}
|
|
className="flex h-10 w-10 items-center justify-center rounded-md text-foreground-muted transition-colors hover:bg-background-surface hover:text-foreground"
|
|
>
|
|
<LogOut className="h-[18px] w-[18px]" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ── CompactHeader ────────────────────────────────────────────────────────
|
|
const header = (
|
|
<CompactHeader
|
|
logo={
|
|
<span className="text-sm font-bold text-primary">{t('common.goodgo')}</span>
|
|
}
|
|
breadcrumb={
|
|
<span className="text-foreground-dim">/</span>
|
|
}
|
|
search={
|
|
<div className="relative">
|
|
<Search
|
|
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-foreground-dim"
|
|
aria-hidden="true"
|
|
/>
|
|
<Input
|
|
type="search"
|
|
placeholder="Tìm bất động sản..."
|
|
className="h-8 w-full bg-background-surface pl-8 text-sm placeholder:text-foreground-dim focus-visible:ring-primary"
|
|
aria-label="Tìm kiếm"
|
|
/>
|
|
</div>
|
|
}
|
|
actions={
|
|
<>
|
|
{user && <NotificationBell />}
|
|
<LanguageSwitcher />
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={toggleTheme}
|
|
aria-label={theme === 'light' ? t('dashboard.darkMode') : t('dashboard.lightMode')}
|
|
className="h-8 w-8 p-0 text-foreground-muted hover:text-foreground"
|
|
>
|
|
{theme === 'light' ? (
|
|
<Moon className="h-4 w-4" aria-hidden="true" />
|
|
) : (
|
|
<Sun className="h-4 w-4" aria-hidden="true" />
|
|
)}
|
|
</Button>
|
|
{user && (
|
|
<span
|
|
className="hidden max-w-[8rem] truncate text-xs text-foreground-muted lg:inline"
|
|
title={user.fullName}
|
|
>
|
|
{user.fullName}
|
|
</span>
|
|
)}
|
|
</>
|
|
}
|
|
/>
|
|
);
|
|
|
|
// ── Ticker strip (top 8 quận, placeholder → TODO: /analytics/districts) ───
|
|
// TODO: thay thế bằng dữ liệu thực từ /analytics/districts khi API sẵn sàng (TEC-3047)
|
|
const tickerItems: TickerItem[] = [
|
|
{ id: 'q1', label: 'Quận 1', changePercent: 2.4, direction: 'up' },
|
|
{ id: 'q2', label: 'Quận 2', changePercent: -0.8, direction: 'down' },
|
|
{ id: 'q3', label: 'Quận 3', changePercent: 1.1, direction: 'up' },
|
|
{ id: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' },
|
|
{ id: 'binhthanh', label: 'Bình Thạnh', changePercent: 0.0, direction: 'neutral' },
|
|
{ id: 'thuduc', label: 'Thủ Đức', changePercent: 1.7, direction: 'up' },
|
|
{ id: 'tanbinh', label: 'Tân Bình', changePercent: -1.3, direction: 'down' },
|
|
{ id: 'phuninh', label: 'Phú Nhuận', changePercent: 0.5, direction: 'up' },
|
|
];
|
|
const ticker = <TickerStrip items={tickerItems} />;
|
|
|
|
// ── Status bar ───────────────────────────────────────────────────────────
|
|
const statusBar = (
|
|
<>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-signal-up" aria-hidden="true" />
|
|
<span>Đã kết nối</span>
|
|
</span>
|
|
{now && (
|
|
<span className="text-foreground-dim">
|
|
Cập nhật lúc {now.toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<DashboardLayout
|
|
sidebar={sidebar}
|
|
header={header}
|
|
ticker={ticker}
|
|
statusBar={statusBar}
|
|
sidebarCollapsed
|
|
>
|
|
{children}
|
|
</DashboardLayout>
|
|
);
|
|
}
|