Design System Master File¶
LOGIC: When building a specific page, first check
design-system/pages/[page-name].md. If that file exists, its rules override this Master file. If not, strictly follow the rules below.
Project: GovData — Nền Tảng Quản Lý, Quản Trị Dữ Liệu Thành Phố Updated: 2026-04-21 Category: Government Data Governance Platform Stack: Next.js (App Router) + Tailwind CSS 4 (OKLCH) + shadcn/ui + Lucide Icons Target Devices: PC-first (Desktop > Tablet > Mobile) Style: Government Modern — Clean, spacious, rounded, bold typography
Global Rules¶
Color Palette¶
Light Mode (:root)¶
| Token | OKLCH Value | Hex (approx) | Usage |
|---|---|---|---|
--background |
oklch(0.984 0.003 247) |
#f8fafc |
App background (slate-50) |
--foreground |
oklch(0.208 0.042 265) |
#1e293b |
Primary text (slate-800) |
--primary |
oklch(0.546 0.245 262) |
#2563eb |
Brand blue, primary actions (blue-600) |
--primary-foreground |
oklch(0.985 0.002 247) |
#eff6ff |
Text on primary (blue-50) |
--secondary |
oklch(0.968 0.003 265) |
#f1f5f9 |
Secondary backgrounds (slate-100) |
--secondary-foreground |
oklch(0.208 0.042 265) |
#1e293b |
Text on secondary (slate-800) |
--muted |
oklch(0.968 0.003 265) |
#f1f5f9 |
Subdued backgrounds (slate-100) |
--muted-foreground |
oklch(0.554 0.022 257) |
#64748b |
Muted text (slate-500) |
--accent |
oklch(0.929 0.013 255) |
#e2e8f0 |
Highlight/hover (slate-200) |
--accent-foreground |
oklch(0.208 0.042 265) |
#1e293b |
Text on accent (slate-800) |
--destructive |
oklch(0.577 0.245 27) |
#dc2626 |
Error, danger, DISPUTED (red-600) |
--destructive-foreground |
oklch(0.985 0.002 15) |
#fef2f2 |
Text on destructive |
--border |
oklch(0.929 0.013 255) |
#e2e8f0 |
Default borders (slate-200) |
--input |
oklch(0.929 0.013 255) |
#e2e8f0 |
Input borders (slate-200) |
--ring |
oklch(0.546 0.245 262) |
#2563eb |
Focus rings (blue-600) |
| Token | OKLCH Value | Hex (approx) | Usage |
|---|---|---|---|
--card |
oklch(1 0 0) |
#ffffff |
Card/panel background |
--card-foreground |
oklch(0.208 0.042 265) |
#1e293b |
Card text |
--popover |
oklch(1 0 0) |
#ffffff |
Popover/dropdown background |
--popover-foreground |
oklch(0.208 0.042 265) |
#1e293b |
Popover text |
| Token | OKLCH Value | Hex (approx) | Usage |
|---|---|---|---|
--sidebar |
oklch(1 0 0) |
#ffffff |
Sidebar background (white) |
--sidebar-foreground |
oklch(0.554 0.022 257) |
#64748b |
Sidebar text (slate-500) |
--sidebar-primary |
oklch(0.546 0.245 262) |
#2563eb |
Active nav item (blue-600) |
--sidebar-primary-foreground |
oklch(1 0 0) |
#ffffff |
Text on active nav |
--sidebar-accent |
oklch(0.968 0.003 265) |
#f1f5f9 |
Hover nav item (slate-100) |
--sidebar-accent-foreground |
oklch(0.208 0.042 265) |
#1e293b |
Hover text |
--sidebar-border |
oklch(0.929 0.013 255) |
#e2e8f0 |
Sidebar border (slate-200) |
--sidebar-ring |
oklch(0.546 0.245 262) |
#2563eb |
Sidebar focus ring |
| Token | OKLCH Value | Hex (approx) | Usage |
|---|---|---|---|
--chart-1 |
oklch(0.546 0.245 262) |
#2563eb |
Blue-600 (primary series) |
--chart-2 |
oklch(0.520 0.155 175) |
#0891b2 |
Cyan-600 (secondary series) |
--chart-3 |
oklch(0.532 0.157 131) |
#059669 |
Emerald-600 (positive/success) |
--chart-4 |
oklch(0.541 0.281 293) |
#7c3aed |
Violet-600 (accent series) |
--chart-5 |
oklch(0.795 0.184 86) |
#eab308 |
Yellow-500 (warning series) |
Semantic Colors (recommended tokens):
| Token | OKLCH Value | Hex (approx) | Usage |
|---|---|---|---|
--success |
oklch(0.532 0.157 131) |
#059669 |
Success, CTA "Ban hành" (emerald-600) |
--success-foreground |
oklch(1 0 0) |
#ffffff |
Text on success |
--warning |
oklch(0.795 0.184 86) |
#eab308 |
Warning, "Review" status (yellow-500) |
--warning-foreground |
oklch(0.344 0.062 58) |
#713f12 |
Text on warning (yellow-900) |
--info |
oklch(0.520 0.155 175) |
#0891b2 |
Info, statistics (cyan-600) |
--info-foreground |
oklch(1 0 0) |
#ffffff |
Text on info |
Dark Mode (.dark)¶
Lưu ý: Tất cả token dark mode sử dụng OKLCH value đầy đủ — không dùng opacity shortcut (
/ 15%) để đảm bảo contrast nhất quán và dễ debug.
| Token | OKLCH Value | Hex (approx) | Notes |
|---|---|---|---|
--background |
oklch(0.129 0.042 265) |
#0f172a |
slate-900 |
--foreground |
oklch(0.929 0.013 255) |
#e2e8f0 |
slate-200 |
--primary |
oklch(0.623 0.214 259) |
#3b82f6 |
blue-500 (lighter for dark bg) |
--primary-foreground |
oklch(0.985 0.002 247) |
#eff6ff |
blue-50 |
--secondary |
oklch(0.208 0.042 265) |
#1e293b |
slate-800 |
--secondary-foreground |
oklch(0.929 0.013 255) |
#e2e8f0 |
slate-200 |
--muted |
oklch(0.208 0.042 265) |
#1e293b |
slate-800 |
--muted-foreground |
oklch(0.704 0.015 261) |
#94a3b8 |
slate-400 |
--accent |
oklch(0.279 0.041 260) |
#334155 |
slate-700 |
--accent-foreground |
oklch(0.929 0.013 255) |
#e2e8f0 |
slate-200 |
--destructive |
oklch(0.637 0.237 25) |
#ef4444 |
red-500 |
--destructive-foreground |
oklch(0.985 0.002 15) |
#fef2f2 |
|
--border |
oklch(0.279 0.041 260) |
#334155 |
slate-700 |
--input |
oklch(0.279 0.041 260) |
#334155 |
✅ slate-700 (full OKLCH — không dùng opacity) |
--ring |
oklch(0.623 0.214 259) |
#3b82f6 |
blue-500 |
--card |
oklch(0.208 0.042 265) |
#1e293b |
slate-800 |
--card-foreground |
oklch(0.929 0.013 255) |
#e2e8f0 |
slate-200 |
--popover |
oklch(0.208 0.042 265) |
#1e293b |
slate-800 |
--popover-foreground |
oklch(0.929 0.013 255) |
#e2e8f0 |
slate-200 |
--sidebar |
oklch(0.208 0.042 265) |
#1e293b |
slate-800 |
--sidebar-foreground |
oklch(0.704 0.015 261) |
#94a3b8 |
slate-400 |
--sidebar-primary |
oklch(0.623 0.214 259) |
#3b82f6 |
blue-500 |
--sidebar-primary-foreground |
oklch(1 0 0) |
#ffffff |
|
--sidebar-border |
oklch(0.279 0.041 260) |
#334155 |
✅ slate-700 (full OKLCH — không dùng opacity) |
--success |
oklch(0.596 0.145 163) |
#10b981 |
emerald-500 |
--warning |
oklch(0.852 0.199 91) |
#facc15 |
yellow-400 |
--info |
oklch(0.580 0.137 195) |
#06b6d4 |
cyan-500 |
Color Notes: Government Modern palette — Blue-600 primary conveys trust and authority. Slate neutrals keep the interface calm. Semantic colors (emerald/red/yellow) align with universal status conventions. Dark mode reduces lightness and chroma for comfortable viewing.
Browser compatibility: OKLCH được hỗ trợ bởi tất cả trình duyệt hiện đại (Chrome 111+, Firefox 113+, Safari 15.4+). Nếu dự án phải hỗ trợ trình duyệt cũ (IE, Chrome <111), cần cấu hình build-time fallback hex/RGB.
Typography¶
Chiến lược font: 3-role font stack — mỗi font phục vụ mục đích riêng biệt, có usage rules bắt buộc.
/* UI — Mặc định cho toàn bộ giao diện */
--font-ui: 'Be Vietnam Pro', 'Plus Jakarta Sans', system-ui, sans-serif;
/* Display — Văn bản pháp lý, in ấn (KHÔNG dùng trong app UI thông thường) */
--font-display: 'Lora', Georgia, serif;
/* Mono — ID codes, data values dạng code */
--font-mono: 'JetBrains Mono', 'Consolas', 'Fira Code', monospace;
Usage Rules — BẮT BUỘC: -
--font-ui: Áp dụng mặc định cho toàn bộ UI. Thay thế Inter. ---font-display: CHỈ dùng cho<LegalDocument />,<PrintTemplate />, tiêu đề trang in ấn. KHÔNG dùng trong app UI thông thường. ---font-mono: ID codes (DE001-DM1.1), data values dạng code, code snippets. Với cột số trong bảng: dùngtabular-numsCSS thay vì chuyển sang mono.
Font Setup (next/font):
import { Be_Vietnam_Pro, Plus_Jakarta_Sans, JetBrains_Mono, Lora } from 'next/font/google'
const beVietnamPro = Be_Vietnam_Pro({
subsets: ['latin', 'vietnamese'],
weight: ['400', '500', '600', '700', '800'],
variable: '--font-ui',
display: 'swap',
})
// Fallback / progressive enhancement
const plusJakartaSans = Plus_Jakarta_Sans({
subsets: ['latin'],
weight: ['400', '500', '600', '700', '800'],
variable: '--font-ui-fallback',
display: 'swap',
})
// Display: chỉ load khi có LegalDocument / PrintTemplate
const lora = Lora({
subsets: ['latin', 'vietnamese'],
weight: ['400', '500', '600', '700'],
variable: '--font-display',
display: 'swap',
})
// Mono: ID codes, data cells
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
weight: ['400', '700'],
variable: '--font-mono',
display: 'swap',
})
Lưu ý Windows ClearType: Test Be Vietnam Pro và Plus Jakarta Sans trên Windows 10 Chrome/Edge trước khi deploy — đây là môi trường thực tế của phần lớn cán bộ nhà nước Việt Nam.
Font Scale:
| Element | Font | Weight | Size (desktop) | Size (mobile) | Tailwind | Notes |
|---|---|---|---|---|---|---|
| H1 (page title) | UI | 800 (extrabold) | 30px | 24px | text-3xl font-extrabold tracking-tight leading-tight |
✅ leading-tight bổ sung cho dấu tiếng Việt |
| H2 (section title) | UI | 700 (bold) | 24px | 20px | text-2xl font-bold leading-snug |
✅ leading-snug bổ sung |
| H3 (subsection) | UI | 700 (bold) | 20px | 18px | text-xl font-bold leading-snug |
|
| H4 (group label) | UI | 600 (semibold) | 18px | 16px | text-lg font-semibold |
|
| Body | UI | 500 (medium) | 14px | 14px | text-sm font-medium |
Default text |
| Body light | UI | 400 (regular) | 14px | 14px | text-sm |
Descriptions |
| Caption | UI | 700 (bold) | 11px | 11px | text-[11px] font-bold uppercase tracking-widest |
KPI labels |
| KPI number | UI | 900 (black) | 36px | 28px | text-4xl font-black tabular-nums |
✅ tabular-nums cho số liệu |
| Table numbers | UI | 500 (medium) | 14px | 14px | text-sm font-medium tabular-nums |
✅ tabular-nums thay vì font-mono |
| Mono/code | Mono | 700 (bold) | 10px | 10px | font-mono text-[10px] font-bold |
ID codes: DE001-DM1.1 |
| Button | UI | 700 (bold) | 14px | 14px | text-sm font-bold |
Heavier than standard |
| Nav item | UI | 600 (semibold) | 14px | 14px | text-sm font-semibold |
Sidebar nav |
| Legal/Print heading | Display | 700 (bold) | context | context | font-display font-bold |
Chỉ trong LegalDocument |
Về
tracking-tighttiếng Việt: H1/H2 dùngtracking-tightkết hợp font-weight 800/700 có thể làm dấu tiếng Việt phức tạp (ề, ộ, ướ) bị chật.leading-tight(1.25) vàleading-snug(1.375) đã được bổ sung để bù trừ — test kỹ với nội dung tiếng Việt thực tế trước khi deploy.
Spacing Variables¶
| Token | Value | Tailwind | Usage |
|---|---|---|---|
--space-xs |
4px | gap-1 / p-1 |
Tight gaps, icon padding |
--space-sm |
8px | gap-2 / p-2 |
Inline spacing, small gaps |
--space-md |
16px | gap-4 / p-4 |
Standard padding |
--space-lg |
24px | gap-6 / p-6 |
Card internal padding |
--space-xl |
32px | gap-8 / p-8 |
Section padding inside large cards |
--space-2xl |
40px | gap-10 / p-10 |
Content area padding |
Shadow & Borders¶
| Token | Value | Usage |
|---|---|---|
--radius-sm |
0.5rem (8px) |
Badges, tags — rounded-lg |
--radius-md |
1rem (16px) |
Buttons, inputs — rounded-2xl |
--radius-lg |
1.5rem (24px) |
Cards, modals — rounded-3xl |
--radius-full |
9999px |
Avatars, pills, search input — rounded-full |
--shadow-sm |
0 1px 2px 0 rgb(0 0 0 / 0.05) |
Default cards — shadow-sm |
--shadow-md |
0 4px 6px -1px rgb(0 0 0 / 0.1) |
Hover states, dropdowns — shadow-md |
--shadow-lg |
0 10px 15px -3px rgb(0 0 0 / 0.1) |
Modals, active buttons — shadow-lg |
--shadow-xl |
0 20px 25px -5px rgb(0 0 0 / 0.1) |
Floating elements — shadow-xl |
✅
--radius-xl(2rem) đã được xóa — không sử dụng trong bất kỳ component nào. Dùngrounded-3xl(--radius-lg) cho tất cả card lớn.
Animation & Transition Tokens¶
/* Transition speed tokens */
--transition-fast: 150ms; /* Hover states, button press */
--transition-normal: 200ms; /* Focus rings, color change */
--transition-slow: 300ms; /* Theme switch, panel expand */
Quy tắc sử dụng:
| Context | Class | Duration |
|---|---|---|
| Button hover/active | transition-all duration-150 |
fast |
| Input focus | transition-all duration-200 |
normal |
| Card hover shadow | transition-all duration-200 |
normal |
| Theme switch (color) | transition-colors duration-300 |
slow |
| Modal open/close | data-[state=open]:animate-in data-[state=closed]:animate-out |
tailwindcss-animate |
| Sidebar expand/collapse | transition-all duration-300 |
slow |
| Toast slide-in | slide-in-from-top-2 fade-in duration-200 |
tailwindcss-animate |
| Skeleton shimmer | animate-pulse |
built-in |
| Accordion slide | data-[state=open]:animate-in data-[state=closed]:animate-out slide-in-from-top-1 |
tailwindcss-animate |
// tailwind.config.ts — kích hoạt tailwindcss-animate
import animate from 'tailwindcss-animate'
export default { plugins: [animate] }
/* prefers-reduced-motion — BẮT BUỘC */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
shadcn/ui Integration¶
components.json:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
Override defaults — áp dụng GovData visual language:
// components/ui/button.tsx — override shadcn default
// Đổi rounded-md → rounded-2xl, thêm Soft variant
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-2xl font-bold text-sm transition-all cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-lg hover:bg-blue-700",
secondary: "bg-secondary text-secondary-foreground hover:bg-accent",
outline: "border border-border bg-transparent text-foreground hover:bg-accent",
ghost: "bg-transparent text-foreground hover:bg-accent",
destructive: "bg-destructive text-destructive-foreground shadow-lg hover:bg-red-700",
success: "bg-success text-success-foreground shadow-lg hover:bg-emerald-700",
soft: "bg-primary/10 text-primary hover:bg-primary/20", // ✅ Soft variant mới
link: "text-primary underline-offset-4 hover:underline font-bold text-xs",
},
},
}
)
Soft variant dùng cho secondary CTA trong gov context: "Xem chi tiết", "Đề xuất", "Lịch sử". Background nhạt, không cạnh tranh với primary CTA.
CSS variables mapping sang shadcn theme — đảm bảo nhất quán:
/* globals.css */
:root {
--background: oklch(0.984 0.003 247);
--foreground: oklch(0.208 0.042 265);
/* ... tất cả tokens như đã định nghĩa ở trên ... */
/* Font variables */
--font-sans: var(--font-ui); /* shadcn dùng --font-sans */
--font-mono: var(--font-mono);
}
App Layout Pattern¶
Pattern: Government Modern — Sidebar (w-72) + Header (h-20) + Content
+---------------------------------------------------------------+
| Sidebar (w-72, fixed) | Header (h-20, sticky top, z-40) |
| +--------------------------------------+
| [Logo: GovData] | Đơn vị công tác: ... [🔔] [👤] |
| ───────────── +--------------------------------------+
| ▶ Tổng quan | |
| Dữ liệu định vị | Content Area |
| Khám phá DL | (p-10, max-w-6xl mx-auto) |
| Bản đồ hiện trạng | |
| Dữ liệu tương lai | bg-slate-50 (light) |
| Tra cứu Từ điển | bg-slate-900 (dark) |
| Báo cáo | |
| ───────────── | |
| [Vai trò hiện tại] | |
| [▼ Manager] | |
+---------------------------------------------------------------+
Header¶
/* h-20 (80px), sticky, border-bottom, flex between */
/* ✅ z-40 — phải cao hơn sticky column trong Matrix (z-10) để tránh overlap */
h-20 bg-white dark:bg-slate-800 border-b border-border
flex items-center justify-between px-10 shrink-0 sticky top-0 z-40
/* Left: Đơn vị công tác */
flex flex-col
span.text-xs.text-muted-foreground.font-semibold.uppercase /* label */
span.font-bold.text-foreground /* unit name */
/* Right: theme toggle + notifications + avatar + logout */
flex items-center gap-6
/* Theme toggle button — xem Theme Toggle section */
Sidebar¶
/* Expanded: w-72, white bg, border-right, flex-col */
w-72 bg-sidebar border-r border-sidebar-border
flex flex-col shrink-0 shadow-sm z-20
/* Logo section (top) */
p-6 border-b border-border
flex items-center gap-3
/* Logo icon: gradient blue, rounded-xl */
w-10 h-10 bg-gradient-to-br from-blue-600 to-blue-800
rounded-xl flex items-center justify-center
text-white font-black text-xl shadow-lg
/* Navigation (middle, scrollable) */
flex-1 p-4 space-y-2 overflow-y-auto
/* Nav item (active) */
w-full flex items-center gap-3 px-4 py-3 rounded-xl
font-semibold bg-sidebar-primary text-sidebar-primary-foreground shadow
/* Nav item (inactive) */
w-full flex items-center gap-3 px-4 py-3 rounded-xl
font-semibold text-sidebar-foreground hover:bg-sidebar-accent transition-all
/* Role switcher (bottom) */
p-6 border-t border-border
bg-blue-50 dark:bg-blue-950 border border-blue-100 dark:border-blue-900
rounded-2xl p-4
/* Label */
text-[10px] font-bold text-blue-400 uppercase tracking-tighter
/* Select */
text-sm font-bold text-blue-700 dark:text-blue-300
/* Collapsed (w-16, icon-only + tooltips) — future */
w-16 /* icons centered, text hidden, tooltips on hover */
/* Mobile (Sheet overlay) */
/* Trigger: hamburger button in header (visible on < lg) */
/* Content: same as expanded sidebar, inside <Sheet /> */
Content Area¶
/* Scrollable main content */
flex-1 flex flex-col overflow-hidden
/* Inner content */
flex-1 overflow-y-auto p-10 bg-background
max-w-6xl mx-auto /* constrained width */
space-y-8 /* section spacing */
Next.js App Router — File Structure Templates¶
// app/layout.tsx — Root layout với font + theme provider
import { beVietnamPro, lora, jetbrainsMono } from '@/lib/fonts'
import { ThemeProvider } from '@/components/theme-provider'
export default function RootLayout({ children }) {
return (
<html lang="vi" suppressHydrationWarning>
<body className={`${beVietnamPro.variable} ${lora.variable} ${jetbrainsMono.variable} font-ui`}>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
)
}
// app/(dashboard)/layout.tsx — Dashboard layout với sidebar + header
export default function DashboardLayout({ children }) {
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<div className="flex flex-col flex-1 overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-10 bg-background">
<div className="max-w-6xl mx-auto space-y-8">
{children}
</div>
</main>
</div>
</div>
)
}
// app/(dashboard)/[page]/loading.tsx — Skeleton loading state
export default function Loading() {
return (
<div className="space-y-8">
<div className="animate-pulse bg-muted rounded-3xl h-32" />
<div className="animate-pulse bg-muted rounded-3xl h-64" />
</div>
)
}
// app/(dashboard)/[page]/error.tsx — Error boundary
'use client'
export default function Error({ error, reset }) {
return (
<div className="bg-destructive/5 border border-destructive/20 rounded-2xl p-8 text-center">
<AlertCircle className="h-10 w-10 text-destructive mx-auto mb-4" />
<h2 className="text-lg font-bold mb-2">Đã xảy ra lỗi</h2>
<p className="text-sm text-muted-foreground mb-4">{error.message}</p>
<Button onClick={reset}>Thử lại</Button>
</div>
)
}
Theme Toggle¶
// components/theme-toggle.tsx — dùng next-themes
'use client'
import { useTheme } from 'next-themes'
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
aria-label="Chuyển giao diện sáng/tối"
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
)
}
Đặt
<ThemeProvider attribute="class" defaultTheme="light">trong root layout. DùngsuppressHydrationWarningtrên<html>để tránh FOUC.
Responsive¶
| Breakpoint | Layout |
|---|---|
| Mobile (< 768px) | Sidebar hidden → hamburger → Sheet. Header simplified. Content p-4. |
| Tablet (768-1023px) | Sidebar collapsed (w-16, icon-only). Content p-6. |
| Desktop (>= 1024px) | Sidebar expanded (w-72) + Header (h-20) + Content (p-10). Full layout. |
| Large Desktop (>= 1280px) | Same as desktop, max-w-6xl keeps content centered. |
Component Specs (Tailwind CSS Classes)¶
Component Status¶
| Component | Status | Notes |
|---|---|---|
| Button (all variants incl. Soft) | ✅ Ready | |
| Card (all variants) | ✅ Ready | |
| Input, Textarea, Select | ✅ Ready | |
| Table, Matrix Table, Tree Table | ✅ Ready | Xem Performance section |
| Form (layout + validation) | ✅ Ready | react-hook-form + zod |
| Dialog, AlertDialog | ✅ Ready | 3 size variants |
| Tooltip | ✅ Ready | |
| Dropdown Menu | ✅ Ready | |
| Checkbox, Radio, Switch | ✅ Ready | |
| Navigation (Breadcrumb, Tabs, Stepper) | ✅ Ready | |
| Badge, Avatar, Skeleton, Empty State | ✅ Ready | |
| Toast (Sonner) | ✅ Ready | |
| Charts (Recharts) | ✅ Ready | Xem Charts section |
| Theme Toggle | ✅ Ready | |
| Error Boundary (error.tsx) | ✅ Ready |
Buttons¶
/* Base (all buttons) */
inline-flex items-center justify-center gap-2 rounded-2xl
font-bold text-sm transition-all duration-150 cursor-pointer
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
/* Primary */
bg-primary text-primary-foreground shadow-lg
hover:bg-blue-700 dark:hover:bg-blue-400
/* Secondary */
bg-secondary text-secondary-foreground
hover:bg-accent
/* Outline */
border border-border bg-transparent text-foreground
hover:bg-accent
/* Ghost */
bg-transparent text-foreground
hover:bg-accent
/* Destructive */
bg-destructive text-destructive-foreground shadow-lg
hover:bg-red-700 dark:hover:bg-red-400
/* Success/CTA ("Ban hành", "Phê duyệt") */
bg-success text-success-foreground shadow-lg
hover:bg-emerald-700 dark:hover:bg-emerald-400
/* Soft — secondary CTA, không cạnh tranh với Primary */
/* Dùng cho: "Xem chi tiết", "Đề xuất", "Lịch sử" */
bg-primary/10 text-primary hover:bg-primary/20
dark:bg-primary/20 dark:hover:bg-primary/30
/* Link */
text-primary underline-offset-4 hover:underline font-bold text-xs
/* Loading state */
opacity-70 pointer-events-none
/* Prepend: <Loader2 className="h-4 w-4 animate-spin" /> */
/* Rate limit / disabled with countdown */
/* Dùng khi API trả về 429 Too Many Requests */
opacity-50 cursor-not-allowed
/* Prepend: countdown text "Thử lại sau Xs" */
/* Sizes */
/* sm */ h-9 px-4 text-xs
/* default */ h-11 px-6 py-2.5
/* lg */ h-12 px-8 text-base
/* icon */ h-10 w-10 p-0
Cards¶
/* Default Card */
bg-card border border-border rounded-3xl shadow-sm p-6
/* Interactive Card (hover effect, clickable) */
bg-card border border-border rounded-3xl shadow-sm p-6
hover:shadow-xl hover:border-primary/20 transition-all duration-200 cursor-pointer
/* Large Interactive Card */
bg-card border border-border rounded-3xl shadow-sm p-8
hover:shadow-xl hover:border-primary/20 transition-all duration-200 cursor-pointer
flex items-start gap-6 group
/* Stat/KPI Card */
bg-card border border-border rounded-3xl shadow-sm p-6
/* Label */ text-[11px] font-bold text-muted-foreground uppercase tracking-widest
/* Value */ text-4xl font-black text-primary mt-2 tabular-nums
/* Desc */ text-xs text-muted-foreground mt-2 font-medium
/* Alert Card (e.g. Data Owner confirmation) */
bg-primary rounded-3xl text-primary-foreground shadow-xl p-6
flex justify-between items-center
/* Error Card */
bg-destructive/5 border border-destructive/20 rounded-2xl p-6
/* Icon + message + retry button */
Inputs¶
/* Text Input */
w-full px-4 py-3 rounded-2xl border border-input bg-background
text-sm font-medium outline-none
focus:ring-2 focus:ring-ring focus:border-primary transition-all duration-200
placeholder:text-muted-foreground
/* Large Search Input (Tra cứu từ điển) */
w-full px-10 py-6 pl-16 rounded-full
border border-border shadow-2xl shadow-primary/10
text-xl font-medium outline-none
focus:ring-4 focus:ring-blue-100 dark:focus:ring-blue-900
focus:border-primary transition-all duration-200
/* Select — shadcn <Select /> với rounded-2xl trigger */
/* Textarea */
w-full px-4 py-3 rounded-2xl border border-input bg-background
min-h-[100px] text-sm font-medium
focus:ring-2 focus:ring-ring focus:border-primary
/* Checkbox — shadcn <Checkbox /> */
/* Radio — shadcn <RadioGroup /> + <RadioGroupItem /> */
/* Switch — shadcn <Switch /> */
/* Tất cả dùng --primary color cho checked state */
/* Error state (any input) */
border-destructive focus:ring-destructive/20
/* Disabled state */
opacity-50 cursor-not-allowed bg-muted
Tooltip¶
/* shadcn <Tooltip /> — cấu hình chuẩn */
/* Provider: <TooltipProvider delayDuration={200}> */
/* Trigger: bất kỳ element nào */
/* Content: */
bg-foreground text-background text-xs font-medium
px-3 py-1.5 rounded-lg shadow-md
max-w-[240px] text-center
/* Usage bắt buộc cho disabled buttons do data state */
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span> {/* wrap trong span để tooltip hoạt động với disabled button */}
<Button disabled>Phê duyệt</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>DE đã PUBLISHED, không thể sửa</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
Lưu ý: Tooltip không hoạt động trực tiếp trên
<button disabled>— phải wrap trong<span>.
Dropdown Menu¶
/* shadcn <DropdownMenu /> — general pattern */
/* Trigger: Button hoặc bất kỳ element nào */
/* Content: */
bg-popover border border-border rounded-2xl shadow-lg p-1
min-w-[160px]
/* Item: */
px-3 py-2 text-sm font-medium rounded-xl cursor-pointer
hover:bg-accent transition-colors duration-150
/* Separator: */
h-px bg-border my-1
/* Label: */
px-3 py-1.5 text-[11px] font-bold text-muted-foreground uppercase tracking-widest
/* Export Dropdown (shared pattern — tất cả màn hình report) */
DropdownMenu
Trigger: Button(variant="outline") → Download + "Export" + ChevronDown
Content:
DropdownMenuLabel "Chọn định dạng"
DropdownMenuItem FileSpreadsheet + "Excel (.xlsx)"
DropdownMenuItem FileText + "CSV (.csv)"
DropdownMenuItem File + "PDF (.pdf)"
DropdownMenuSeparator
DropdownMenuLabel "Phạm vi"
DropdownMenuRadioGroup "Trang hiện tại" | "Tất cả dữ liệu"
Tables¶
/* Table container */
bg-card rounded-3xl border border-border overflow-hidden shadow-sm
/* Table header row */
bg-muted border-b border-border
/* Header cell */
px-8 py-5 font-bold text-muted-foreground uppercase tracking-widest text-[11px]
/* Table body row */
hover:bg-accent/50 transition-colors duration-150
/* Table cell */
px-8 py-4 text-sm font-medium
/* Number cells — tabular-nums thay vì font-mono */
px-8 py-4 text-sm font-medium tabular-nums text-right
/* Divider */
divide-y divide-border
/* Sortable header */
cursor-pointer select-none hover:text-foreground transition-colors
flex items-center gap-1
/* Sort icon: ChevronUp/Down h-3 w-3 */
/* Matrix table (Bản đồ hiện trạng — special) */
/* Sticky first column */
/* ✅ z-10 — Header là z-40, không conflict */
sticky left-0 bg-card z-10 border-r border-border
/* Dot indicator */
w-4 h-4 rounded-[4px] mx-auto
/* Color per state — xem Matrix View section */
/* Color-coded cells */
bg-green-50 dark:bg-green-950 /* CONFIRMED */
bg-red-50 dark:bg-red-950 /* DISPUTED */
/* Tree table (Anchored Data) */
/* Domain row: bold, has expand icon */
bg-muted/50 font-black text-foreground flex items-center gap-2
/* Sub Domain row: indented, italic */
pl-14 font-bold text-muted-foreground italic
/* Data Element row: deep indent, bullet */
pl-20 font-medium text-muted-foreground
Forms¶
Pattern: react-hook-form + zod — bắt buộc cho tất cả form có validation.
// Form pattern chuẩn
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const schema = z.object({
name: z.string().min(1, 'Tên không được để trống'),
description: z.string().optional(),
})
export function MyForm() {
const form = useForm({ resolver: zodResolver(schema) })
// Confirm before leave nếu form đã dirty
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (form.formState.isDirty) e.preventDefault()
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [form.formState.isDirty])
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<FormField name="name" render={({ field }) => (
<FormItem>
<FormLabel>Tên <span className="text-destructive">*</span></FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage /> {/* inline error, real-time */}
</FormItem>
)} />
</div>
<div className="flex justify-end gap-3 mt-6">
<Button type="button" variant="outline">Hủy</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
Lưu
</Button>
</div>
</form>
</Form>
)
}
/* Form layout */
/* Desktop: 2 cột | Mobile: 1 cột */
grid grid-cols-1 lg:grid-cols-2 gap-6
/* Label + Input group */
flex flex-col gap-2
/* Label */
text-sm font-semibold text-foreground
/* Required indicator */
text-destructive ml-1 /* asterisk */
/* Error message — inline, gần field */
text-xs text-destructive font-medium mt-1
/* Help text */
text-xs text-muted-foreground mt-1
/* Submit button — loading state */
opacity-70 pointer-events-none /* khi isSubmitting = true */
Quy tắc form:
- Validation: inline real-time (onChange mode), error message ngay dưới field
- Submit state: spinner + disable button trong lúc submit
- Dirty check: confirm dialog nếu user rời trang khi form đã thay đổi
- Error handling: try/catch trong onSubmit → toast error nếu API fail
Modals/Dialogs¶
/* Dialog sizes */
max-w-lg /* sm — form đơn giản, confirm */
max-w-2xl /* md — form 2 cột, detail panel */
max-w-4xl /* lg — complex form, preview */
/* Dialog wrapper */
bg-card rounded-3xl border border-border shadow-xl p-0
/* Animation — tailwindcss-animate */
data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95
data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95
duration-200
/* Dialog Header */
px-8 py-6 border-b border-border
/* Title */ text-xl font-bold
/* Description */ text-sm text-muted-foreground mt-1
/* Dialog Body */
px-8 py-6
/* Dialog Footer */
px-8 py-4 border-t border-border flex justify-end gap-3
/* AlertDialog — CHỈ dùng cho hành động destructive/irreversible */
/* Thêm: warning icon + mô tả hậu quả rõ ràng */
/* Destructive action button: variant="destructive" */
/* Cancel button: variant="outline" */
/* Irreversible actions: delete user, reject mapping, ban hành */
/* Sheet (mobile sidebar, detail panels) */
/* Slides from right, full height */
/* Width: w-full sm:max-w-lg */
Navigation¶
/* Breadcrumb */
flex items-center gap-2 text-sm text-muted-foreground
/* Active item */ font-semibold text-foreground
/* Separator */ text-muted-foreground/50 mx-1
/* Tabs */
border-b border-border
/* Tab trigger */
px-4 py-2 text-sm font-semibold text-muted-foreground
border-b-2 border-transparent transition-all duration-150
hover:text-foreground
/* Tab trigger (active) */
text-primary border-primary
/* Workflow Stepper — BPF lifecycle */
/* Màn hình: SCR-DISC-10, SCR-MAP-20, SCR-MAP-10 (Approver), SCR-PLAN-10 */
flex items-center gap-2 py-4 px-6 bg-muted/30 rounded-2xl
/* Step completed */
div.w-7.h-7.bg-success.text-white.rounded-full + Check.h-4.w-4
span.text-sm.font-semibold.text-success
/* Step current */
div.w-7.h-7.bg-primary.text-white.rounded-full.ring-4.ring-primary/20
span.text-xs.font-black /* step number */
span.text-sm.font-semibold.text-primary
/* Step upcoming */
div.w-7.h-7.bg-muted.text-muted-foreground.rounded-full
span.text-xs.font-semibold
span.text-sm.font-medium.text-muted-foreground
/* Connectors */
div.h-[2px].w-8.bg-success /* completed → completed */
div.h-[2px].w-8.bg-border /* current → upcoming */
/* Steps: 1.Discovery → 2.Matching → 3.Rà soát → 4.Xác nhận → 5.Phê duyệt → 6.Ban hành */
Data Display¶
/* Badge — status indicators */
inline-flex items-center px-3 py-1 text-[10px] font-black uppercase rounded-lg border
/* Badge variants */
/* Published */ bg-green-100 text-green-700 border-green-200
dark:bg-green-950 dark:text-green-400 dark:border-green-800
/* Review */ bg-yellow-100 text-yellow-700 border-yellow-200
dark:bg-yellow-950 dark:text-yellow-400 dark:border-yellow-800
/* Draft */ bg-slate-100 text-slate-500 border-slate-200
dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700
/* AI Matched */ bg-blue-100 text-blue-700 border-blue-200
dark:bg-blue-950 dark:text-blue-400 dark:border-blue-800
/* DISPUTED */ bg-red-100 text-red-700 border-red-200
dark:bg-red-950 dark:text-red-400 dark:border-red-800
/* CONFIRMED */ bg-green-100 text-green-700 border-green-200
/* Avatar */
w-10 h-10 bg-muted rounded-full flex items-center justify-center
font-bold text-muted-foreground
/* next/image cho avatar từ server — không dùng <img> */
/* Skeleton — cho page load (bukan inline action) */
animate-pulse bg-muted rounded-2xl
/* Inline action spinner: <Loader2 className="h-4 w-4 animate-spin" /> */
/* Empty state */
flex flex-col items-center justify-center py-16 text-center
/* Icon */ h-12 w-12 text-muted-foreground/30
/* Title */ text-lg font-bold text-muted-foreground mt-4
/* Description */ text-sm text-muted-foreground mt-2
/* Action */ mt-4 /* Primary button — xem Role-Specific Empty States */
/* Error state — component level */
bg-destructive/5 border border-destructive/20 rounded-2xl p-6
/* Icon + message + retry button */
/* Global error: dùng toast (sonner) */
Charts¶
// Chart Container — wrapper chuẩn cho tất cả charts
function ChartContainer({ title, children, onExport }) {
return (
<div className="bg-card border border-border rounded-3xl shadow-sm p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold">{title}</h3>
{onExport && (
<Button variant="outline" size="sm" onClick={onExport}>
<Download className="h-4 w-4 mr-2" /> Xuất
</Button>
)}
</div>
{children}
</div>
)
}
// Recharts — sử dụng CSS variables cho màu series
// Mapping: var(--chart-1) → Blue, var(--chart-2) → Cyan, etc.
import { LineChart, Line, BarChart, Bar, XAxis, YAxis,
CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
function KPILineChart({ data }) {
return (
<ChartContainer title="Tiến độ xác nhận">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="month" tick={{ fontSize: 11, fontWeight: 700,
fill: 'var(--muted-foreground)', fontFamily: 'var(--font-ui)' }} />
<YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} />
<Tooltip
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)',
borderRadius: '12px', fontFamily: 'var(--font-ui)' }}
/>
<Line dataKey="confirmed" stroke="var(--chart-1)" strokeWidth={2} dot={false} />
<Line dataKey="disputed" stroke="var(--chart-5)" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</ChartContainer>
)
}
// Heatmap (Maturity Score): green-600 / yellow-500 / red-600 / slate-200
// Sử dụng --success / --warning / --destructive / --muted
Feedback & Error Handling¶
/* Toast (Sonner) */
/* Setup: <Toaster position="top-right" richColors duration={3000} /> */
/* Slide-in animation: slide-in-from-top-2 fade-in duration-200 */
/* Variants: */
toast.success("Lưu thành công") /* green */
toast.error("Lỗi khi lưu dữ liệu") /* red */
toast.warning("Cảnh báo: X DEs chưa xác nhận") /* yellow */
toast.info("Đang xử lý...") /* blue */
/* Progress bar */
h-3 bg-muted rounded-full overflow-hidden
/* Fill */ h-full bg-primary rounded-full
/* Notification item */
flex gap-4 p-4 bg-primary/5 rounded-2xl border border-primary/10
/* Dot */ w-2 h-2 bg-primary rounded-full mt-2 animate-pulse
/* Content */ text-sm
Global Error Handling Pattern:
// Server Action error pattern
async function saveData(data) {
try {
await api.save(data)
toast.success("Lưu thành công")
} catch (error) {
if (error.status === 429) {
// Rate limit: disable button + countdown
toast.error(`Quá nhiều yêu cầu. Thử lại sau ${error.retryAfter}s`)
} else if (error.status >= 500) {
toast.error("Lỗi hệ thống. Vui lòng thử lại.")
} else {
toast.error(error.message || "Đã xảy ra lỗi")
}
}
}
// Batch action partial failure
async function batchConfirm(ids) {
const results = await api.batchConfirm(ids)
const failed = results.filter(r => !r.success)
if (failed.length === 0) {
toast.success(`Đã xác nhận ${ids.length} mục`)
} else {
toast.warning(`Xác nhận ${ids.length - failed.length}/${ids.length} mục. ${failed.length} mục thất bại.`)
// Hiển thị danh sách lỗi trong Dialog
}
}
Authorization & Permission-Based UI¶
GovData uses PBAC (hybrid). Access depends on role, organization unit, AND data attributes (status, ownership). UI must reflect all 3 dimensions — not just role.
3-Layer Permission Model¶
Layer 1: ROLE → What type of actions can I perform? (menu, buttons)
Layer 2: UNIT SCOPE → What data can I see? (Data Owner: own unit only)
Layer 3: DATA STATE → Can I act on THIS specific record? (status, ownership, guards)
UI Impact Per Layer¶
| Layer | UI Pattern | Example |
|---|---|---|
| Role (RBAC) | Hide/show menu items, sections, action buttons | Manager sees "Sửa", Staff doesn't |
| Unit scope (ABAC) | Filter data, scope entire view to user's unit | Data Owner sees only own unit's column in matrix |
| Data state (ABAC) | Disable/enable per-record actions, show tooltips | "Xác nhận" button disabled if cell already CONFIRMED |
Permission-Aware UI Strategies¶
| Strategy | When to use | Example |
|---|---|---|
| Hide element | User's ROLE never allows this action | Staff never sees "Sửa" — hide completely |
| Show disabled + tooltip | User's role allows, but DATA STATE prevents | Button disabled: "Không thể sửa DE đã PUBLISHED" |
| Filter data | User can see layout, but UNIT SCOPE limits records | Data Owner sees card list of own unit's fields only |
| Replace entire section | Role implies fundamentally different task on same page | MAP-10: Manager → matrix edit, Data Owner → card review |
| Add role-specific panel | Extra context for one role | Approver → Readiness Panel on MAP-10 |
| Conditional cell actions | Same table, different actions per cell based on ownership | Data Owner: "Đánh dấu tranh chấp" only on own unit cells |
Data-Level Permission Examples¶
| Screen | Action | Role Check | Unit Check | Data State Check | UI when denied |
|---|---|---|---|---|---|
| MAP-10 | Click cell to edit | Manager only | — | Cell ≠ CONFIRMED | Disabled + tooltip |
| MAP-10 | Confirm mapping | Data Owner | Own unit cells only | Cell status = pending | Hidden for other units' cells |
| MAP-10 | Mark DISPUTED | Data Owner | Own unit cells only | Cell ≠ already DISPUTED | Hidden for other units |
| MAP-10 | Mark DISPUTED | Manager | Any cell | Cell ≠ already DISPUTED | Disabled if already DISPUTED |
| MAP-43 | Approve Bảng C | Approver | — | >70% confirmed + 0 DISPUTED | Button disabled + guard status display |
| ANCHOR-10 | Edit Domain | Manager | — | Domain has no PUBLISHED DEs | Disabled if has PUBLISHED |
| ANCHOR-10 | Delete Domain | Manager | — | No child Sub Domains | AlertDialog with warning |
| PLAN-21 | Chốt chủ quản | Approver | — | DE has proposed owner | Disabled if no proposal |
| PLAN-31 | Ban hành | Approver | — | All DEs = APPROVED | Disabled + "X DEs chưa APPROVED" |
| DICT-10 | Search | All roles | — | Staff: PUBLISHED only | Staff never sees non-PUBLISHED |
| FUTURE-20 | Approve | Approver | — | Status = DRAFT | Hidden if already APPROVED |
| SYS-10 | Delete user | Admin | — | User ≠ self | "Không thể xóa chính mình" |
Implementation Pattern¶
// Server Component — permission check phía server
const { role, unitId } = await getSession()
const permissions = await getPermissions(role, unitId, resourceId)
// permissions = { canEdit: bool, canConfirm: bool, canApprove: bool, denyReason: string }
{permissions.canEdit && <Button>Sửa</Button>}
{permissions.canConfirm && <Button>Xác nhận</Button>}
{!permissions.canApprove && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button disabled>Phê duyệt</Button>
</span>
</TooltipTrigger>
<TooltipContent>{permissions.denyReason}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
// KHÔNG làm:
// ❌ CSS-only hiding (security risk)
// ❌ Client-side role check cho security-sensitive actions
// ❌ Assume role alone — luôn check data state
Disabled State UX Rules¶
When an action is permission-denied due to data state (not role):
- Show the button — user biết action này tồn tại
- Disable it (
opacity-50 cursor-not-allowed) - Add tooltip explaining WHY ("DE đã PUBLISHED, không thể sửa")
- Never show an error after click — prevent the click entirely
- Guard conditions (e.g., Bảng C approval) → show status panel with checkmarks
Screen × Role Matrix¶
| Screen | Manager | Data Owner | Approver | Staff | Admin |
|---|---|---|---|---|---|
| DASH-10 | Action Items (matching, review, proposals) + KPIs + Progress | Hero confirmation card + deadline + proposals | Action Items (DISPUTED, Bảng C, ownership) + mini heatmap | Search-first hero | Unassigned users + system stats |
| ANCHOR-10 | Full CRUD (add/edit/delete Domain/Sub/DE) | — (no access; proposes via ANCHOR-14) | Read-only tree view (no action buttons, no "Thêm") | — | — |
| ANCHOR-20 | Full detail + "Sửa" button | — | Read-only detail (no "Sửa" button) | — | — |
| ANCHOR-14 | — | Submit proposal form (from DICT or MAP context) | — | — | — |
| ANCHOR-15 | Review proposals + approve/reject | — | — | — | — |
| MAP-10 | Full matrix + cell click edit + matching trigger | Card-based review list (own unit only) + inline confirm/reject + batch confirm + deadline | Readiness panel + read-only matrix + DISPUTED badge + "Phê duyệt Bảng C" button | — | — |
| MAP-20 | Batch controls + AI matching trigger + inline review + batch accept | — | — | — | — |
| MAP-40 | Mark any cell DISPUTED | Mark own unit cells DISPUTED | — | — | — |
| MAP-41/42 | — | — | View DISPUTED list + resolve each (3 options) | — | — |
| MAP-43 | — | — | Approve Bảng C (guard conditions) | — | — |
| DISC-10/11/20 | Full CRUD + upload + matching trigger | — | — | — | — |
| PLAN-10 | — (read-only if accessible) | — | Full control: chốt chủ quản, ban hành | — | — |
| PLAN-20/21 | — | — | Detail + approve/reject ownership | — | — |
| PLAN-31 | — | — | Ban hành AlertDialog (irreversible) | — | — |
| DICT-10/20 | Full search + browse + "Đề xuất DE mới" on no-results | Full search + browse | Full search + browse | Full search + browse (only PUBLISHED data) | — |
| DICT-30/40 | Tree browse + owner info | Tree browse + owner info | Tree browse + owner info | Tree browse + owner info | — |
| FUTURE-10 | CRUD (add/edit/delete) + list | — | Read-only list + "Phê duyệt/Từ chối" buttons | — | — |
| FUTURE-20 | Detail + "Sửa" button | — | Detail + "Phê duyệt/Từ chối" buttons | — | — |
| RPT-10 | All reports + drill-down + export | — | All reports + drill-down + export | — | — |
| RPT-20/30 | Overlap/fragmentation tables + export | — | Same view (no difference) | — | — |
| RPT-40 | Progress tabs (by unit, by domain) + drill-down | — | Same view | — | — |
| RPT-50 | Heatmap + drill-down Sheet + export | — | Same view + highlighted "Sở chưa đạt" rows | — | — |
| SYS-10/11 | — | — | — | — | Full user CRUD |
| SYS-20 | — | — | — | — | View permission matrix |
| SYS-30/31 | — | — | — | — | Org tree CRUD + Import CSV |
| SYS-40 | — | — | — | — | Master Data import + catalogs |
Role-Specific Empty States¶
| Role | Screen | Empty State Message | CTA |
|---|---|---|---|
| Manager | DISC-10 | "Chưa có nguồn metadata. Upload file để bắt đầu." | "Nhập metadata" |
| Manager | MAP-20 | "Chưa chạy matching. Chọn batch và khởi động." | "Khởi động Matching" |
| Data Owner | MAP-10 | "Đơn vị bạn chưa có dữ liệu cần xác nhận." | — (no action needed) |
| Approver | MAP-41 | "Không còn ô tranh chấp." (success state) | "Quay lại Bản đồ" |
| Approver | PLAN-10 | "Bảng C chưa được chốt. Chờ Manager hoàn thành Discovery & Matching." | — |
| Staff | DICT-10 | "Từ điển chưa được ban hành." | — |
| Admin | SYS-10 | "Chưa có người dùng. Thêm người dùng đầu tiên." | "Thêm người dùng" |
Style Guidelines¶
Style: Government Modern Keywords: Clean, spacious, rounded, bold typography, professional, trustworthy, modern government
Key Effects¶
rounded-3xl(24px) on all major surfaces — cards, panels, tablesrounded-2xl(16px) on buttons, inputs, sub-elementsshadow-smon cards (subtle),shadow-lgon primary buttonsshadow-2xl shadow-primary/10on hero search inputbg-gradient-to-br from-blue-600 to-blue-800on logo icon onlyanimate-pulseon notification dot only- Transitions:
transition-all duration-[150-300ms](xem Animation Tokens) tracking-widest+uppercaseon small labels (KPI cards, table headers)tracking-tight+leading-tighton page titles (H1) — leading bắt buộc cho tiếng Việtfont-black(900) +tabular-numsfor KPI numbers
Icon Size Scale¶
| Context | Size | Class |
|---|---|---|
| Navigation (sidebar) | 20×20px | h-5 w-5 |
| Inline (button, table) | 16×16px | h-4 w-4 |
| Empty state | 48×48px | h-12 w-12 |
| Large decorative | 24×24px | h-6 w-6 |
Lucide only — không dùng emoji, không dùng custom SVG inline.
UX Philosophy: "Design for Government Officers"¶
| Principle | Implementation |
|---|---|
| Important first | KPI cards at top of dashboard, pending actions as alerts, DISPUTED count as badge |
| Fewer clicks | Inline confirm/reject on Bản đồ, role switcher without logout, expandable tree table |
| Smart confirmation | Only <AlertDialog /> for: delete user, reject mapping, ban hành (irreversible). Normal create/edit → save + toast |
| Quick save, easy edit | Popup forms save on submit, toast success, close popup. Re-edit by re-opening |
| Progressive disclosure | Tree table collapsed by default. Matrix shows dots, click for detail. Reports drill-down |
| Role-aware UI | Same screen, different actions visible per role. Data Owner sees confirmation alert. Approver sees approval controls |
Enterprise UX Rules¶
| Rule | Requirement |
|---|---|
| Clickable elements | cursor-pointer required on all |
| Icons | Lucide only — no emoji, no custom SVG. Dùng Icon Size Scale ở trên |
| Transitions | duration-150 hover, duration-200 focus, duration-300 theme (xem Animation Tokens) |
| Form validation | react-hook-form + zod, inline real-time, error near field |
| Loading states | Skeleton cho page load; spinner (Loader2 animate-spin) cho inline action |
| Empty states | Illustration-free: icon + message + action button |
| Data tables | Sortable headers, row hover, sticky first column on matrix |
| ID codes | font-mono text-[10px] font-bold text-muted-foreground |
| Number columns | tabular-nums — không dùng font-mono cho số trong bảng |
| Status badges | Uppercase, bold, color-coded + text label (không dùng màu đơn độc) |
| Page titles | Left-aligned, with subtitle if needed. Action buttons top-right |
| Security | DOMPurify/sanitize-html cho dynamic content từ user input; CSRF token trong form |
Anti-Patterns¶
- No emojis as icons — use Lucide SVG icons
- No missing
cursor-pointeron clickable elements - No layout-shifting hovers
- No low contrast text (4.5:1 minimum WCAG AA)
- No instant state changes — always transition (xem Animation Tokens)
- No invisible focus states
- No placeholder-only inputs — always
<Label /> - No unnecessary confirmation dialogs — only for destructive/irreversible actions
- No
rounded-noneorrounded-sm— minimumrounded-lg(8px) on any surface - No thin font weights (300/400) for action labels — minimum
font-semibold(600) - No generic gray buttons for primary actions — always
bg-primaryorbg-success - No
font-monofor number columns in tables — usetabular-numsinstead - No opacity shortcuts in OKLCH dark mode tokens — use full OKLCH values
Domain-Specific Patterns¶
Bản đồ hiện trạng (Matrix View)¶
The most complex screen — Data Element rows × Organization Unit columns.
/* Matrix container */
bg-card rounded-3xl border border-border overflow-hidden shadow-sm
overflow-x-auto
overscroll-behavior-x: contain /* ✅ tránh kích hoạt browser back-swipe trên mobile */
/* First column sticky */
/* ✅ z-10 — Header là z-40, đảm bảo không overlap */
sticky left-0 bg-card z-10 w-48 border-r border-border
/* Cell states (dot + background + text label) */
/* ✅ Color không phải chỉ báo duy nhất — luôn có text label */
DATABASE: dot bg-primary, cell bg-transparent, label "DATABASE"
DATABASE_OVERLAP: dot bg-primary/30, cell bg-transparent, label "OVERLAP"
FRAGMENTED: dot bg-warning, cell bg-transparent, label "FRAGMENTED"
READY: dot bg-success, cell bg-transparent, label "READY"
EMPTY: no dot, cell bg-transparent, label "—"
DISPUTED: dot bg-destructive, cell bg-red-50/bg-red-950, label "DISPUTED"
CONFIRMED: dot bg-success, cell bg-green-50/bg-green-950, label "CONFIRMED"
REJECTED: no dot, cell bg-muted, strikethrough text
Performance — bắt buộc khi số lượng dữ liệu lớn:
// Dùng @tanstack/react-virtual cho Matrix lớn
import { useVirtualizer } from '@tanstack/react-virtual'
// Quy tắc:
// - Virtualize cả rows VÀ columns khi > 50 columns hoặc > 200 rows
// - Debounce filter/search: useDebouncedCallback(fn, 300)
// - Lazy load data theo viewport: chỉ fetch rows đang hiển thị
// - Test baseline: 50 columns × 500 rows — phải smooth trên Chrome Windows
// Chunk loading pattern
const CHUNK_SIZE = 50
const [visibleRange, setVisibleRange] = useState({ start: 0, end: CHUNK_SIZE })
Tree Table (Anchored Data)¶
3-level hierarchy in a single flat table:
/* Level 1: Domain — bold, expand icon, background tint */
bg-muted/50 px-8 py-4 font-black text-foreground
/* Expand icon: ChevronDown (expanded) / ChevronRight (collapsed) */
/* aria-expanded: true/false */
/* Level 2: Sub Domain — indented, italic */
px-14 py-3 font-bold text-muted-foreground italic
/* Level 3: Data Element — deep indent, bullet prefix */
px-20 py-2 font-medium text-muted-foreground
/* Prefix: "• " before name */
Search-centric (Tra cứu Từ điển)¶
/* Centered layout, no sidebar context */
max-w-4xl mx-auto space-y-12 pt-12
/* Title block — centered, large */
text-center space-y-4
h1: text-4xl font-black tracking-tighter leading-tight /* ✅ leading-tight */
p: text-lg text-muted-foreground font-medium
/* Search input — hero element */
rounded-full shadow-2xl shadow-primary/10 text-xl
/* Results — card grid */
grid grid-cols-1 md:grid-cols-2 gap-6
/* Each result: interactive card with icon, title, ID, owner badge */
i18n Strategy¶
Thư viện: next-intl (khuyến nghị cho Next.js App Router)
Key naming convention: page.section.element
// vi.json
{
"dashboard": {
"kpi": {
"confirmedLabel": "Đã xác nhận",
"disputedLabel": "Đang tranh chấp"
}
},
"matrix": {
"emptyState": "Đơn vị bạn chưa có dữ liệu cần xác nhận.",
"states": {
"DATABASE": "DATABASE",
"CONFIRMED": "ĐÃ XÁC NHẬN",
"DISPUTED": "TRANH CHẤP"
}
}
}
Quy tắc quan trọng:
// Số liệu — dùng Intl.NumberFormat
const formatNumber = (n: number) =>
new Intl.NumberFormat('vi-VN').format(n)
// → "1.234.567"
// Ngày — dùng Intl.DateTimeFormat
const formatDate = (d: Date) =>
new Intl.DateTimeFormat('vi-VN', { dateStyle: 'medium' }).format(d)
// → "21 thg 4, 2026"
// Tiền tệ
const formatCurrency = (n: number) =>
new Intl.NumberFormat('vi-VN', { style: 'currency', currency: 'VND' }).format(n)
// ID codes — KHÔNG dịch, render raw
// DE001-DM1.1, BPF-01 → thêm data-i18n-ignore hoặc đặt trong <code>
<code className="font-mono text-[10px] font-bold text-muted-foreground">
{dataElement.code}
</code>
Layout tiếng Việt: - Text tiếng Việt thường dài hơn English 15-30% — thiết kế button/label có đủ padding - Tránh fixed width cho text containers - Test layout với nội dung tiếng Việt thực tế, không dùng Lorem Ipsum
Security UI Patterns¶
// CSRF — thêm vào tất cả Server Actions hoặc form POST
// Next.js App Router Server Actions tự handle CSRF nếu dùng đúng pattern
// Nếu dùng custom API route: thêm csrf-token header
// XSS — sanitize dynamic content từ user input trước khi render
import DOMPurify from 'dompurify'
const safeHtml = DOMPurify.sanitize(userGeneratedContent)
// Dùng cho: mô tả DE, comment, annotation
// Rate limit (HTTP 429) — feedback UI
function RateLimitedButton({ children, onClick }) {
const [countdown, setCountdown] = useState(0)
const handleClick = async () => {
try {
await onClick()
} catch (error) {
if (error.status === 429) {
const retryAfter = error.retryAfter || 30
setCountdown(retryAfter)
const interval = setInterval(() => {
setCountdown(c => {
if (c <= 1) { clearInterval(interval); return 0 }
return c - 1
})
}, 1000)
toast.error(`Quá nhiều yêu cầu. Thử lại sau ${retryAfter}s`)
}
}
}
return (
<Button onClick={handleClick} disabled={countdown > 0}>
{countdown > 0 ? `Thử lại sau ${countdown}s` : children}
</Button>
)
}
Style Guidelines (Summary)¶
Key Effects¶
rounded-3xl(24px) on all major surfaces — cards, panels, tablesrounded-2xl(16px) on buttons, inputs, sub-elementsshadow-smon cards (subtle),shadow-lgon primary buttonsshadow-2xl shadow-primary/10on hero search inputbg-gradient-to-br from-blue-600 to-blue-800on logo icon onlyanimate-pulseon notification dot onlytracking-widest+uppercaseon small labels (KPI cards, table headers)tracking-tight+leading-tighton page titles (H1) — leading bắt buộcfont-black(900) +tabular-numsfor KPI numbers
Pre-Delivery Checklist¶
Before delivering any UI code, verify:
Visual Quality¶
- [ ] No emojis as icons (use Lucide SVG icons)
- [ ] All icons from lucide-react, sizing theo Icon Size Scale (h-5 w-5 nav, h-4 w-4 inline)
- [ ] Hover states with smooth transitions (duration-150 to duration-300)
- [ ] No layout-shifting hovers
- [ ] All surfaces use
rounded-2xlminimum (buttons) orrounded-3xl(cards) - [ ] KPI numbers use
font-black text-4xl tabular-nums - [ ] Status badges use uppercase + bold + colored border + text label (không chỉ màu)
- [ ] H1/H2 có
leading-tight/leading-snug— test với nội dung tiếng Việt thực tế
Interaction¶
- [ ]
cursor-pointeron ALL clickable elements - [ ] Visible focus states (
focus-visible:ring-2) for keyboard navigation - [ ] Loading states: skeleton cho page load, spinner cho inline action
- [ ]
<AlertDialog />ONLY for destructive/irreversible actions - [ ] Form validation: react-hook-form + zod, inline error near field, dirty check
- [ ] Disabled buttons có tooltip giải thích lý do (khi do data state, không phải role)
- [ ] Rate limit (429): button disabled + countdown timer
Light/Dark Mode¶
- [ ] Text contrast >= 4.5:1 in both modes
- [ ] Borders visible in both modes (slate-200 light, slate-700 dark)
- [ ] Cards distinguishable from background in both modes
- [ ] Status badge colors readable in both modes
- [ ] Matrix cell backgrounds visible in both modes
- [ ] Dark mode tokens dùng full OKLCH values (không dùng opacity shortcuts)
Layout & Responsive¶
- [ ] Sidebar w-72 on desktop (z-20), Sheet on mobile
- [ ] Header h-20 sticky z-40 (cao hơn Matrix sticky column z-10)
- [ ] Content area p-10 with max-w-6xl
- [ ] Matrix:
overflow-x-auto+overscroll-behavior-x: contain - [ ] Matrix sticky column:
z-10(thấp hơn header) - [ ] No horizontal scroll on standard pages
- [ ] Test at: 375px, 768px, 1024px, 1440px
Accessibility¶
- [ ] Alt text on all images (dùng
next/image) - [ ] Labels on all form inputs (no placeholder-only)
- [ ]
aria-labelon icon-only buttons - [ ] Color is NOT the only indicator (dots + background + text label for matrix cells)
- [ ]
prefers-reduced-motionrespected (CSS global rule) - [ ] Semantic HTML (headings hierarchy, landmarks)
- [ ] Skip-to-content link
- [ ] Matrix Table:
role="grid",aria-colcount,aria-rowindex - [ ] Toast/notifications:
aria-live="polite" - [ ] Form errors:
aria-describedbylinking input to error message - [ ] Keyboard navigation trong Matrix: ArrowKeys di chuyển cell, Space/Enter mở detail
Security¶
- [ ] Dynamic user content (mô tả, comment) qua DOMPurify trước khi render
- [ ] Rate limit (429) có feedback UI (countdown)
- [ ] Không log sensitive data (token, password) lên console
Performance (Matrix)¶
- [ ] Virtual scroll khi > 50 columns hoặc > 200 rows (
@tanstack/react-virtual) - [ ] Debounce search/filter: 300ms
- [ ] Lazy load data theo viewport
i18n¶
- [ ] All user-facing text uses translation keys (vi primary, en secondary)
- [ ] Layout handles text length differences (VI thường dài hơn EN 15-30%)
- [ ] Số dùng
Intl.NumberFormat('vi-VN') - [ ] Ngày dùng
Intl.DateTimeFormat('vi-VN') - [ ] ID codes (DM{N}, DE{NNN}) render raw, không dịch