/* AAU CRM — Platform: QC, Analytics, RBAC, Settings, Automation */ // 10 mẫu ruleset đa dạng (chat + call) cho F&B education. const QC_TEMPLATES = [ { name: 'Chuẩn chat tư vấn', type: 'chat', channelScope: 'all', threshold: 7, criteria: [{ name: 'Chào hỏi đúng chuẩn', weight: 15 }, { name: 'Hỏi nhu cầu/qualify', weight: 25 }, { name: 'Báo giá & xử lý phản đối', weight: 30 }, { name: 'Chốt bước tiếp theo (CTA)', weight: 20 }, { name: 'Chính tả & thái độ', weight: 10 }] }, { name: 'Chuẩn chat ngoài giờ (Bot AI)', type: 'chat', channelScope: 'all', threshold: 7, criteria: [{ name: 'Chào hỏi thân thiện', weight: 20 }, { name: 'Trả lời đúng theo KB', weight: 35 }, { name: 'Không bịa thông tin', weight: 25 }, { name: 'Hẹn chuyên viên sáng mai', weight: 20 }] }, { name: 'Tư vấn khóa học (Education)', type: 'chat', channelScope: 'all', threshold: 7, criteria: [{ name: 'Hiểu mô hình KD của khách', weight: 25 }, { name: 'Map đúng khóa phù hợp', weight: 30 }, { name: 'Trình bày lộ trình học', weight: 25 }, { name: 'Học phí & ưu đãi rõ ràng', weight: 20 }] }, { name: 'Chốt deal / Đàm phán', type: 'chat', channelScope: 'all', threshold: 7.5, criteria: [{ name: 'Tạo tính cấp thiết (urgency)', weight: 20 }, { name: 'Xử lý phản đối', weight: 30 }, { name: 'Ưu đãi đúng thời điểm', weight: 20 }, { name: 'Chốt rõ ràng (CTA)', weight: 30 }] }, { name: 'Chăm sóc sau ghi danh (Onboarding)', type: 'chat', channelScope: 'all', threshold: 7, criteria: [{ name: 'Xác nhận lịch học', weight: 25 }, { name: 'Hướng dẫn thủ tục', weight: 25 }, { name: 'Giải đáp thắc mắc', weight: 25 }, { name: 'Thái độ tận tâm', weight: 25 }] }, { name: 'Xử lý khiếu nại', type: 'chat', channelScope: 'all', threshold: 7.5, criteria: [{ name: 'Lắng nghe & xin lỗi', weight: 25 }, { name: 'Xác nhận đúng vấn đề', weight: 20 }, { name: 'Đưa giải pháp', weight: 30 }, { name: 'Cam kết theo dõi', weight: 15 }, { name: 'Giữ thái độ bình tĩnh', weight: 10 }] }, { name: 'Re-engage lead nguội', type: 'chat', channelScope: 'all', threshold: 6.5, criteria: [{ name: 'Mở đầu cá nhân hóa', weight: 25 }, { name: 'Lý do nên quay lại', weight: 25 }, { name: 'Ưu đãi hấp dẫn', weight: 25 }, { name: 'CTA đặt lịch', weight: 25 }] }, { name: 'Chuẩn Zalo OA', type: 'chat', channelScope: 'zalo', threshold: 7, criteria: [{ name: 'Phản hồi nhanh', weight: 25 }, { name: 'Dùng đúng ZNS template', weight: 20 }, { name: 'Tư vấn đúng nhu cầu', weight: 35 }, { name: 'Chốt CTA', weight: 20 }] }, { name: 'Facebook comment → inbox', type: 'chat', channelScope: 'fb', threshold: 6.5, criteria: [{ name: 'Rep comment lịch sự', weight: 20 }, { name: 'Kéo về inbox', weight: 40 }, { name: 'Tư vấn trong inbox', weight: 25 }, { name: 'Chốt CTA', weight: 15 }] }, { name: 'Telesale (Call QC)', type: 'call', channelScope: 'all', threshold: 7, criteria: [{ name: 'Mở đầu chuyên nghiệp', weight: 15 }, { name: 'Khai thác nhu cầu', weight: 30 }, { name: 'Trình bày giá trị', weight: 25 }, { name: 'Chốt lịch hẹn', weight: 30 }] }, ]; function RulesetEditor({ initial, onClose, onSaved }) { const isNew = !initial.id; const [name, setName] = useState(initial.name || ''); const [channelScope, setCh] = useState(initial.channelScope || 'all'); const [type, setType] = useState(initial.type || 'chat'); const [threshold, setTh] = useState(String(initial.threshold != null ? initial.threshold : 7)); const [crit, setCrit] = useState((initial.criteria && initial.criteria.length ? initial.criteria : [{ name: '', weight: 0 }]).map(c => ({ ...c }))); const [busy, setBusy] = useState(false); const sumW = crit.reduce((s, c) => s + (parseInt(c.weight, 10) || 0), 0); const setC = (i, k, v) => setCrit(cs => cs.map((c, j) => j === i ? { ...c, [k]: v } : c)); const save = async () => { if (!name.trim() || busy) return; setBusy(true); const payload = { id: initial.id || ('qc_' + Date.now()), name: name.trim(), channelScope, type, threshold: parseFloat(threshold) || 7, criteria: crit.filter(c => c.name.trim()).map(c => ({ name: c.name.trim(), weight: parseInt(c.weight, 10) || 0 })), model: initial.model || 'Claude', }; try { if (window.API && window.API.enabled) { if (isNew) await window.API.qcCreateRuleset(payload); else await window.API.qcUpdateRuleset(payload.id, payload); } onSaved(payload, isNew); onClose(); } catch (e) { window.alert('Lưu lỗi: ' + (e && e.message || e)); } finally { setBusy(false); } }; return ( }>
setC(i, 'name', v)} placeholder="Tên tiêu chí" /> setC(i, 'weight', v)} placeholder="%" style={{ width: 70 }} /> ))}
); } function QCRulesets() { const live = !!(window.API && window.API.enabled); const [list, setList] = useState(() => (AAU.qcRulesets || []).map(r => ({ ...r }))); const [editing, setEditing] = useState(null); useEffect(() => { if (live && window.API.qcRulesets) window.API.qcRulesets().then(d => { if (Array.isArray(d)) setList(d); }).catch(() => { }); }, [live]); const reload = () => { if (live && window.API.qcRulesets) window.API.qcRulesets().then(d => { if (Array.isArray(d)) { setList(d); if (window.AAU) { AAU.qcRulesets.length = 0; d.forEach(x => AAU.qcRulesets.push(x)); } } }).catch(() => { }); }; const onSaved = () => reload(); const del = r => { if (!window.confirm('Xóa ruleset "' + r.name + '"?')) return; if (live && window.API.qcDeleteRuleset) window.API.qcDeleteRuleset(r.id).then(reload).catch(() => { }); else setList(l => l.filter(x => x.id !== r.id)); }; return (
setEditing({})}>Tạo ruleset} />
{QC_TEMPLATES.map((t, i) => ( ))}
{list.map(r => ( {r.type || 'chat'}}>
Đã chấm
{r.analyzed || 0}
Điểm TB
{r.avg || '—'}
Ngưỡng
{r.threshold != null ? r.threshold : 7}
Pass rate
{r.analyzed ? Math.round((r.pass || 0) / r.analyzed * 100) + '%' : '—'}
TIÊU CHÍ CHẤM
{(r.criteria || []).map((c, i) =>
{c.name}{c.weight}%
)}
{Perm.canDelete() && }
))}
{editing && setEditing(null)} onSaved={onSaved} />}
); } function QCDashboard() { const live = !!(window.API && window.API.enabled); const [from, setFrom] = useState(''); const [to, setTo] = useState(''); const [ch, setCh] = useState('all'); const [rep, setRep] = useState(null); const [loading, setLoading] = useState(false); useEffect(() => { if (!live || !window.API.qcScan) return; const qs = []; if (from) qs.push('from=' + from); if (to) qs.push('to=' + to); if (ch !== 'all') qs.push('channel=' + ch); setLoading(true); window.API.qcScan(qs.join('&')).then(d => { setRep(d); setLoading(false); }).catch(() => setLoading(false)); }, [live, from, to, ch]); const sum = rep && rep.summary, bd = rep && rep.breakdown; const sc = v => v >= 8 ? '#0a9e6e' : v >= 7 ? '#b06a00' : '#c4320a'; const distColor = { good: '#0a9e6e', ok: '#3b82f6', weak: '#f59e0b', bad: '#c4320a' }; const distLabel = { good: 'Tốt (≥8)', ok: 'Đạt (7–8)', weak: 'Yếu (5–7)', bad: 'Kém (<5)' }; const tot = (sum && sum.scanned) || 1; return (
setFrom(e.target.value)} style={{ height: 32, fontSize: 12.5 }} /> setTo(e.target.value)} style={{ height: 32, fontSize: 12.5 }} /> ({ value: u.id, label: u.name + (isSuper(u) ? ' — Super Admin' : (u.title ? ' — ' + u.title : '')) }))} />
{!candidates.length &&
Không có người nhận hợp lệ (cần ít nhất 1 người đang hoạt động). Hãy thêm/mở khóa một tài khoản trước.
} )} {!loading && !needTransfer && (
Người này không phụ trách lead/nhóm khóa nào — xóa an toàn.
)} ); } function InviteResultModal({ info, onClose }) { const [copied, setCopied] = useState(false); const copy = () => { try { navigator.clipboard.writeText(info.link); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (e) { } }; return ( }>
{info.emailed ?
Đã gửi email mời tới {info.email}. Nhắc họ kiểm tra cả hộp thư spam.
:
{info.emailError ? ('Gửi email thất bại: ' + info.emailError + '. ') : 'Email chưa được cấu hình. '}Hãy gửi link bên dưới cho người dùng (Zalo/email tay).
}
{ }} readOnly style={{ flex: 1, fontFamily: 'monospace', fontSize: 12 }} />
Người nhận mở link → đặt mật khẩu → tự đăng nhập. Link có hạn 7 ngày; hết hạn thì bấm “Lấy link” để tạo lại.
); } function UserForm({ user, onClose, onSave, onResetLink, busy }) { const [f, setF] = useState(user || { name: '', email: '', role: 'sales', salesRole: 'member', title: '', buScope: 'all', access: 'edit' }); const [scopeKind, setScopeKind] = useState(user ? (user.buScope === 'all' ? 'all' : 'bu') : 'all'); const [courses, setCourses] = useState(() => Array.isArray(user && user.buScope) ? user.buScope.slice() : []); const [channelScope, setChannelScope] = useState(() => Array.isArray(user && user.channelScope) ? user.channelScope.slice() : []); const toggleCh = id => setChannelScope(cs => cs.includes(id) ? cs.filter(x => x !== id) : [...cs, id]); const [invite, setInvite] = useState(true); const [newPw, setNewPw] = useState(''); const [newPw2, setNewPw2] = useState(''); const [roles, setRoles] = useState(() => { if (user && Array.isArray(user.roles) && user.roles.length) return user.roles.slice(); if (user && user.role) return [user.role]; return ['sales']; }); const set = (k, v) => setF(s => ({ ...s, [k]: v })); const toggleRole = id => setRoles(rs => rs.includes(id) ? rs.filter(x => x !== id) : [...rs, id]); const pwErr = newPw && (newPw.length < 6 ? 'Mật khẩu tối thiểu 6 ký tự.' : (newPw !== newPw2 ? 'Mật khẩu nhập lại không khớp.' : '')); const valid = (f.name || '').trim() && (f.email || '').trim() && roles.length > 0 && !pwErr; const toggleCourse = id => setCourses(cs => cs.includes(id) ? cs.filter(x => x !== id) : [...cs, id]); const submit = () => { if (!valid) return; const buScope = scopeKind === 'all' ? 'all' : courses; onSave({ ...f, role: roles[0], roles, buScope, channelScope }, !user && invite, newPw.trim() || null); }; const allCourses = (AAU.courses || []).filter(c => c.status !== 'coming_soon'); return ( }>
set('name', v)} placeholder="VD: Nguyễn Văn A" autoFocus /> set('email', v)} placeholder="ten@aau.vn" />
1 ? ' · đang chọn ' + roles.length : '')}>
{AAU.roles.map(r => ( ))}
set('title', v)} placeholder="VD: Sales BU 4" /> {scopeKind === 'bu' && (
{allCourses.map(c => ( ))}
{allCourses.length === 0 &&
Chưa có khóa học.
}
)} set('access', v)} options={[{ value: 'view', label: 'Chỉ xem' }, { value: 'edit', label: 'Chỉnh sửa' }, { value: 'manage', label: 'Quản trị' }]} />
{Object.values(AAU.channels || {}).map(ch => ( ))}
Để trống = thấy & trả lời mọi kênh. Chọn cụ thể = chỉ thấy hội thoại các kênh đó. Admin / Manager luôn xem tất cả. Với Zalo cá nhân, còn phải được gán đúng tài khoản ở “Nguồn kết nối”.
{!user && (
Mời kích hoạt
Tạo link đặt mật khẩu (gửi email nếu đã cấu hình SMTP). Tắt = tạo tài khoản trước, mời sau.
)} {user && (
Đặt lại mật khẩu
{pwErr ?
{pwErr}
:
Đặt mật khẩu mới trực tiếp (để trống nếu không đổi), HOẶC gửi link để người dùng tự đặt.
}
)}
Vai trò {roleLabel(f.role)} sẽ áp dụng ma trận quyền tương ứng. Chỉnh chi tiết ở màn RBAC.
); } function RBACMatrix() { const [role, setRole] = useState('manager'); const r = AAU.roles.find(x => x.id === role); const all = r.perms === 'all'; // Resolve actions cho 1 resource: override theo resource > scope theo group const groupOf = key => AAU.rbacGroups.find(g => g.resources.some(x => x.key === key)).group; const permsFor = key => { if (all) return AAU.rbacActions.map(a => a.key); if (r.over && r.over[key]) return r.over[key]; return (r.scope && r.scope[groupOf(key)]) || []; }; const isOverridden = key => !all && r.over && r.over.hasOwnProperty(key); const can = (key, act) => permsFor(key).includes(act); // Tổng hợp số liệu const allRes = AAU.rbacGroups.flatMap(g => g.resources); const granted = allRes.reduce((s, x) => s + (all ? AAU.rbacActions.length : permsFor(x.key).length), 0); const totalCells = allRes.length * AAU.rbacActions.length; const modulesTouched = AAU.rbacGroups.filter(g => g.resources.some(x => permsFor(x.key).length > 0)).length; return (
Tạo role} /> {/* Role picker — thẻ chọn */}
{AAU.roles.map(x => { const active = x.id === role; return (
setRole(x.id)} className="card pad" style={{ cursor: 'pointer', borderColor: active ? 'var(--p-interactive)' : 'var(--p-border)', boxShadow: active ? '0 0 0 2px var(--p-interactive)' : 'none' }}>
{x.label} {x.perms === 'all' ? Full : {x.users} user}
{x.desc}
); })}
{/* Tóm tắt quyền của role đang chọn */}
{[ { t: 'Quyền được cấp', v: all ? totalCells : granted, sub: '/' + totalCells + ' ô', tone: 'info' }, { t: 'Module truy cập', v: modulesTouched, sub: '/' + AAU.rbacGroups.length + ' nhóm', tone: 'success' }, { t: 'Override riêng', v: all ? 0 : Object.keys(r.over || {}).length, sub: 'khác mặc định nhóm', tone: 'warning' }, { t: 'Mức độ', v: all ? '100%' : Math.round(granted / totalCells * 100) + '%', sub: 'độ phủ quyền', tone: 'magic' }, ].map((c, i) => (
{c.t}
{c.v}{c.sub}
))}
{AAU.rbacActions.map(a => )} {AAU.rbacGroups.map(g => { const grpActs = AAU.rbacActions.map(a => g.resources.every(res => can(res.key, a.key))); return ( {AAU.rbacActions.map((a, ai) => ( ))} {g.resources.map(res => ( {AAU.rbacActions.map(a => ( ))} ))} ); })}
Resource{a.label}
{g.group}
{}} />
{res.label}{isOverridden(res.key) && ⚑ riêng}
{}} />
); } function SystemSettings() { const [tab, setTab] = useState('ai'); const tabs = [{ value: 'ai', label: 'AI Providers' }, { value: 'notif', label: 'Thông báo' }, { value: 'fx', label: 'Tỷ giá' }, { value: 'brand', label: 'Branding' }]; return (
Lưu thay đổi} />
{tab === 'ai' &&
{}} /> {}} /> {}} />
} {tab === 'brand' &&
{}} />
{['#008060', '#0084ff', '#7c3aed'].map(c => )}
}
); } function AutomationCenter() { const [rules, setRules] = useState(AAU.automationRules.map(r => ({ ...r }))); return (
Thêm rule} />
{rules.map((r, idx) => (
{r.name}
Khi: {r.event}Gửi: {r.channel}{r.to}
{ setRules(rs => rs.map((x, i) => i === idx ? { ...x, on: v } : x)); if (window.API && window.API.enabled) window.API.put('/automation/rules/' + r.id, { on: v }).catch(() => {}); }} />
))}
); } // ============================================================ // Auto-staging rules — quy tắc tự động chuyển stage cho Pipeline Kanban. // ============================================================ const COND_TYPES = [ { value: 'has_phone', label: 'Có số điện thoại' }, { value: 'two_way_chat', label: 'Đang chat (khách nhắn + mình trả lời)' }, { value: 'stale_days', label: 'N ngày không tương tác' }, { value: 'has_email', label: 'Có email' }, { value: 'hot_signal', label: 'Có tín hiệu nóng (hỏi giá/lịch)' }, ]; const condLabel = r => { const base = (COND_TYPES.find(c => c.value === r.condType) || {}).label || r.condType; return r.condType === 'stale_days' ? (r.days || 14) + ' ngày không tương tác' : base; }; const stageName = id => { const s = (AAU.stages || []).find(x => x.id === id); return s ? ((s.code ? s.code + ' ' : '') + s.name) : id; }; function StageRules() { const live = () => !!(window.API && window.API.enabled); const [list, setList] = useState([]); const [editing, setEditing] = useState(null); // null | 'add' | rule const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(''); const canEdit = window.Perm ? Perm.can('leads', 'approve') : true; const load = () => { if (live()) window.API.stageRules().then(d => Array.isArray(d) && setList(d)).catch(() => { }); }; useEffect(() => { load(); }, []); const sorted = [...list].sort((a, b) => (a.priority || 0) - (b.priority || 0)); const save = async (rule) => { setBusy(true); setMsg(''); try { if (rule.id && list.find(r => r.id === rule.id)) await window.API.updateStageRule(rule.id, rule); else await window.API.createStageRule(rule); setEditing(null); load(); } catch (e) { setMsg(e.message || 'Lưu lỗi'); } setBusy(false); }; const toggle = async (r) => { try { await window.API.updateStageRule(r.id, { enabled: !r.enabled }); load(); } catch (e) { setMsg(e.message); } }; const remove = async (r) => { if (!window.confirm('Xóa quy tắc "' + r.name + '"?')) return; try { await window.API.deleteStageRule(r.id); load(); } catch (e) { setMsg(e.message); } }; const runNow = async () => { setBusy(true); setMsg(''); try { const res = await window.API.runStageRules(); setMsg('✓ Đã chạy: chuyển ' + res.moved + ' lead.'); if (typeof LeadStore !== 'undefined' && LeadStore.reload) { try { await window.API.refreshLeads(); } catch (e) { } } } catch (e) { setMsg(e.message || 'Chạy lỗi'); } setBusy(false); }; return (
} /> {msg &&
{msg}
} {!canEdit &&
Bạn chỉ xem được quy tắc (cần vai trò Leader/Manager/Admin để chỉnh).
} {sorted.map(r => ( ))} {list.length === 0 && }
#Tên quy tắcĐiều kiệnÁp dụng cho stage→ Chuyển sangBật
{r.priority} {r.name} {condLabel(r)} {(r.fromStages && r.fromStages.length) ? r.fromStages.map(stageName).join(', ') : Mọi stage} {stageName(r.toStage)} {canEdit ? toggle(r)} /> : (r.enabled ? Bật : Tắt)} {canEdit &&
{window.Perm && Perm.canDelete() && }
}
Chưa có quy tắc. Bấm “Thêm quy tắc”.
{editing && setEditing(null)} onSave={save} />} ); } function StageRuleForm({ rule, onClose, onSave, busy }) { const [f, setF] = useState(rule || { name: '', condType: 'has_phone', days: 14, toStage: 's_chat', fromStages: ['s_raw'], priority: 10, enabled: true }); const set = (k, v) => setF(s => ({ ...s, [k]: v })); const leadStages = (AAU.stages || []).filter(s => ['lead', 'leadb'].includes(s.funnel)); const allStages = (AAU.stages || []); const toggleFrom = id => setF(s => { const cur = s.fromStages || []; return { ...s, fromStages: cur.includes(id) ? cur.filter(x => x !== id) : [...cur, id] }; }); const valid = (f.name || '').trim() && f.condType && f.toStage; return ( }>
set('name', v)} placeholder="VD: Có số điện thoại → Hot Lead Call" autoFocus />
set('days', v)} /> : set('priority', v)} />}
{f.condType === 'stale_days' && set('priority', v)} />} toggleFrom(s.id)} /> {(s.code ? s.code + ' ' : '') + s.name} ))}
Mẹo: để tránh kéo lead đã chốt về, chỉ chọn các stage đầu phễu (1.x/2.x).
); } Object.assign(window, { QCRulesets, QCDashboard, QCReports, AttributionHub, FunnelReconcileBody, RoasForecastBody, UserManagement, UserForm, RBACMatrix, SystemSettings, AutomationCenter, StageRules });