/* AAU CRM — App shell: TopBar + 2-level Nav + hash router */ const NAV = [ { label: 'Tổng quan', items: [ { label: 'Executive Dashboard', icon: 'dashboard', path: '/exec' }, { label: 'Flow Map', icon: 'flow', path: '/flow' }, { label: 'Lập kế hoạch', icon: 'target', path: '/planning' }, { label: 'Theo dõi KPI', icon: 'chart', path: '/planning/tracking' }, ]}, { label: 'Marketing', items: [ { label: 'Marketing Overview', icon: 'dashboard', path: '/marketing/overview' }, { label: 'Nguồn dữ liệu', icon: 'layers', path: '/marketing/connections' }, { label: 'Ad Tracking', icon: 'megaphone', path: '/marketing/ads' }, { label: 'Content Analytics', icon: 'fileText', path: '/marketing/content' }, { label: 'Lịch nội dung', icon: 'calendar', path: '/marketing/calendar' }, { label: 'Source Performance', icon: 'chart', path: '/marketing/sources' }, { label: 'Attribution', icon: 'flow', path: '/marketing/attribution' }, { label: 'Cấu hình Marketing', icon: 'settings', path: '/marketing/settings' }, ]}, { label: 'Inbox', items: [ { label: 'Hội thoại', icon: 'inbox', path: '/inbox', dynamicBadge: 'inbox' }, { label: 'Báo cáo Inbox', icon: 'chart', path: '/inbox/report' }, { label: 'Nhật ký cuộc gọi', icon: 'phone', path: '/calls' }, ]}, { label: 'Sales CRM', items: [ { label: 'Hôm nay', icon: 'bolt', path: '/sales/today' }, { label: 'Pipeline Kanban', icon: 'kanban', path: '/leads/kanban' }, { label: 'Bảng Pipeline', icon: 'table', path: '/leads' }, { label: 'Hồ sơ 360°', icon: 'user', path: '/leads/L1' }, { label: 'Sales Activity', icon: 'chart', path: '/sales/activity' }, { label: 'Báo cáo Sales', icon: 'chart', path: '/sales/report' }, { label: 'Cấu hình & Playbook', icon: 'settings', path: '/leads/pipeline-config' }, ]}, { label: 'Vận hành', items: [ { label: 'Khóa học', icon: 'graduation', path: '/courses' }, { label: 'Lịch khai giảng', icon: 'calendar', path: '/classes' }, { label: 'Ghi danh', icon: 'users', path: '/enrollments' }, { label: 'Giảng viên', icon: 'user', path: '/instructors' }, ]}, { label: 'Tài chính', items: [ { label: 'Tổng quan hợp nhất', icon: 'dollar', path: '/finance' }, { label: 'P&L theo khóa', icon: 'graduation', path: '/finance/cohorts' }, { label: 'Báo cáo doanh thu', icon: 'chart', path: '/finance/revenue' }, { label: 'So sánh kỳ', icon: 'pieChart', path: '/finance/compare' }, { label: 'Nguồn & Nhập liệu', icon: 'table', path: '/finance/sources' }, ]}, { label: 'Enablement & AI', items: [ { label: 'Conversation Intel', icon: 'target', path: '/enablement/intelligence' }, { label: 'Asset Library', icon: 'layers', path: '/enablement/assets' }, { label: 'Script Flow', icon: 'flow', path: '/enablement/scripts' }, { label: 'Knowledge Base', icon: 'book', path: '/enablement/kb' }, { label: 'AI Chat Config', icon: 'robot', path: '/settings/ai-chat' }, ]}, { label: 'QC Engine', items: [ { label: 'QC Review', icon: 'sparkles', path: '/qc/review', badge: 6 }, { label: 'Rulesets', icon: 'checkCircle', path: '/qc/rulesets' }, { label: 'QC Dashboard', icon: 'chart', path: '/qc/dashboard' }, { label: 'QC Daily Report', icon: 'fileText', path: '/qc/reports' }, ]}, { label: 'Hệ thống', items: [ { label: 'Quản lý người dùng', icon: 'users', path: '/admin/users' }, { label: 'RBAC', icon: 'shield', path: '/admin/permissions' }, { label: 'Nguồn kết nối', icon: 'message', path: '/system/channels' }, { label: 'MCP Connections', icon: 'externalLink', path: '/system/mcp' }, { label: 'Agents Center', icon: 'robot', path: '/system/agents' }, { label: 'Cấu hình AI', icon: 'sparkles', path: '/system/ai-settings' }, { label: 'Nhật ký & Chi phí', icon: 'fileText', children: [ { label: 'Nhật ký hoạt động', path: '/system/activity' }, { label: 'Nhật ký thông báo', path: '/system/notifications' }, { label: 'AI Cost', path: '/system/ai-cost' }, ]}, { label: 'Dữ liệu mẫu', icon: 'layers', path: '/system/demo' }, { label: 'Nhật ký cập nhật', icon: 'fileText', path: '/changelog' }, { label: 'System Settings', icon: 'settings', path: '/settings' }, ]}, ]; /* ── Universal search: not just features. Indexes screens + live data (khách hàng, hội thoại, khóa học, lớp, giảng viên) so the box answers the user's actual intent — a matching feature surfaces first, otherwise it behaves like a normal flexible search over everything. ── */ function stripDia(s) { return (s == null ? '' : String(s)).toLowerCase() .normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd'); } // Result categories — order defines how groups stack in the panel. const SEARCH_KINDS = { feature: { label: 'Màn hình & tính năng', order: 0, cap: 6, hint: 'mở' }, lead: { label: 'Khách hàng & Lead', order: 1, cap: 5, hint: 'hồ sơ' }, conv: { label: 'Hội thoại', order: 2, cap: 4, hint: 'mở inbox' }, course: { label: 'Khóa học', order: 3, cap: 4, hint: 'xem' }, class: { label: 'Lớp khai giảng', order: 4, cap: 3, hint: 'xem' }, instructor: { label: 'Giảng viên', order: 5, cap: 3, hint: 'xem' }, }; function buildSearchIndex() { const items = []; // 1) Screens / features from the nav tree for (const g of NAV) { for (const it of g.items) { if (it.path) items.push({ kind: 'feature', label: it.label, sub: g.label, path: it.path, icon: it.icon, badge: it.badge }); if (it.children) for (const c of it.children) { items.push({ kind: 'feature', label: c.label, sub: g.label + ' · ' + it.label, path: c.path, icon: it.icon, badge: c.badge }); } } } const A = window.AAU; if (A) { // 2) Khách hàng / Lead → Hồ sơ 360° (A.leads || []).forEach(l => { if (l.name === 'Spam Bot') return; const st = A.stageById ? A.stageById(l.stage) : null; items.push({ kind: 'lead', label: l.name, path: '/leads/' + l.id, icon: 'user', sub: [l.company && l.company !== '—' ? l.company : null, l.region, st && st.name].filter(Boolean).join(' · '), meta: l.hot ? '🔥' : (l.grade ? l.grade : ''), text: [l.name, l.company, l.phone, l.email, l.region, l.industry, l.bizModel, (l.painpoints || []).join(' ')].join(' '), }); }); // 3) Hội thoại → Inbox (deep-link to the conversation) Object.values(A.conversations || {}).forEach(cv => { const l = A.leadById(cv.leadId); const last = cv.messages && cv.messages[cv.messages.length - 1]; const lastTxt = last ? (last.text || (last.attachment ? '📎 ' + last.attachment : '')) : ''; items.push({ kind: 'conv', label: l ? l.name : cv.leadId, path: '/inbox', icon: 'inbox', intent: { inboxConv: cv.id }, sub: cv.channel.toUpperCase() + (lastTxt ? ' · “' + lastTxt.slice(0, 46) + (lastTxt.length > 46 ? '…' : '') + '”' : ''), badge: cv.unread || undefined, text: [l ? l.name : '', l ? l.company : '', cv.channel, (cv.messages || []).map(m => m.text).join(' ')].join(' '), }); }); // 4) Khóa học (A.courses || []).forEach(c => items.push({ kind: 'course', label: c.name, path: '/courses', icon: 'graduation', sub: [c.code, A.courseGroupLabels ? A.courseGroupLabels[c.group] : c.group, c.sessions ? c.sessions + ' buổi' : null].filter(Boolean).join(' · '), text: [c.name, c.code, c.en, c.desc].join(' '), })); // 5) Lớp khai giảng (A.classes || []).forEach(cl => { const c = A.courseById ? A.courseById(cl.course) : null; items.push({ kind: 'class', label: (c ? c.name : cl.course) + ' — ' + cl.batch, path: '/classes', icon: 'calendar', sub: [cl.start, cl.patLabel, cl.cat].filter(Boolean).join(' · '), text: [cl.batch, c ? c.name : '', cl.start, cl.cat, cl.patLabel].join(' '), }); }); // 6) Giảng viên (A.instructors || []).forEach(i => items.push({ kind: 'instructor', label: i.name, path: '/instructors', icon: 'user', sub: [i.title, i.expertise].filter(Boolean).join(' · '), text: [i.name, i.title, i.expertise].join(' '), })); } // precompute normalized search strings items.forEach(it => { it._l = stripDia(it.label); it._s = stripDia(it.label + ' ' + (it.sub || '') + ' ' + (it.text || '')); }); return items; } let _searchIndex = null; function getSearchIndex() { if (!_searchIndex) _searchIndex = buildSearchIndex(); return _searchIndex; } function scoreItem(it, qs) { const l = it._l, s = it._s; if (l.startsWith(qs)) return 100; if ((' ' + l).includes(' ' + qs)) return 86; // matches a word start in the label if (l.includes(qs)) return 72; if (s.includes(qs)) return 46; // subsequence match over the label (typo / abbrev tolerant) let i = 0; for (const ch of l) { if (ch === qs[i]) i++; if (i === qs.length) break; } if (i === qs.length) return 18; return -1; } function CommandSearch() { const [q, setQ] = useState(''); const [open, setOpen] = useState(false); const [active, setActive] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); const results = useMemo(() => { const idx = getSearchIndex(); const query = q.trim(); if (!query) { // Quick access — just the top screens, but the box searches far more. return idx.filter(i => i.kind === 'feature').slice(0, 7); } const qs = stripDia(query); const scored = idx .map(it => ({ it, sc: scoreItem(it, qs) })) .filter(x => x.sc >= 0) .sort((a, b) => b.sc - a.sc); // Bucket by kind, cap each, then stack groups in defined order. const byKind = {}; scored.forEach(({ it, sc }) => { (byKind[it.kind] = byKind[it.kind] || []).push({ ...it, score: sc }); }); const out = []; Object.keys(SEARCH_KINDS) .sort((a, b) => SEARCH_KINDS[a].order - SEARCH_KINDS[b].order) .forEach(k => { if (byKind[k]) out.push(...byKind[k].slice(0, SEARCH_KINDS[k].cap)); }); return out.slice(0, 18); }, [q]); useEffect(() => { setActive(0); }, [q]); // ⌘K / Ctrl-K to focus useEffect(() => { const on = e => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); inputRef.current && inputRef.current.focus(); setOpen(true); } }; window.addEventListener('keydown', on); return () => window.removeEventListener('keydown', on); }, []); const go = (item) => { if (!item) return; if (item.intent) window.NavIntent = item.intent; // deep-link payload (e.g. which conversation) navigate(item.path); setOpen(false); setQ(''); inputRef.current && inputRef.current.blur(); }; const onKey = e => { if (e.key === 'ArrowDown') { e.preventDefault(); setActive(a => Math.min(a + 1, results.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(a => Math.max(a - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); go(results[active]); } else if (e.key === 'Escape') { setOpen(false); inputRef.current && inputRef.current.blur(); } }; // keep active item scrolled into view inside the list (skip group headers) useEffect(() => { if (!open || !listRef.current) return; const el = listRef.current.querySelectorAll('.cmd-item')[active]; if (el) { const lr = listRef.current, t = el.offsetTop, b = t + el.offsetHeight; if (t < lr.scrollTop) lr.scrollTop = t; else if (b > lr.scrollTop + lr.clientHeight) lr.scrollTop = b - lr.clientHeight; } }, [active, open]); return (