/* 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 (
inputRef.current && inputRef.current.focus()}> { setQ(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 120)} onKeyDown={onKey} /> ⌘K
{open && (
{q.trim() ? (results.length + ' kết quả') : 'Truy cập nhanh'}
{results.length === 0 ? (
Không có kết quả cho “{q.trim()}”.
Thử tên khách, số điện thoại, kênh chat, tên khóa học hoặc giảng viên.
) : (
{results.map((r, i) => { const showHead = i === 0 || results[i - 1].kind !== r.kind; return ( {showHead &&
{SEARCH_KINDS[r.kind].label}
} setActive(i)} onMouseDown={e => { e.preventDefault(); go(r); }} > {r.label} {r.meta ? {r.meta} : null} {r.badge ? {r.badge} : null} {r.sub ? {r.sub} : null} {i === active ? SEARCH_KINDS[r.kind].hint : }
); })}
)}
di chuyển mở esc đóng
)}
); } function useHashRoute() { const [path, setPath] = useState(() => (location.hash || '#/exec').slice(1)); useEffect(() => { const on = () => setPath((location.hash || '#/exec').slice(1)); window.addEventListener('hashchange', on); return () => window.removeEventListener('hashchange', on); }, []); return path; } function navigate(p) { location.hash = '#' + p; const m = document.querySelector('.main'); if (m) m.scrollTop = 0; } window.navigate = navigate; function matchRoute(routes, path) { for (const r of routes) { if (r.path === path) return { comp: r.comp, params: {}, route: r }; if (r.path.includes(':')) { const rp = r.path.split('/'), pp = path.split('/'); if (rp.length !== pp.length) continue; const params = {}; let ok = true; for (let i = 0; i < rp.length; i++) { if (rp[i].startsWith(':')) params[rp[i].slice(1)] = decodeURIComponent(pp[i]); else if (rp[i] !== pp[i]) { ok = false; break; } } if (ok) return { comp: r.comp, params, route: r }; } } return null; } // Tổng tin chưa đọc thật từ AAU.conversations (cho badge sidebar Hội thoại). function inboxUnread() { try { return Object.values(AAU.conversations || {}).reduce((s, c) => s + (Number(c.unread) || 0), 0); } catch (e) { return 0; } } function NavItem({ item, path }) { const hasKids = item.children && item.children.length; const childActive = hasKids && item.children.some(c => c.path === path); const [open, setOpen] = useState(childActive); useEffect(() => { if (childActive) setOpen(true); }, [childActive]); const active = item.path === path; const badge = item.dynamicBadge === 'inbox' ? inboxUnread() : item.badge; if (!hasKids) { return (
navigate(item.path)}> {item.label} {badge ? {badge} : null}
); } return (
setOpen(!open)}> {item.label}
{open &&
{item.children.map(c => (
navigate(c.path)}> {c.label}{c.badge && {c.badge}}
))}
}
); } function NavGroup({ group, path }) { const groupHasActive = group.items.some(it => it.path === path || (it.children && it.children.some(c => c.path === path)) ); const key = 'navgroup:' + group.label; const [open, setOpen] = useState(() => { const saved = localStorage.getItem(key); if (saved !== null) return saved === '1'; return true; }); // Always reveal a group that contains the active route useEffect(() => { if (groupHasActive && !open) setOpen(true); }, [groupHasActive]); const toggle = () => { const n = !open; setOpen(n); localStorage.setItem(key, n ? '1' : '0'); }; return (
{open &&
{group.items.map(it => )}
}
); } function AppShell({ routes }) { const path = useHashRoute(); const matched = matchRoute(routes, path) || { comp: () => , params: {} }; const Comp = matched.comp; const [tenant, setTenant] = useState(AAU.tenants[0]); const viewer = useViewer(); // Re-render sidebar khi dữ liệu hội thoại đổi (hydrate / wipe / đọc tin) → badge real-time. const [, bump] = React.useReducer(x => x + 1, 0); useEffect(() => { const on = () => bump(); window.addEventListener('aau:conversations-changed', on); return () => window.removeEventListener('aau:conversations-changed', on); }, []); return (
navigate('/exec')} style={{ cursor: 'pointer' }}>
A
AAU CRM Center
Học viện F&B
Xem với
{tenant.short}
); } function NotFound({ path }) { return
navigate('/exec')}>Về Dashboard} />
; } // ============================================================ // Authentication gate — a real login screen in front of the app. // Enforced only when served from a real host (the VPS); local dev // (localhost / 127.0.0.1 / file://) opens straight through so the // build workflow stays frictionless. ?api / token still work as before. // ============================================================ const LOGIN_INP = { width: '100%', boxSizing: 'border-box', padding: '9px 11px', borderRadius: 8, border: '1px solid #d1d5db', fontSize: 14, outline: 'none' }; function applyAuthUser(u) { if (!u || !u.id) return; try { AAU.currentUser = u; } catch (e) { } try { localStorage.setItem('aau_user', JSON.stringify(u)); } catch (e) { } try { if (AAU.users.find(x => x.id === u.id)) AccessStore.set(u.id); } catch (e) { } } function authLogout() { try { window.API && window.API.logout && window.API.logout(); } catch (e) { } try { localStorage.removeItem('aau_token'); localStorage.removeItem('aau_user'); } catch (e) { } location.reload(); } function LoginScreen({ onSuccess }) { const [email, setEmail] = useState(''); const [pw, setPw] = useState(''); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); async function submit(e) { if (e) e.preventDefault(); if (!email.trim() || !pw) { setErr('Nhập email và mật khẩu.'); return; } setBusy(true); setErr(''); try { const res = await window.API.login(email.trim().toLowerCase(), pw); applyAuthUser(res.user); onSuccess(res.user); } catch (ex) { setErr('Email hoặc mật khẩu không đúng.'); setBusy(false); } } return (
A
AAU CRM Center
Học viện F&B
Đăng nhập để tiếp tục
setEmail(e.target.value)} autoFocus type="email" placeholder="ten@aau.vn" style={LOGIN_INP} /> setPw(e.target.value)} type="password" placeholder="••••••••" style={LOGIN_INP} /> {err &&
{err}
}
); } // ---- account activation (invited user sets their own password) ---- function hashRoute() { return (location.hash || '').replace(/^#/, '').split('?')[0]; } function hashQuery(name) { const h = location.hash || ''; const i = h.indexOf('?'); if (i < 0) return ''; try { return new URLSearchParams(h.slice(i + 1)).get(name) || ''; } catch (e) { return ''; } } function ActivationScreen() { const token = hashQuery('token'); const [info, setInfo] = useState(null); const [pw, setPw] = useState(''); const [pw2, setPw2] = useState(''); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); const [done, setDone] = useState(false); useEffect(() => { let alive = true; (async () => { try { await window.AAU_BOOT; } catch (e) { } if (!token) { if (alive) setErr('Link không hợp lệ (thiếu mã kích hoạt).'); return; } try { const r = await window.API.activateInfo(token); if (alive) setInfo(r); } catch (ex) { if (alive) setErr(ex.message || 'Link không hợp lệ hoặc đã hết hạn.'); } })(); return () => { alive = false; }; }, []); async function submit(e) { if (e) e.preventDefault(); if (pw.length < 6) { setErr('Mật khẩu tối thiểu 6 ký tự.'); return; } if (pw !== pw2) { setErr('Mật khẩu nhập lại không khớp.'); return; } setBusy(true); setErr(''); try { await window.API.activate(token, pw); setDone(true); setTimeout(() => { location.hash = '#/exec'; location.reload(); }, 1200); } catch (ex) { setErr(ex.message || 'Kích hoạt thất bại.'); setBusy(false); } } const card = { width: 380, background: '#fff', borderRadius: 14, padding: '30px 28px', boxShadow: '0 8px 30px rgba(0,0,0,.10)', border: '1px solid #e3e6ea' }; const wrap = { minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f4f6f8' }; const head = (
A
AAU CRM Center
Kích hoạt tài khoản
); if (done) return
{head}
✓ Kích hoạt thành công! Đang đưa bạn vào hệ thống…
; if (err && !info) return
{head}
{err}
Hãy đề nghị quản trị viên gửi lại link mời mới.
; return (
{head} {info &&
Xin chào {info.name || info.email}, đặt mật khẩu để kích hoạt tài khoản {info.email}.
} setPw(e.target.value)} autoFocus type="password" placeholder="Tối thiểu 6 ký tự" style={LOGIN_INP} /> setPw2(e.target.value)} type="password" placeholder="••••••••" style={LOGIN_INP} /> {err &&
{err}
}
); } function AuthGate({ children }) { const isLocalDev = location.hostname === 'localhost' || location.hostname === '127.0.0.1' || location.protocol === 'file:'; const [route, setRoute] = useState(hashRoute()); useEffect(() => { const on = () => setRoute(hashRoute()); window.addEventListener('hashchange', on); return () => window.removeEventListener('hashchange', on); }, []); const [state, setState] = useState(isLocalDev ? 'authed' : 'checking'); useEffect(() => { if (isLocalDev) return; if (hashRoute() === '/activate') return; // activation page is public let alive = true; (async () => { try { await window.AAU_BOOT; } catch (e) { } if (!localStorage.getItem('aau_token')) { if (alive) setState('login'); return; } try { const me = await window.API.get('/auth/me'); applyAuthUser(me); if (alive) setState('authed'); } catch (e) { try { localStorage.removeItem('aau_token'); localStorage.removeItem('aau_user'); } catch (_) { } if (alive) setState('login'); } })(); return () => { alive = false; }; }, []); if (route === '/activate') return ; // public, even on the VPS if (state === 'checking') return
Đang tải…
; if (state === 'login') return setState('authed')} />; return children; } Object.assign(window, { AppShell, NAV, AuthGate, authLogout });