/* AAU CRM — Marketing Hub part 3: Channel Detail, Content Calendar,
Marketing Settings, Attribution models */
// channel field on leads/conversations uses legacy short ids
const LEGACY_CHAN = { facebook: 'fb', instagram: 'instagram', tiktok: 'tiktok', zalo: 'zalo', email: 'email' };
function ChannelDetail({ channelId }) {
const c = AAU.mktChannelById(channelId);
const t = useTimeRange('30d');
if (!c) return
navigate('/marketing/sources')}>Về Source Performance} />
;
const fs = AAU.mktFacts.filter(f => f.channel === channelId);
const spend = fs.reduce((s, f) => s + f.spend, 0);
const org = fs.reduce((s, f) => s + f.leadsOrg, 0);
const ads = fs.reduce((s, f) => s + f.leadsAds, 0);
const won = fs.reduce((s, f) => s + f.won, 0);
const msgs = fs.reduce((s, f) => s + f.messages, 0);
const leads = org + ads;
const fn = AAU.channelFunnel.find(x => x.channel === channelId);
const posts = AAU.contentPostsX.filter(p => p.channel === channelId);
const cps = AAU.campaigns.filter(cp => cp.platform === channelId);
const chLeads = AAU.leads.filter(l => l.channel === (LEGACY_CHAN[channelId] || '___'));
const kpis = [
{ l: 'Σ Lead', v: leads, sub: org + ' organic · ' + ads + ' ads' },
{ l: 'Spend', v: spend ? AAU.fmtVNDm(spend) : '—' },
{ l: 'CPL (ads)', v: ads ? AAU.fmtVND(spend / ads) : '—', crm: true },
{ l: 'Won (CRM)', v: won, crm: true },
{ l: 'CAC', v: won && spend ? AAU.fmtVNDm(spend / won) : '—', crm: true },
{ l: 'Bài đăng', v: posts.length },
];
return (
navigate('/marketing/sources') }, { label: c.label }]}
title={{c.label} } subtitle={'Chi tiết hiệu quả kênh ' + c.label + ' — organic, ads, nội dung và lead'}
actions={<> >} />
{c.label}
{c.kind === 'social' ? 'Mạng xã hội' : c.kind === 'messaging' ? 'Nhắn tin' : c.kind === 'search' ? 'Tìm kiếm' : c.kind === 'web' ? 'Website' : 'CRM'} · {c.hasAds ? 'Có chạy ads' : 'Chỉ organic'}
{kpis.map((k, i) => (
{k.crm && }{k.l}
{k.v}
{k.sub &&
{k.sub}
}
))}
{fn &&
}
{cps.length ? Campaign Spend Msg Lead CPL ROAS
{cps.map(cp => navigate('/marketing/ads/' + cp.id)}>{cp.name} {AAU.fmtVNDm(cp.spend)} {cp.messages} {cp.leads} {AAU.fmtVND(cp.spend / cp.leads)} {(cp.won * 14900000 / cp.spend).toFixed(1)}× )}
: }
{posts.length ? Bài đăng Format Đăng lúc Reach Eng% Msg Lead
{posts.map(p => {p.title} {p.format} {AAU.fmtDate(p.date)} {p.time} {AAU.fmtNum(p.reach)} {p.engagement}% {p.messages} {p.leads} )}
: }
{chLeads.length ? Lead Công ty Giai đoạn Phụ trách
{chLeads.map(l => navigate('/leads/' + l.id)}>{l.name} {l.company} {AAU.users.find(u => u.id === l.assignedTo)?.name || '—'} )}
: }
);
}
function ContentCalendar() {
const [chan, setChan] = useState('all');
const [mo, setMo] = useState(0); // months offset from June 2026
const cur = new Date(2026, 5 + mo, 1);
const year = cur.getFullYear(), month = cur.getMonth();
const isThisMonth = mo === 0;
const entries = isThisMonth ? AAU.contentCalendar.filter(e => chan === 'all' || e.channel === chan) : [];
const firstDow = (new Date(year, month, 1).getDay() + 6) % 7; // Monday-start
const daysInMonth = new Date(year, month + 1, 0).getDate();
const monthLabel = 'Tháng ' + (month + 1) + ', ' + year;
const cells = [];
for (let i = 0; i < firstDow; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
const evByDay = {};
entries.forEach(e => { (evByDay[e.day] = evByDay[e.day] || []).push(e); });
const counts = { posted: 0, scheduled: 0, draft: 0 };
AAU.contentCalendar.forEach(e => counts[e.status]++);
const statusLabel = { posted: 'Đã đăng', scheduled: 'Đã lên lịch', draft: 'Bản nháp' };
return (
c.id !== 'email').map(c => ({ value: c.id, label: c.label }))]} />
Lên lịch bài
>} />
setMo(m => m - 1)} title="Tháng trước" />{monthLabel} setMo(m => m + 1)} title="Tháng sau" />{!isThisMonth && setMo(0)}>Về tháng này }} subtitle="Màu viền = pillar nội dung · trạng thái theo nền chip"
actions={ Đã đăng Lên lịch Nháp
}>
{['T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'CN'].map(d =>
{d}
)}
{cells.map((d, i) => (
{d != null && <>
{d}{d === 2 && isThisMonth && Hôm nay }
{(evByDay[d] || []).map((e, j) => (
{e.title}
))}
>}
))}
);
}
function MarketingSettings() {
const b = AAU.mktBudget;
const [f, setF] = useState({
monthlyBudget: b.monthlyBudget / 1e6, cplTarget: b.cplTarget / 1e3, cplAlert: b.cplAlert / 1e3,
costMsgTarget: b.costMsgTarget / 1e3, costMsgAlert: b.costMsgAlert / 1e3, roasTarget: b.roasTarget,
});
const [saved, setSaved] = useState(false);
const set = (k, v) => { setF(s => ({ ...s, [k]: v })); setSaved(false); };
const num = (v) => parseFloat(String(v).replace(',', '.')) || 0;
function save() {
AAU.saveMktConfig({
monthlyBudget: num(f.monthlyBudget) * 1e6, cplTarget: num(f.cplTarget) * 1e3, cplAlert: num(f.cplAlert) * 1e3,
costMsgTarget: num(f.costMsgTarget) * 1e3, costMsgAlert: num(f.costMsgAlert) * 1e3, roasTarget: num(f.roasTarget),
});
setSaved(true);
}
const numRow = (k, label, unit, hint) => (
);
const [mAlerts, setMAlerts] = useState([
{ id: 'cpl', name: 'CPL vượt ngưỡng cảnh báo', note: 'Tô đỏ kênh/khóa + đẩy cảnh báo lên Overview', on: true },
{ id: 'pace', name: 'Pacing ngân sách > 100%', note: 'Báo khi chi vượt nhịp ngân sách tháng', on: true },
{ id: 'roas', name: 'ROAS dưới mục tiêu', note: 'Cảnh báo khi hiệu quả tụt dưới ngưỡng', on: true },
{ id: 'fill', name: 'Lớp sắp ế — cần đẩy ads', note: 'Fill thấp gần ngày khai giảng', on: true },
{ id: 'msg', name: 'Cost/Message vượt ngưỡng', note: 'Chi phí mỗi tin nhắn quá cao', on: false },
]);
return (
{saved && Đã lưu }Lưu cấu hình >} />
{numRow('monthlyBudget', 'Ngân sách quảng cáo / tháng', 'triệu ₫', 'Dùng cho pacing ở Overview')}
{numRow('roasTarget', 'Mục tiêu ROAS', '×', 'Ngưỡng hiệu quả tối thiểu')}
{numRow('cplTarget', 'CPL mục tiêu', 'nghìn ₫')}
{numRow('cplAlert', 'CPL cảnh báo', 'nghìn ₫', 'CPL vượt mức này sẽ tô đỏ + sinh cảnh báo')}
{numRow('costMsgTarget', 'Cost/Msg mục tiêu', 'nghìn ₫')}
{numRow('costMsgAlert', 'Cost/Msg cảnh báo', 'nghìn ₫')}
CPL hiện tại (blended) {AAU.fmtVND(AAU.mktSummary('channel').total.cplBlended)}
Ngưỡng cảnh báo {AAU.fmtVND(num(f.cplAlert) * 1e3)}
Pacing ngân sách {Math.round(AAU.mktBudget.spentMTD / (num(f.monthlyBudget) * 1e6) * 100)}%
{mAlerts.map(r => (
setMAlerts(a => a.map(x => x.id === r.id ? { ...x, on: v } : x))} />
))}
Cảnh báo hiển ở Marketing Overview và gửi in-app cho phụ trách khi bật.
);
}
function AttributionModelsBody() {
const [model, setModel] = useState('linear');
const rows = AAU.attributionByModel(model);
const totRev = rows.reduce((s, r) => s + r.revenue, 0);
const totLeads = rows.reduce((s, r) => s + r.leads, 0);
const donut = rows.map(r => ({ l: AAU.mktChannelById(r.channel).label, v: Math.round(r.revenue / 1e6), color: AAU.mktChannelById(r.channel).color }));
const modelDesc = {
first: 'Lần chạm ĐẦU — toàn bộ công ghi cho kênh khách tiếp xúc đầu tiên (đo kênh khám phá / top-funnel).',
last: 'Lần chạm CUỐI — toàn bộ công ghi cho kênh ngay trước khi chốt (đo kênh chốt đơn / bottom-funnel).',
linear: 'Tuyến tính — chia đều công cho mọi điểm chạm trong hành trình (cân bằng cả phễu).',
};
return (
<>
Mô hình ghi công chuyển đổi
{({ first: 'Lần chạm đầu', last: 'Lần chạm cuối', linear: 'Tuyến tính' })[model]}: {modelDesc[model]}
({ l: d.l, color: d.color }))} />
Kênh Lead Won Doanh thu % doanh thu
{rows.map(r => (
navigate('/marketing/channel/' + r.channel)}>
{r.leads} {r.won}
{AAU.fmtVNDm(r.revenue)}
))}
{AAU.touchPaths.map((p, i) => (
{p.path.map((ch, j) => (
{j > 0 && }
))}
{p.leads} lead {p.won} won {AAU.fmtVNDm(p.revenue)}
))}
>
);
}
Object.assign(window, { ChannelDetail, ContentCalendar, MarketingSettings, AttributionModelsBody });