/* 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}%
)}
))}
{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 }} /> { }} 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 [invite, setInvite] = useState(true); const [newPw, setNewPw] = useState(''); const [newPw2, setNewPw2] = useState(''); const set = (k, v) => setF(s => ({ ...s, [k]: v })); 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() && f.role && !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, buScope }, !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" />
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ị' }]} /> {!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(() => {}); }} />
))}
); } Object.assign(window, { QCRulesets, QCDashboard, QCReports, AttributionHub, FunnelReconcileBody, RoasForecastBody, UserManagement, UserForm, RBACMatrix, SystemSettings, AutomationCenter });