// Danh bạ nội bộ (Internal Directory) — Phase 10.2 G-O1 (S34 2026-05-27). // Card grid responsive, filter search + department, avatar fallback gradient theo // userId hash stable. File này MIRROR SHA256 identical với fe-admin counterpart. // Reuse BE GET /api/directory readonly (DirectoryFeatures.cs). import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { useSearchParams } from 'react-router-dom' import { Mail, Phone, Search, UserCircle2, Users } from 'lucide-react' import { PageHeader } from '@/components/PageHeader' import { EmptyState } from '@/components/EmptyState' import { Input } from '@/components/ui/Input' import { Select } from '@/components/ui/Select' import { api } from '@/lib/api' import { cn } from '@/lib/cn' import type { Paged, Department } from '@/types/master' import type { DirectoryItem } from '@/types/directory' // Pattern 14 Tailwind JIT palette 6 màu gradient cho initials avatar. const AVATAR_PALETTE = [ 'bg-gradient-to-br from-blue-400 to-blue-600', 'bg-gradient-to-br from-emerald-400 to-emerald-600', 'bg-gradient-to-br from-amber-400 to-amber-600', 'bg-gradient-to-br from-violet-400 to-violet-600', 'bg-gradient-to-br from-rose-400 to-rose-600', 'bg-gradient-to-br from-cyan-400 to-cyan-600', ] as const // Format SĐT Vietnam 10 chữ số: 0XXX XXX XXX function formatPhone(p: string | null): string { if (!p) return '' const digits = p.replace(/\D/g, '') if (digits.length === 10) return digits.replace(/(\d{4})(\d{3})(\d{3})/, '$1 $2 $3') if (digits.length === 11) return digits.replace(/(\d{4})(\d{3})(\d{4})/, '$1 $2 $3') return p } // Hash stable userId → palette index (charCodeAt sum mod 6). function avatarColor(userId: string): string { let sum = 0 for (let i = 0; i < userId.length; i++) sum += userId.charCodeAt(i) return AVATAR_PALETTE[sum % AVATAR_PALETTE.length] } // First letter của FullName (uppercase, fallback "?"). function initials(fullName: string): string { const trimmed = fullName.trim() if (!trimmed) return '?' return trimmed.charAt(0).toUpperCase() } export function InternalDirectoryPage() { const [sp, setSp] = useSearchParams() const search = sp.get('q') ?? '' const departmentId = sp.get('deptId') ?? '' const [localSearch, setLocalSearch] = useState(search) const departments = useQuery({ queryKey: ['departments-all-directory'], queryFn: async () => (await api.get>('/departments', { params: { page: 1, pageSize: 200 } })).data.items, }) const list = useQuery({ queryKey: ['directory', { search, departmentId }], queryFn: async () => { const res = await api.get('/directory', { params: { search: search || undefined, departmentId: departmentId || undefined, }, }) return res.data }, }) function setParam(key: string, value: string | null) { const next = new URLSearchParams(sp) if (value == null || value === '') next.delete(key) else next.set(key, value) setSp(next, { replace: true }) } function applySearch(e: React.FormEvent) { e.preventDefault() setParam('q', localSearch.trim() || null) } const total = list.data?.length ?? 0 return (
{/* Filter bar sticky top */}
setLocalSearch(e.target.value)} onBlur={() => setParam('q', localSearch.trim() || null)} placeholder="Tìm tên / email / SĐT / mã NV..." className="pl-8" />
{/* Card grid */} {list.isLoading ? (
{Array.from({ length: 8 }).map((_, i) => (
))}
) : total === 0 ? ( ) : (
{list.data!.map(item => ( ))}
)}
) } function DirectoryCard({ item }: { item: DirectoryItem }) { return (
{/* Top row: avatar + name + code */}
{item.photoUrl ? ( {item.fullName} ) : (
{initials(item.fullName)}
)}

{item.fullName}

{item.employeeCode && ( {item.employeeCode} )}
{item.position && (

{item.position}

)} {item.departmentName && ( {item.departmentName} )}
{/* Contact rows */}
{item.email ? ( {item.email} ) : (
)} {item.phone ? ( {formatPhone(item.phone)} ) : (
)} {item.internalPhone && (
Ext: {item.internalPhone}
)}
) }