Files
edu/admin/js/dashboard.js
T

491 lines
22 KiB
JavaScript

const dashboard = {
state: {
year: new Date().getFullYear(),
quarter: Math.ceil((new Date().getMonth() + 1) / 3).toString(),
category: 'ALL',
data: null
},
init: function () {
// Read initial values from DOM if possible
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('year')) this.state.year = parseInt(urlParams.get('year'), 10);
if (urlParams.has('quarter')) {
const q = urlParams.get('quarter');
if (q !== 'NaN') this.state.quarter = q;
} else {
const selectQ = document.getElementById('select_quarter');
if (selectQ && selectQ.value) {
this.state.quarter = selectQ.value;
}
}
this.updatePeriodText();
this.bindEvents();
this.fetchData();
},
bindEvents: function () {
document.querySelectorAll('#tab_popular_categories button').forEach(btn => {
btn.addEventListener('click', (e) => {
// Update UI
document.querySelectorAll('#tab_popular_categories button').forEach(b => {
b.classList.remove('bg-[#114b3d]', 'text-white');
b.classList.add('text-gray-500', 'hover:bg-gray-50');
});
e.target.classList.remove('text-gray-500', 'hover:bg-gray-50');
e.target.classList.add('bg-[#114b3d]', 'text-white');
// Filter Data
this.state.category = e.target.dataset.category;
this.renderPopularContents();
});
});
},
changeYear: function (year) {
this.state.year = year;
// Update URL to maintain state (optional but good for refresh)
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('year', year);
window.history.replaceState({}, '', '?' + urlParams.toString());
// Update buttons UI
const btnPrev = document.getElementById('btn_year_prev');
const btnCurr = document.getElementById('btn_year_curr');
const currentYear = new Date().getFullYear();
if(year === currentYear) {
btnCurr.className = "px-3 py-1.5 text-sm rounded transition bg-[#114b3d] text-white shadow-sm font-bold";
btnPrev.className = "px-3 py-1.5 text-sm rounded transition text-gray-500 hover:bg-gray-50";
} else {
btnPrev.className = "px-3 py-1.5 text-sm rounded transition bg-[#114b3d] text-white shadow-sm font-bold";
btnCurr.className = "px-3 py-1.5 text-sm rounded transition text-gray-500 hover:bg-gray-50";
}
this.updatePeriodText();
this.fetchData();
},
changeQuarter: function (quarter) {
this.state.quarter = quarter;
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('quarter', quarter);
window.history.replaceState({}, '', '?' + urlParams.toString());
this.updatePeriodText();
this.fetchData();
},
updatePeriodText: function() {
let qNum = this.state.quarter;
if (typeof qNum === 'string' && qNum.startsWith('CA200')) {
qNum = parseInt(qNum.slice(-1), 10);
} else {
qNum = parseInt(qNum, 10);
}
if (isNaN(qNum) || qNum < 1 || qNum > 4) qNum = 1;
const startMonth = (qNum - 1) * 3 + 1;
const endMonth = qNum * 3;
const endDay = new Date(this.state.year, endMonth, 0).getDate();
const startStr = `${this.state.year}-${String(startMonth).padStart(2, '0')}-01`;
const endStr = `${this.state.year}-${String(endMonth).padStart(2, '0')}-${endDay}`;
document.getElementById('txt_period_start').innerText = startStr;
document.getElementById('txt_period_end').innerText = endStr;
},
fetchData: function () {
fetch(`../bbs/get_dashboard_info.php?year=${this.state.year}&quarter=${this.state.quarter}`)
.then(res => res.json())
.then(res => {
if (res.success) {
this.state.data = res.data;
this.renderAll();
} else {
console.error("Failed to fetch dashboard data", res.message);
}
})
.catch(err => console.error("Error parsing JSON:", err));
},
renderAll: function () {
this.renderKPI();
this.renderCorpAccess();
this.renderStackedBars();
this.renderContentUsage();
this.renderPopularContents();
},
renderKPI: function () {
const kpi = this.state.data.kpi;
if (!kpi) return;
// 1. 전체 접속률
const accessQty = parseInt(kpi.access_qty || 0);
const accessAll = parseInt(kpi.quarter_qty || 0);
const accessRate = accessAll > 0 ? Math.round((accessQty / accessAll) * 100) : 0;
const accessQtyU = parseInt(kpi.access_qty_u || 0);
const accessAllU = parseInt(kpi.quarter_qty_u || 0);
const accessRateU = accessAllU > 0 ? Math.round((accessQtyU / accessAllU) * 100) : 0;
document.getElementById('kpi_access_rate_current').innerHTML = `${accessRate}<span class="text-lg font-bold">%</span>`;
document.getElementById('kpi_access_desc_current').innerText = `${accessAll.toLocaleString()}명 중 ${accessQty.toLocaleString()}명 접속`;
document.getElementById('kpi_access_rate_prev').innerHTML = `${accessRateU}<span class="text-sm font-bold">%</span>`;
document.getElementById('kpi_access_desc_prev').innerText = `${accessAllU.toLocaleString()}명 중 ${accessQtyU.toLocaleString()}명 접속`;
setTimeout(() => {
document.getElementById('kpi_access_bar_current').style.width = `${accessRate}%`;
document.getElementById('kpi_access_bar_prev').style.width = `${accessRateU}%`;
}, 100);
// 2. 법정의무교육 이수율
const legalRate = kpi.computed_legal_rate || 0;
const legalAll = parseInt(kpi.legal_qty_all || 0);
const legalY = parseInt(kpi.legal_qty_y || 0);
const legalUncompleted = kpi.computed_legal_uncompleted || 0;
document.getElementById('kpi_legal_uncompleted').innerText = `미이수 ${legalUncompleted.toLocaleString()}명 잔여`;
document.getElementById('kpi_legal_rate').innerHTML = `${legalRate}<span class="text-lg">%</span>`;
document.getElementById('kpi_legal_desc').innerText = `이수 ${legalY.toLocaleString()}명 / 전체 ${legalAll.toLocaleString()}명`;
setTimeout(() => {
document.getElementById('kpi_legal_bar').style.width = `${legalRate}%`;
}, 100);
// 3. 마이클래스
const mcTargetRate = kpi.myclass_target_rate || 0;
const mcTargetQty = kpi.myclass_target_qty || 0;
const mcAchieveRate = kpi.myclass_achieve_rate || 0;
const mcAchieveQty = kpi.myclass_achieve_qty || 0;
document.getElementById('kpi_myclass_target_rate').innerHTML = `${mcTargetRate}<span class="text-sm">%</span>`;
document.getElementById('kpi_myclass_target_desc').innerText = `설정 ${parseInt(mcTargetQty).toLocaleString()}명`;
document.getElementById('kpi_myclass_achieve_rate').innerHTML = `${mcAchieveRate}<span class="text-sm">%</span>`;
document.getElementById('kpi_myclass_achieve_desc').innerText = `달성 ${parseInt(mcAchieveQty).toLocaleString()}명`;
setTimeout(() => {
document.getElementById('kpi_myclass_bar').style.width = `${Math.min(mcAchieveRate, 100)}%`;
}, 100);
// 4. 접속자 1인당 완주 콘텐츠
const contentPerUser = kpi.computed_content_per_user || 0;
const contentDiff = kpi.computed_content_per_user_diff || 0;
const completedQty = parseInt(kpi.completed_qty || 0);
const diffIcon = contentDiff > 0 ? '<i class="fa-solid fa-caret-up"></i>' : (contentDiff < 0 ? '<i class="fa-solid fa-caret-down text-red-500"></i>' : '-');
const diffText = contentDiff > 0 ? `+${contentDiff}` : contentDiff;
document.getElementById('kpi_content_diff').innerHTML = `${diffIcon} 전분기 대비 ${diffText}편`;
document.getElementById('kpi_content_per_user').innerHTML = `${contentPerUser}<span class="text-lg">편</span>`;
document.getElementById('kpi_content_desc').innerHTML = `접속자 ${accessQty.toLocaleString()}명 기준<br>총 완수 ${completedQty.toLocaleString()}회`;
const contentRate = accessQty > 0 ? (completedQty / accessQty) * 100 : 0;
setTimeout(() => {
document.getElementById('kpi_content_bar').style.width = `${Math.min(contentRate, 100)}%`;
}, 100);
},
renderCorpAccess: function () {
const tbody = document.getElementById('tbody_corp_access');
const data = this.state.data.corp_access_rate || [];
tbody.innerHTML = '';
data.forEach(item => {
// Note: DB doesn't currently provide prev_rate for corp in the query, defaulting to -
// If the query is updated, replace `0` with `item.prev_diff` or similar.
const diff = 0;
const diffHtml = diff > 0
? `<span class="text-red-500 text-[10px]">▲ ${diff}%p</span>`
: (diff < 0 ? `<span class="text-blue-500 text-[10px]">▼ ${Math.abs(diff)}%p</span>` : `<span class="text-gray-400 text-[10px]">-</span>`);
tbody.innerHTML += `
<tr class="hover:bg-gray-50/50 transition">
<td class="py-2.5 px-5 text-gray-700 font-medium">${item.code_name}</td>
<td class="py-2.5 px-2 text-center">${diffHtml}</td>
<td class="py-2.5 px-2 text-center text-gray-500">${parseInt(item.all_qty).toLocaleString()}명</td>
<td class="py-2.5 px-2 text-center text-gray-800 font-bold">${parseInt(item.access_qty || 0).toLocaleString()}명</td>
<td class="py-2.5 px-2 text-center font-bold text-[#114b3d]">${item.access_rate}%</td>
<td class="py-2.5 px-5 text-right text-gray-400">${parseInt(item.no_access_qty).toLocaleString()}명</td>
</tr>
`;
});
},
renderStackedBars: function () {
// 1. 학습자 접속 빈도
const freq = this.state.data.access_freq || {};
const lv1 = parseInt(freq.active_lv01 || 0);
const lv2 = parseInt(freq.normal_lv02 || 0);
const lv3 = parseInt(freq.low_lv03 || 0);
const lv4 = parseInt(freq.none_lv04 || 0);
const totalFreq = lv1 + lv2 + lv3 + lv4;
if(totalFreq > 0) {
const elFreq = document.getElementById('bar_access_freq').children;
const w1 = (lv1 / totalFreq) * 100;
const w2 = (lv2 / totalFreq) * 100;
const w3 = (lv3 / totalFreq) * 100;
const w4 = (lv4 / totalFreq) * 100;
setTimeout(() => {
elFreq[0].style.width = `${w1}%`; elFreq[0].innerText = w1 >= 5 ? `${Math.round(w1)}%` : '';
elFreq[1].style.width = `${w2}%`; elFreq[1].innerText = w2 >= 5 ? `${Math.round(w2)}%` : '';
elFreq[2].style.width = `${w3}%`; elFreq[2].innerText = w3 >= 5 ? `${Math.round(w3)}%` : '';
elFreq[3].style.width = `${w4}%`; elFreq[3].innerText = w4 >= 5 ? `${Math.round(w4)}%` : '';
}, 100);
}
// 2. 접속 시간대
const time = this.state.data.access_time || {};
const wt = parseInt(time.worktime || 0);
const lt = parseInt(time.lunchtime || 0);
const ot = parseInt(time.outtime || 0);
const totalTime = parseInt(time.alltime || 0);
if(totalTime > 0) {
const elTime = document.getElementById('bar_access_time').children;
const t1 = (wt / totalTime) * 100;
const t2 = (lt / totalTime) * 100;
const t3 = (ot / totalTime) * 100;
setTimeout(() => {
elTime[0].style.width = `${t1}%`; elTime[0].innerText = t1 >= 5 ? `${Math.round(t1)}%` : '';
elTime[1].style.width = `${t2}%`; elTime[1].innerText = t2 >= 5 ? `${Math.round(t2)}%` : '';
elTime[2].style.width = `${t3}%`; elTime[2].innerText = t3 >= 5 ? `${Math.round(t3)}%` : '';
}, 100);
}
// 3. 접속 방법
const device = this.state.data.access_device || {};
const pc = parseInt(device.pc || 0);
const mob = parseInt(device.mobile || 0);
const totalDev = parseInt(device.alldevice || 0);
if(totalDev > 0) {
const elDev = document.getElementById('bar_access_device').children;
const d1 = (pc / totalDev) * 100;
const d2 = (mob / totalDev) * 100;
setTimeout(() => {
elDev[0].style.width = `${d1}%`; elDev[0].innerText = d1 >= 5 ? `${Math.round(d1)}%` : '';
elDev[1].style.width = `${d2}%`; elDev[1].innerText = d2 >= 5 ? `${Math.round(d2)}%` : '';
}, 100);
}
},
renderContentUsage: function () {
const usage = this.state.data.content_usage || {};
const newQty = this.state.data.new_content_qty || {};
const total = parseInt(usage.tot_qty || 0);
const container = document.getElementById('list_content_usage');
container.innerHTML = '';
const categories = [
{ key: 'myclass', name: '마이클래스', icon: 'fa-graduation-cap', color: '#2563eb' },
{ key: 'insight', name: '인사이트', icon: 'fa-lightbulb', color: '#22c55e' },
{ key: 'leader', name: '리더십', icon: 'fa-user-tie', color: '#8b5cf6' },
{ key: 'biz', name: '비즈트렌드', icon: 'fa-chart-line', color: '#f97316' }
];
categories.forEach(cat => {
const useQty = parseInt(usage[`${cat.key}_use_qty`] || 0);
const rate = total > 0 ? (useQty / total) * 100 : 0;
const newCount = parseInt(newQty[`${cat.key}_new_qty`] || 0);
const badgeHtml = `<span class="px-2 py-0.5 rounded text-[9px] font-bold" style="color: ${cat.color}; background-color: ${cat.color}20">신규 콘텐츠 ${newCount}편</span>`;
container.innerHTML += `
<div>
<div class="flex justify-between items-center mb-1">
<div class="flex items-center gap-2">
<i class="fa-solid ${cat.icon} text-gray-400"></i>
<span class="font-bold text-gray-800 text-xs">${cat.name}</span>
</div>
${badgeHtml}
</div>
<div class="flex items-baseline gap-2 mb-2">
<span class="font-extrabold text-lg" style="color: ${cat.color}">${rate.toFixed(1)}%</span>
<span class="text-[10px] text-gray-400">시청수 ${useQty.toLocaleString()}회</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-1">
<div class="h-1 rounded-full transition-all duration-1000" style="width: 0%; background-color: ${cat.color}" data-width="${rate}%"></div>
</div>
</div>
`;
});
// Trigger animations
setTimeout(() => {
container.querySelectorAll('[data-width]').forEach(el => {
el.style.width = el.dataset.width;
});
}, 100);
},
renderPopularContents: function () {
const tbody = document.getElementById('tbody_popular_contents');
let data = this.state.data.popular_contents || [];
// Filter
if (this.state.category !== 'ALL') {
data = data.filter(item => item.category_code === this.state.category);
}
tbody.innerHTML = '';
if (data.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" class="py-10 text-center text-gray-400">조회된 콘텐츠가 없습니다.</td></tr>`;
return;
}
data.forEach((item, index) => {
const categoryColor = {
'CA10001': 'text-blue-500',
'CA10005': 'text-green-500',
'CA10004': 'text-purple-500',
'CA10006': 'text-orange-500'
}[item.category_code] || 'text-gray-500';
const compRate = item.completed_rate ? parseFloat(item.completed_rate).toFixed(1) : 0;
tbody.innerHTML += `
<tr class="hover:bg-gray-50/50 transition">
<td class="py-2.5 px-5 text-center font-bold text-gray-800">${index + 1}</td>
<td class="py-2.5 px-2 font-medium text-gray-700 truncate max-w-[150px] lg:max-w-[200px] xl:max-w-[300px]" title="${item.content_title}">
${item.content_title}
</td>
<td class="py-2.5 px-2 text-center text-[10px] font-medium ${categoryColor}">${item.category_name}</td>
<td class="py-2.5 px-2 text-center font-bold text-gray-800">${parseInt(item.view_count).toLocaleString()}</td>
<td class="py-2.5 px-2 text-center text-gray-600">${compRate}%</td>
<td class="py-2.5 px-5 text-center text-gray-600 font-bold">${parseInt(item.comment_cnt || 0).toLocaleString()}</td>
</tr>
`;
});
}
};
const myclassModal = {
state: {
activeTab: 'goals',
data: null
},
open: function() {
document.getElementById('modal_myclass').classList.remove('hidden');
document.getElementById('modal_myclass_quarter').value = dashboard.state.quarter;
this.fetchData();
},
close: function() {
document.getElementById('modal_myclass').classList.add('hidden');
},
fetchData: function(q) {
const year = dashboard.state.year;
const quarter = q || document.getElementById('modal_myclass_quarter').value;
document.getElementById('modal_list_tbody').innerHTML = '<tr><td colspan="2" class="text-center py-10 text-gray-400">데이터를 불러오는 중입니다...</td></tr>';
fetch(`../bbs/get_myclass_details.php?year=${year}&quarter=${quarter}`)
.then(res => res.json())
.then(res => {
if(res.success) {
this.state.data = res.data;
this.renderKPI();
this.renderList();
} else {
alert('데이터 조회 실패: ' + res.error);
}
})
.catch(err => {
alert('통신 오류: ' + err.message);
});
},
switchTab: function(tab) {
this.state.activeTab = tab;
const btnGoals = document.getElementById('tab_btn_goals');
const btnComps = document.getElementById('tab_btn_comps');
if(tab === 'goals') {
btnGoals.className = "py-3 text-sm font-bold border-b-2 border-blue-600 text-blue-600 transition";
btnComps.className = "py-3 text-sm font-bold border-b-2 border-transparent text-gray-400 hover:text-gray-600 transition";
document.getElementById('modal_table_th1').innerText = "목표";
} else {
btnComps.className = "py-3 text-sm font-bold border-b-2 border-blue-600 text-blue-600 transition";
btnGoals.className = "py-3 text-sm font-bold border-b-2 border-transparent text-gray-400 hover:text-gray-600 transition";
document.getElementById('modal_table_th1').innerText = "법인";
}
this.renderList();
},
renderKPI: function() {
const kpi = this.state.data.kpi;
document.getElementById('modal_mc_target_rate').innerHTML = `${kpi.target_rate}<span class="text-lg">%</span>`;
document.getElementById('modal_mc_target_desc').innerText = `선택자 ${kpi.goals_qty.toLocaleString()}명 / 전체 ${kpi.quarter_qty.toLocaleString()}명`;
document.getElementById('modal_mc_achieve_rate').innerHTML = `${kpi.achieve_rate}<span class="text-lg">%</span>`;
document.getElementById('modal_mc_achieve_desc').innerText = `달성자 ${kpi.goals_completed_qty.toLocaleString()}명 / 선택자 ${kpi.goals_qty.toLocaleString()}명`;
document.getElementById('modal_mc_unselect_qty').innerHTML = `${kpi.unselected_qty.toLocaleString()}<span class="text-lg">명</span>`;
document.getElementById('modal_mc_unselect_desc').innerText = `전체 학습자의 ${kpi.unselected_rate}%`;
},
renderList: function() {
const tbody = document.getElementById('modal_list_tbody');
document.getElementById('modal_table_thead').classList.remove('hidden');
tbody.innerHTML = '';
const list = this.state.activeTab === 'goals' ? this.state.data.goals : this.state.data.comps;
if(!list || list.length === 0) {
tbody.innerHTML = '<tr><td colspan="2" class="text-center py-10 text-gray-400">데이터가 없습니다.</td></tr>';
return;
}
list.forEach(item => {
const title = this.state.activeTab === 'goals' ? item.title : item.comp_name;
let qty = 0;
let rate = 0;
if(this.state.activeTab === 'goals') {
qty = parseInt(item.goal_qty || 0);
const allQty = parseInt(this.state.data.kpi.goals_qty || 0);
rate = allQty > 0 ? (qty / allQty) * 100 : 0;
} else {
qty = parseInt(item.comp_qty || 0);
const allQty = parseInt(item.all_qty || 0);
rate = allQty > 0 ? (qty / allQty) * 100 : 0;
}
const displayRate = rate.toFixed(1);
tbody.innerHTML += `
<tr>
<td class="py-3 px-4 font-bold text-gray-700 w-1/3 pr-8 break-keep">${title}</td>
<td class="py-3 px-4 w-2/3">
<div class="flex items-center gap-3 justify-end">
<div class="w-full bg-gray-100 rounded-full h-1.5 flex-1">
<div class="bg-[#114b3d] h-1.5 rounded-full" style="width: ${Math.min(rate, 100)}%"></div>
</div>
<div class="font-bold text-gray-800 w-12 text-right">${displayRate}%</div>
</div>
</td>
</tr>
`;
});
}
};
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
dashboard.init();
});