/* AAU CRM — Operations: Khóa học · Lịch khai giảng (lớp + lịch tháng) · chỉnh sửa + đồng bộ aau.vn */
const GROUP_C = id => (AAU.courseGroupById(id) || {}).color || '#616161';
const fillColor = t => (t === 'ok' ? '#0e7c4a' : t === 'warn' ? '#d97706' : '#c4320a');
const DOW_VN = ['CN', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7'];
const PATTERNS = [
{ pat: ['2', '4', '6'], label: 'Thứ 2 · 4 · 6' },
{ pat: ['3', '5', '7'], label: 'Thứ 3 · 5 · 7' },
{ pat: ['6', '7', 'CN'], label: 'Thứ 6 · 7 · CN' },
{ pat: ['7', 'CN'], label: 'Thứ 7 · CN' },
{ pat: ['2', '4'], label: 'Thứ 2 · 4' },
{ pat: ['3', '5'], label: 'Thứ 3 · 5' },
{ pat: ['2', '3', '4', '5', '6'], label: 'Thứ 2 → 6' },
];
function useOps() {
const [, force] = React.useReducer(x => x + 1, 0);
React.useEffect(() => OpsStore.subscribe(force), []);
return OpsStore;
}
function classFill(c) {
const fill = Math.round(c.enrolled / c.cap * 100);
const slots = Math.max(0, c.cap - c.enrolled);
const days = AAU.daysToStart(c);
const tone = fill >= 80 ? 'ok' : fill >= 50 ? 'warn' : 'bad';
const almostFull = slots <= 2;
const lowFill = c.status === 'enrolling' && fill < 50 && days <= 22;
return { fill, slots, days, tone, almostFull, lowFill };
}
function fmtFieldVal(k, v) {
if (v == null || v === '') return '—';
if (k === 'price') return AAU.fmtVND(v);
if (k === 'start') return AAU.fmtDate(v);
if (k === 'status') return v === 'active' ? 'Đang mở' : 'Sắp ra mắt';
if (k === 'group') return (AAU.courseGroupById(v) || {}).label || v;
if (k === 'brochure') return 'link Drive';
if (k === 'desc') return String(v).slice(0, 40) + '…';
return String(v);
}
function GroupTag({ id }) {
const g = AAU.courseGroupById(id); if (!g) return null;
return {g.label};
}
function ClassStatusBadge({ c }) {
if (c.status === 'in_progress') return Đang học · buổi {c.doneCount}/{c.sessions.length};
return Đang tuyển;
}
function FillBar({ c, h = 6 }) {
const { fill, tone } = classFill(c);
return
;
}
function WebChip() { return Đã đăng web; }
function DiffChip() { return Lệch web; }
function InsAvatar({ id, size = 24 }) {
const i = AAU.instructorById(id); if (!i) return null;
return ;
}
/* ============================= KHÓA HỌC ============================= */
function CourseCatalog() {
useOps();
const [grp, setGrp] = useState('all');
const [view, setView] = useState(() => localStorage.getItem('aau_course_view') || 'grid');
const [course, setCourse] = useState(null);
const [sync, setSync] = useState(false);
const setV = v => { setView(v); localStorage.setItem('aau_course_view', v); };
const courses = AAU.courses.filter(c => grp === 'all' || c.group === grp);
const diffIds = new Set(OpsStore.courseDiffs().map(d => d.id));
const courseObj = course && AAU.courseById(course.id);
const totalStudents = AAU.classes.reduce((s, c) => s + c.enrolled, 0);
const openClasses = AAU.classes.filter(c => c.status === 'enrolling').length;
const activeCourses = AAU.courses.filter(c => c.status === 'active').length;
const totalCourses = AAU.courses.length;
const soonCourses = AAU.courses.filter(c => c.status === 'coming_soon').length;
return (
>} />
s + Math.max(0, c.cap - c.enrolled), 0)} sub="tất cả lớp đang mở" icon="target" />
({ value: g.id, label: g.label + ' (' + AAU.courses.filter(c => c.group === g.id).length + ')' }))]} />
{view === 'grid'
? {courses.map(c => setCourse(c)} />)}
: }
{courseObj && setCourse(null)} />}
{sync && setSync(false)} />}
);
}
function CourseCard({ c, diff, onOpen }) {
const cls = AAU.classesByCourse(c.id);
const soon = c.status === 'coming_soon';
return (
{diff && }{soon ? Sắp ra mắt : Đang mở}
{c.name}
{c.en}
{c.desc}
{c.price ? AAU.fmtVNDm(c.price) : 'Đang cập nhật'}
{c.sessions} buổi · {c.hours}h
{soon
? Mở đăng ký nhận tin khi ra mắt
: cls.length
?
{cls.length} LỚP ĐANG KHAI GIẢNG
{cls.slice(0, 2).map(k => { const f = classFill(k); return (
{k.batch}KG {AAU.fmtDate(k.start)}
{k.enrolled}/{k.cap} HVcòn {f.slots} chỗ
); })}
: Chưa có lịch khai giảng
}
);
}
function CourseList({ courses, diffIds, onOpen }) {
return (
| Khóa học | Nhóm | Buổi | Học phí | Lớp đang mở | HV / Chỗ |
{courses.map(c => {
const cls = AAU.classesByCourse(c.id);
const soon = c.status === 'coming_soon';
const hv = cls.reduce((s, k) => s + k.enrolled, 0), cap = cls.reduce((s, k) => s + k.cap, 0);
return (
onOpen(c)} style={{ borderTop: '1px solid var(--p-border-secondary)', fontSize: 13 }}>
{c.name}{diffIds.has(c.id) && } {c.code} · {c.en} |
|
{c.sessions} buổi · {c.hours}h |
{c.price ? AAU.fmtVND(c.price) : '—'} |
{soon ? Sắp ra mắt : cls.length ? cls.map(k => k.batch).join(', ') : —} |
{cap ? {hv} : '—'}{cap ? / {cap} : ''} |
);
})}
);
}
function CourseEditForm({ c, onDone }) {
const [f, setF] = useState({ name: c.name, en: c.en || '', code: c.code, group: c.group, sessions: c.sessions, hours: c.hours, price: c.price || 0, status: c.status, targetStudents: c.targetStudents, desc: c.desc });
const set = (k, v) => setF(s => ({ ...s, [k]: v }));
const save = () => { OpsStore.updateCourse(c.id, { name: f.name, en: f.en, code: f.code, group: f.group, sessions: +f.sessions, hours: +f.hours, price: +f.price, status: f.status, targetStudents: +f.targetStudents, desc: f.desc }); onDone(); };
return (
set('name', v)} />
set('en', v)} />
set('code', v)} />
set('sessions', v)} />
set('hours', v)} />
set('price', v)} />
set('targetStudents', v)} />
);
}
function CourseDrawer({ course, hasDiff, onClose }) {
const [edit, setEdit] = useState(false);
const cls = AAU.classesByCourse(course.id);
return (
{course.slug && }
>}>
{course.status === 'active' ? 'Đang mở' : 'Sắp ra mắt'}
{hasDiff &&
}
{edit ? setEdit(false)} /> : <>
{course.code} · {course.en}
{course.desc}
LỊCH KHAI GIẢNG ({cls.length})
{cls.length ? {cls.map(k => { const f = classFill(k); return (
{AAU.fmtDate(k.start)}{f.days > 0 ? ' · còn ' + f.days + ' ngày' : ''}
{k.patLabel} · {k.time}
{k.enrolled}/{k.cap} HV · {f.fill}%còn {f.slots} chỗ
); })}
: }
>}
);
}
/* ====================== LỚP / LỊCH KHAI GIẢNG ====================== */
function ClassesPage() {
useOps();
const [tab, setTab] = useState('list');
const [cls, setCls] = useState(null);
const [sync, setSync] = useState(false);
const diffIds = new Set(OpsStore.classDiffs().map(d => d.id));
const clsObj = cls && AAU.classById(cls.id);
const enrolling = AAU.classes.filter(c => c.status === 'enrolling');
const slotsLeft = AAU.classes.reduce((s, c) => s + Math.max(0, c.cap - c.enrolled), 0);
const soon7 = AAU.classes.filter(c => { const d = AAU.daysToStart(c); return d >= 0 && d <= 7; }).length;
const risk = AAU.classes.filter(c => classFill(c).lowFill).length;
return (
>} />
{tab === 'list'
? {[...AAU.classes].sort((a, b) => a.start.localeCompare(b.start)).map(c => setCls(c)} />)}
: }
{clsObj && setCls(null)} />}
{sync && setSync(false)} />}
);
}
function ClassCard({ c, diff, onOpen }) {
const f = classFill(c);
return (
{c.cat}
{c.courseName}
{c.batch}
{diff && }
KG {AAU.fmtDate(c.start)}
{c.patLabel} · {c.time}
GV chính: {AAU.instructorById(c.lead).name}
Lấp đầy: {c.enrolled}/{c.cap} · còn {f.slots} chỗ{f.fill}%
Doanh thu: {AAU.fmtVNDm(c.revenueActual)} / {AAU.fmtVNDm(c.revenueTarget)}
{f.days >= 0 ? còn {f.days} ngày tới KG : đã khai giảng}
{f.lowFill && Nguy cơ ế: {f.fill}%, còn {f.days} ngày — cần đẩy marketing
}
{f.almostFull && c.status === 'enrolling' && Sắp đầy — còn {f.slots} chỗ{c.waitlist ? ' · ' + c.waitlist + ' người chờ' : ''}
}
);
}
function ClassEditForm({ c, onDone }) {
const [f, setF] = useState({ batch: c.batch, cat: c.cat, start: c.start, patLabel: c.patLabel, time: c.time, cap: c.cap, enrolled: c.enrolled, n: c.n, lead: c.lead, alt: c.alt, roomId: c.roomId, brochure: c.brochure });
const set = (k, v) => setF(s => ({ ...s, [k]: v }));
const save = () => {
const pp = PATTERNS.find(p => p.label === f.patLabel);
OpsStore.updateClass(c.id, { batch: f.batch, cat: f.cat, start: f.start, patLabel: f.patLabel, pat: pp ? pp.pat : c.pat, time: f.time, cap: +f.cap, enrolled: +f.enrolled, n: +f.n, lead: f.lead, alt: f.alt, roomId: f.roomId, brochure: f.brochure });
onDone();
};
const insOpts = AAU.instructors.map(i => ({ value: i.id, label: i.name }));
return (
Đổi ngày KG / lịch học / số buổi sẽ tự sinh lại lịch từng buổi (giữ chỉnh sửa riêng theo số buổi).
set('batch', v)} />
set('cat', v)} />
set('start', v)} />
set('time', v)} />
set('n', v)} />
set('cap', v)} />
set('enrolled', v)} />
set('brochure', v)} />
);
}
function SessionEditModal({ c, s, onClose }) {
const [f, setF] = useState({ date: s.date, time: s.time, topic: s.topic, instructor: s.instructor, roomId: s.roomId });
const set = (k, v) => setF(x => ({ ...x, [k]: v }));
const save = () => { OpsStore.updateSession(c.id, s.n, { date: f.date, time: f.time, topic: f.topic, instructor: f.instructor, roomId: f.roomId }); onClose(); };
return (
>}>
);
}
function ClassDrawer({ c, hasDiff, onClose }) {
const [edit, setEdit] = useState(false);
const [sess, setSess] = useState(null);
const f = classFill(c);
const wl = AAU.waitlistByClass(c.id);
const insMap = {};
c.sessions.forEach(s => { insMap[s.instructor] = (insMap[s.instructor] || 0) + 1; });
return (
>}>
{c.cat}{c.batch}
{hasDiff ?
:
}
{edit ? setEdit(false)} /> : <>
= 0 ? 'còn ' + f.days + ' ngày' : 'đã KG'} />
{f.lowFill && Lớp nguy cơ ế — mới {f.fill}%, còn {f.days} ngày. Đề xuất chạy retarget + ưu đãi early-bird.
}
{f.almostFull && c.status === 'enrolling' && Sắp đầy — còn {f.slots} chỗ. {c.waitlist ? c.waitlist + ' người trong danh sách chờ.' : 'Cân nhắc mở thêm lớp.'}
}
LỊCH TỪNG BUỔI · GIẢNG VIÊN PHỤ TRÁCH
bấm ✎ để sửa từng buổi
{c.sessions.map(s => { const ins = AAU.instructorById(s.instructor); const done = s.status === 'done'; return (
{done ? : s.n}
{DOW_VN[new Date(s.date).getDay()]} · {AAU.fmtDate(s.date)}
{s.time}
{s.topic}
{AAU.roomById(s.roomId).name}
{ins.name.replace('Thầy ', '').replace('Cô ', '')}
{done && s.present != null ?
{s.present}/{c.enrolled} có mặt
:
{ins.title.split('·')[0]}
}
setSess(s)} />
); })}
{Object.entries(insMap).map(([id, n]) => { const i = AAU.instructorById(id); return (
); })}
{wl.length ? : Chưa có ai trong danh sách chờ. Khi lớp đầy, lead mới sẽ vào đây.
}
>}
{sess && setSess(null)} />}
);
}
/* ============================ ĐỒNG BỘ WEB ============================ */
function SyncModal({ scope, onClose }) {
useOps();
const diffs = scope === 'course' ? OpsStore.courseDiffs() : OpsStore.classDiffs();
const pull = scope === 'course' ? OpsStore.pullCourse : OpsStore.pullClass;
const title = scope === 'course' ? 'Đồng bộ Khóa học ↔ aau.vn' : 'Đồng bộ Lịch khai giảng ↔ aau.vn';
const page = scope === 'course' ? 'cac-khoa-hoc' : 'lich-khai-giang';
return (
Đồng bộ gần nhất: {OpsStore.lastSync()}
{diffs.length > 0 && }
>}>
Nguồn: aau.vn/pages/{page} (Shopify). Dưới đây là các mục CRM đang lệch so với web — chọn “Lấy theo web” để cập nhật.
{diffs.length === 0
?
: {diffs.map(d => (
{d.name}{d.batch ? ' · ' + d.batch : ''}
{d.fields.map(fl => (
{fl.label}
CRM: {fmtFieldVal(fl.key, fl.crm)}
Web: {fmtFieldVal(fl.key, fl.web)}
))}
))}
}
);
}
/* ============================ LỊCH THÁNG ============================ */
function monthGrid(year, month) {
const first = new Date(year, month, 1);
const start = (first.getDay() + 6) % 7;
const dim = new Date(year, month + 1, 0).getDate();
const cells = [];
for (let i = 0; i < start; i++) cells.push(null);
for (let d = 1; d <= dim; d++) cells.push(new Date(year, month, d));
while (cells.length % 7) cells.push(null);
const weeks = [];
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
return weeks;
}
function CalendarView({ onOpenClass }) {
const sessionsByDate = useMemo(() => {
const map = {};
AAU.classes.forEach(c => c.sessions.forEach(s => { (map[s.date] = map[s.date] || []).push({ ...s, cls: c }); }));
return map;
}, [AAU.classes.map(c => c.sessions.length + c.start).join()]);
const conflicts = useMemo(() => {
const out = {};
Object.entries(sessionsByDate).forEach(([date, list]) => {
const byIns = {};
list.forEach(s => { (byIns[s.instructor] = byIns[s.instructor] || []).push(s); });
const dup = Object.entries(byIns).filter(([, arr]) => arr.length >= 2).map(([id]) => id);
if (dup.length) out[date] = dup;
});
return out;
}, [sessionsByDate]);
const conflictDays = Object.keys(conflicts).length;
const todayISO = '2026-06-02';
const Month = ({ year, month, label }) => (
{DOW_VN.slice(1).concat(DOW_VN[0]).map(d =>
{d}
)}
{monthGrid(year, month).map((wk, wi) => (
{wk.map((d, di) => {
if (!d) return
;
const iso = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const list = (sessionsByDate[iso] || []).sort((a, b) => a.time.localeCompare(b.time));
const conf = conflicts[iso];
return (
{d.getDate()}
{list.map((s, i) => {
const warn = conf && conf.includes(s.instructor);
return
onOpenClass(s.cls)}>{s.time.split('–')[0]} {s.cls.cat}
;
})}
);
})}
))}
);
return (
{conflictDays ? conflictDays + ' ngày có giảng viên đứng ≥2 buổi (viền cam) — kiểm tra tránh trùng' : 'Không có giảng viên bị trùng lịch'}
{AAU.courseGroups.map(g => {g.label})}
);
}
Object.assign(window, { CourseCatalog, ClassesPage });