Initial commit: 교육 프로젝트 배포
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,490 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* legal_cert_print.js - 수료증 출력 클라이언트 스크립트
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 1. URL 쿼리 파라미터 파싱
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const year = urlParams.get('year') || '';
|
||||
const comp = urlParams.get('comp') || '';
|
||||
const memberId = urlParams.get('member_id') || '';
|
||||
const categoryGroup = urlParams.get('category_group') || '';
|
||||
|
||||
// 파라미터 유효성 검사
|
||||
if (!year || !comp || !memberId || !categoryGroup) {
|
||||
alert('잘못된 접근이거나 필수 출력 정보 파라미터가 누락되었습니다.');
|
||||
document.body.innerHTML = `
|
||||
<div style="padding: 50px; text-align: center; font-family: 'Noto Sans KR', sans-serif;">
|
||||
<h2 style="color: #e11d48; margin-bottom: 20px;">출력 오류</h2>
|
||||
<p style="color: #4b5563; font-size: 16px;">수료증을 조회하기 위한 파라미터(년도, 회사코드, 사번, 교육과정코드)가 올바르지 않습니다.</p>
|
||||
<button onclick="window.close()" style="margin-top: 20px; padding: 10px 20px; background: #4b5563; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;">창 닫기</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// API 요청 주소
|
||||
const requestUrl = `../bbs/get_legal_cert_print.php?year=${encodeURIComponent(year)}&comp=${encodeURIComponent(comp)}&member_id=${encodeURIComponent(memberId)}&category_group=${encodeURIComponent(categoryGroup)}`;
|
||||
|
||||
console.log('[CertPrint] Fetching certificate data from API...', requestUrl);
|
||||
|
||||
// 2. 백엔드 API에서 수료증 데이터 가져오기
|
||||
fetch(requestUrl)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(res => {
|
||||
console.log('[CertPrint] API Response:', res);
|
||||
|
||||
// 추출된 수료자 사번 리스트 (member_ids)를 개발자 도구 콘솔에 명시적으로 출력
|
||||
if (res.debug && res.debug.matched_member_ids) {
|
||||
console.log('[CertPrint] ★ 추출된 수료자 사번 리스트 (member_ids) ★:', res.debug.matched_member_ids);
|
||||
}
|
||||
|
||||
if (!res.success) {
|
||||
console.error('[CertPrint] Certificate load failed. Debug details:', res.debug || res);
|
||||
|
||||
let debugHtml = '';
|
||||
if (res.debug) {
|
||||
debugHtml = `
|
||||
<div style="margin-top: 20px; padding: 15px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; text-align: left; max-width: 600px; margin-left: auto; margin-right: auto; font-family: monospace; font-size: 13px; color: #334155; line-height: 1.5; overflow-x: auto;">
|
||||
<strong>[서버 디버그 정보]</strong><br/>
|
||||
- 입력 연도: ${res.debug.api_params?.year || '-'}<br/>
|
||||
- 입력 법인: ${res.debug.api_params?.comp || '-'}<br/>
|
||||
- 입력 사번: ${res.debug.api_params?.member_id || '-'}<br/>
|
||||
- 입력 과정: ${res.debug.api_params?.category_group || '-'}<br/>
|
||||
- 대상 과정 교육수: ${res.debug.total_legal_cnt || 0}개<br/>
|
||||
- 조건 충족 수료자수: ${res.debug.matched_member_ids?.length || 0}명 (${res.debug.matched_member_ids?.join(', ') || '없음'})<br/>
|
||||
- 프로시저 호출 정보: ${res.debug.procedure_calls?.length || 0}건 호출 시도
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
document.body.innerHTML = `
|
||||
<div style="padding: 50px; text-align: center; font-family: 'Noto Sans KR', sans-serif;">
|
||||
<h2 style="color: #e11d48; margin-bottom: 20px; font-weight: bold;"><i class="fa-solid fa-triangle-exclamation mr-2"></i>수료증 조회 실패</h2>
|
||||
<p style="color: #4b5563; font-size: 16px;">${res.message || '수료증 정보를 조회할 수 없습니다.'}</p>
|
||||
<p style="color: #6b7280; font-size: 13px; margin-top: 10px;">자세한 쿼리 파라미터는 브라우저 콘솔로그(F12)에서도 확인하실 수 있습니다.</p>
|
||||
${debugHtml}
|
||||
<button onclick="window.close()" style="margin-top: 20px; padding: 10px 20px; background: #4b5563; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 14px;">창 닫기</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const items = Array.isArray(res.data) ? res.data : [res.data];
|
||||
|
||||
const templatePage = document.querySelector('.cert-page');
|
||||
const parent = templatePage.parentNode;
|
||||
|
||||
items.forEach((item, idx) => {
|
||||
let currentPage = templatePage;
|
||||
if (idx > 0) {
|
||||
currentPage = templatePage.cloneNode(true);
|
||||
parent.appendChild(currentPage);
|
||||
}
|
||||
|
||||
// 3. 성명 정제 (이름 뒤에 사번이 대괄호로 오는 경우 정제 ex. 홍길동[M24031] -> 홍길동)
|
||||
let rawName = item.name || '';
|
||||
let cleanName = rawName;
|
||||
if (rawName.includes('[')) {
|
||||
cleanName = rawName.split('[')[0].trim();
|
||||
}
|
||||
|
||||
// 4. 발급번호 매핑 (앞뒤에 '제', '호' 붙이기)
|
||||
let certIssueNo = item.cert_issue_no || '';
|
||||
let formattedCertNo = certIssueNo;
|
||||
if (certIssueNo && !certIssueNo.startsWith('제')) {
|
||||
formattedCertNo = `제 ${certIssueNo} 호`;
|
||||
}
|
||||
|
||||
// 5. 프론트엔드 DOM 요소 바인딩 (각 복사된 페이지 내부 요소 쿼리)
|
||||
currentPage.querySelector('#val-cert-no').textContent = formattedCertNo || '제 호';
|
||||
currentPage.querySelector('#val-name').textContent = cleanName || '-';
|
||||
currentPage.querySelector('#val-category').textContent = item.category_name || '-';
|
||||
currentPage.querySelector('#val-period').textContent = item.period || '-';
|
||||
currentPage.querySelector('#val-hours').textContent = item.total_content_tm || '-';
|
||||
currentPage.querySelector('#val-prt-date').textContent = item.prt_dt || '- 년 - 월 - 일';
|
||||
currentPage.querySelector('#val-company').textContent = item.belong_comp || '-';
|
||||
currentPage.querySelector('#val-ceo').textContent = item.ceo_name || '-';
|
||||
|
||||
// 6. 기업별 동적 스탬프 및 로고 워터마크 파일 바인딩
|
||||
const watermarkImg = currentPage.querySelector('#val-watermark');
|
||||
const stampImg = currentPage.querySelector('#val-stamp');
|
||||
|
||||
// 로고 워터마크 이미지 바인딩
|
||||
if (item.logo_url && item.logo_url.trim() !== '') {
|
||||
watermarkImg.src = item.logo_url;
|
||||
watermarkImg.style.display = 'block';
|
||||
console.log(`[CertPrint] Page ${idx+1} Logo Watermark loaded:`, item.logo_url);
|
||||
} else {
|
||||
watermarkImg.style.display = 'none';
|
||||
console.log(`[CertPrint] Page ${idx+1} No Logo Watermark.`);
|
||||
}
|
||||
|
||||
// 스탬프 직인 이미지 바인딩
|
||||
if (item.stamp_url && item.stamp_url.trim() !== '') {
|
||||
stampImg.src = item.stamp_url;
|
||||
stampImg.style.display = 'block';
|
||||
console.log(`[CertPrint] Page ${idx+1} Signature Stamp loaded:`, item.stamp_url);
|
||||
} else {
|
||||
stampImg.style.display = 'none';
|
||||
console.log(`[CertPrint] Page ${idx+1} No Signature Stamp.`);
|
||||
}
|
||||
});
|
||||
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[CertPrint] Fetch Error:', err);
|
||||
alert('데이터 통신 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
// Deleted scratch file
|
||||
Reference in New Issue
Block a user