Initial commit: 교육 프로젝트 배포

This commit is contained in:
송대일
2026-07-01 18:32:42 +09:00
commit be6dccd120
1483 changed files with 5082202 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
<?php
require __DIR__ . '/../../bbs/db_conn.php';
// PDO 연결 생성
$pdo = db_conn();
header('Content-Type: application/json; charset=utf-8');
$type = isset($_GET['type']) ? $_GET['type'] : '';
/* 콘텐츠 상세(수정 모달용) */
if ($type === 'content_detail') {
$content_id = isset($_GET['content_id']) ? trim($_GET['content_id']) : '';
if ($content_id === '') {
echo json_encode(['success' => false, 'message' => 'content_id 가 필요합니다.']);
exit;
}
$stmt = $pdo->prepare("SELECT * FROM edu_contents WHERE content_id = :c LIMIT 1");
$stmt->execute([':c' => $content_id]);
$item = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$item) {
echo json_encode(['success' => false, 'message' => '콘텐츠를 찾을 수 없습니다.']);
exit;
}
$json = json_encode(['success' => true, 'item' => $item], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
if ($json === false) {
echo json_encode(['success' => false, 'message' => 'JSON encoding failed: ' . json_last_error_msg()]);
} else {
echo $json;
}
exit;
}
/* 콘텐츠 포스트잇(메모) 목록 조회 */
if ($type === 'content_memos') {
$content_id = isset($_GET['content_id']) ? trim($_GET['content_id']) : '';
if ($content_id === '') {
echo json_encode(['success' => false, 'message' => 'content_id 가 필요합니다.']);
exit;
}
$stmt = $pdo->prepare("SELECT content_id, seq, title, is_active, created_at, updated_at
FROM edu_content_memos
WHERE content_id = :c
ORDER BY seq ASC
LIMIT 3");
$stmt->execute([':c' => $content_id]);
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'items' => $items]);
exit;
}
/* 학습목표 추천이유 목록 조회 */
if ($type === 'goal_recommends') {
$goal_code = isset($_GET['goal_code']) ? trim($_GET['goal_code']) : '';
if ($goal_code === '') {
echo json_encode(['success' => false, 'message' => 'goal_code 가 필요합니다.']);
exit;
}
$stmt = $pdo->prepare("SELECT goal_code, seq, title, title2, is_active, created_at, updated_at
FROM edu_recommended_goals
WHERE goal_code = :g
ORDER BY seq ASC");
$stmt->execute([':g' => $goal_code]);
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'items' => $items]);
exit;
}
/* 추천 키워드 목록 조회 */
if ($type === 'recommend_keywords') {
$stmt = $pdo->prepare("SELECT keyword_code FROM edu_recommend_keywords WHERE is_active='1'");
$stmt->execute();
$items = $stmt->fetchAll(PDO::FETCH_COLUMN);
echo json_encode(['items' => $items]);
exit;
}
/* 카테고리 */
if ($type === 'category') {
$stmt = $pdo->prepare("CALL proc_get_code_list('CA100')");
$stmt->execute();
echo json_encode(['items' => $stmt->fetchAll()]);
exit;
}
/* 카테고리구분 */
if ($type === 'category_group') {
$desc01 = isset($_GET['desc01']) ? $_GET['desc01'] : '';
$stmt = $pdo->prepare("CALL proc_get_sub_codes('CA200', :d)");
$stmt->execute([':d' => $desc01]);
echo json_encode(['items' => $stmt->fetchAll()]);
exit;
}
/* 새 콘텐츠 등록 학습목표 코드 */
if ($type === 'learning_goal') {
$year = isset($_GET['year']) ? $_GET['year'] : date('Y');
$categoryGroup = isset($_GET['category_group']) ? $_GET['category_group'] : '';
// 기준년도 + 카테고리구분(중분류)을 함께 사용하는 조회
// 카테고리구분(category_group)을 변수 :p 로 받아 edu_learning_goals.quarter 에 매핑합니다.
$sql = "SELECT goal_code AS code, title AS name
FROM edu_learning_goals
WHERE base_year = :y
AND is_active = '1'";
$params = [':y' => $year];
// 카테고리 그룹이 넘어온 경우에만 조건 추가
if ($categoryGroup !== '') {
$sql .= " AND quarter = :p";
$params[':p'] = $categoryGroup;
}
$sql .= " ORDER BY quarter , sort_order";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
echo json_encode(['items' => $stmt->fetchAll()]);
exit;
}
/* 학습목표 목록 */
if ($type === 'learning_goal_all') {
$year = isset($_GET['year']) ? $_GET['year'] : date('Y');
$quarter = isset($_GET['quarter']) ? $_GET['quarter'] : '';
$sql = "SELECT goal_code, title, quarter, fn_get_code_name(quarter) AS quarter_name, is_active, remarks, sort_order, goal_no, fn_get_code_name(goal_no) AS goal_no_name FROM edu_learning_goals WHERE base_year=:y AND is_active='1' ";
$params = [':y' => $year];
if ($quarter !== '') {
$sql .= " AND quarter=:q";
$params[':q'] = $quarter;
}
$sql .= " ORDER BY quarter DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['items' => $items]);
exit;
}
/* 키워드 */
if ($type === 'content_keywords') {
$content_id = isset($_GET['content_id']) ? $_GET['content_id'] : '';
if ($content_id === '') {
echo json_encode(['items' => []]);
exit;
}
$stmt = $pdo->prepare("SELECT keyword_code FROM edu_content_keywords WHERE content_id=:c AND is_active='1'");
$stmt->execute([':c' => $content_id]);
$items = $stmt->fetchAll(PDO::FETCH_COLUMN);
echo json_encode(['items' => $items]);
exit;
}
/*인사이트 이슈구분 코드*/
if ($type === 'issue_type_code') {
$stmt = $pdo->prepare("CALL proc_get_code_list('IS100')");
$stmt->execute();
echo json_encode(['items' => $stmt->fetchAll()]);
exit;
}
echo json_encode(['items' => []]);
+65
View File
@@ -0,0 +1,65 @@
<?php
require __DIR__ . '/../../bbs/db_conn.php';
// PDO 연결 생성
$pdo = db_conn();
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => '잘못된 요청입니다.']);
exit;
}
$content_id = isset($_POST['content_id']) ? trim($_POST['content_id']) : '';
if ($content_id === '') {
echo json_encode(['success' => false, 'message' => '콘텐츠 ID가 필요합니다.']);
exit;
}
try {
// 1. 제약사항 검사: 학습이력(edu_learning_histories) 또는 찜목록(edu_content_wishlist) 확인
$check_stmt = $pdo->prepare("
SELECT
(SELECT COUNT(*) FROM edu_learning_histories WHERE content_id = :content_id1) as history_count,
(SELECT COUNT(*) FROM edu_content_wishlist WHERE content_id = :content_id2) as wishlist_count
");
$check_stmt->execute([
':content_id1' => $content_id,
':content_id2' => $content_id
]);
$check_result = $check_stmt->fetch(PDO::FETCH_ASSOC);
if ($check_result['history_count'] > 0 || $check_result['wishlist_count'] > 0) {
echo json_encode(['success' => false, 'message' => '학습이력이나 찜 목록에 존재하는 콘텐츠는 삭제할 수 없습니다.']);
exit;
}
// 2. 연관 데이터 삭제를 위한 트랜잭션 시작
$pdo->beginTransaction();
// 키워드 삭제
$stmt_keywords = $pdo->prepare("DELETE FROM edu_content_keywords WHERE content_id = :content_id");
$stmt_keywords->execute([':content_id' => $content_id]);
// 포스트잇(메모) 삭제
$stmt_memos = $pdo->prepare("DELETE FROM edu_content_memos WHERE content_id = :content_id");
$stmt_memos->execute([':content_id' => $content_id]);
// 본 콘텐츠 삭제
$stmt_content = $pdo->prepare("DELETE FROM edu_contents WHERE content_id = :content_id");
$stmt_content->execute([':content_id' => $content_id]);
// 3. 트랜잭션 커밋
$pdo->commit();
echo json_encode(['success' => true]);
} catch (PDOException $e) {
// 오류 발생 시 롤백
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
echo json_encode(['success' => false, 'message' => '삭제 실패: ' . $e->getMessage()]);
}
+221
View File
@@ -0,0 +1,221 @@
<?php
require __DIR__ . '/../../bbs/db_conn.php';
require_once __DIR__ . '/../../bbs/auth.php';
edu_start_session();
// 로그인 사용자 member_id 추출
$login_member_id = $_SESSION['member_id'] ?? null;
// PDO 연결 생성
$pdo = db_conn();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../skin/content_upload.php');
exit;
}
function generate_content_id(PDO $pdo): string
{
$prefix = date('Ym') . '-';
$stmt = $pdo->prepare("SELECT MAX(content_id) AS max_id FROM edu_contents WHERE content_id LIKE :pfx");
$stmt->execute([':pfx' => $prefix . '%']);
$max = $stmt->fetchColumn();
$next = 1;
if ($max) {
$seq = (int) substr($max, -3);
$next = $seq + 1;
}
return $prefix . str_pad((string) $next, 3, '0', STR_PAD_LEFT);
}
$content_id = isset($_POST['content_id']) ? trim($_POST['content_id']) : '';
$category_code = isset($_POST['category_code']) ? trim($_POST['category_code']) : '';
if ($category_code === '' && isset($_POST['category_code_hidden'])) {
$category_code = trim($_POST['category_code_hidden']);
}
$title = isset($_POST['title']) ? trim($_POST['title']) : '';
$description = isset($_POST['description']) ? trim($_POST['description']) : '';
$description1 = isset($_POST['description1']) ? trim($_POST['description1']) : '';
$description2 = isset($_POST['description2']) ? trim($_POST['description2']) : '';
$description3 = isset($_POST['description3']) ? trim($_POST['description3']) : '';
$content_url = isset($_POST['content_url']) ? trim($_POST['content_url']) : '';
$thumbnail_url = isset($_POST['thumbnail_url']) ? trim($_POST['thumbnail_url']) : '';
$sort_order = isset($_POST['sort_order']) && $_POST['sort_order'] !== '' ? (int) $_POST['sort_order'] : null;
$keywords = isset($_POST['keywords']) ? trim($_POST['keywords']) : '';
$is_active = isset($_POST['is_active']) ? '1' : '0';
$content_tm = isset($_POST['content_tm']) && $_POST['content_tm'] !== '' ? (int) $_POST['content_tm'] : null;
// 카테고리별 추가 필드
$base_year = null;
$goal_code = null;
$category_group = null;
$issue_type_code = null;
$start_date = null;
$end_date = null;
$is_offer = '0';
$offer_id = null;
$book_yn = '0'; // 사내도서여부 기본값
// 카테고리 이름 조회
$catName = null;
if ($category_code !== '') {
$nstmt = $pdo->prepare("SELECT code_name FROM edu_codes WHERE group_code='CA100' AND base_code=:c LIMIT 1");
$nstmt->execute([':c' => $category_code]);
$catName = $nstmt->fetchColumn();
}
if ($catName === '마이클래스') {
$base_year = isset($_POST['base_year']) ? trim($_POST['base_year']) : null;
$goal_code = isset($_POST['goal_code']) ? trim($_POST['goal_code']) : null;
}
// 공통 카테고리구분
$category_group = isset($_POST['category_group']) ? trim($_POST['category_group']) : null;
if ($catName === '법정교육') {
$base_year = isset($_POST['base_year_law']) ? trim($_POST['base_year_law']) : null;
$start_date = isset($_POST['start_date']) ? $_POST['start_date'] : null;
$end_date = isset($_POST['end_date']) ? $_POST['end_date'] : null;
}
if ($catName === '인사이트' || $catName === '리더십') {
$issue_type_code = isset($_POST['issue_type_code']) ? trim($_POST['issue_type_code']) : null;
$offer_id = isset($_POST['offer_id']) ? trim($_POST['offer_id']) : null;
$is_offer = isset($_POST['is_offer']) ? '1' : '0';
if ($is_offer === '1') {
// 기존 추천콘텐츠 초기화 인사이트/리더십카테고리 , 카테고리 구분별 (단일 적용)
$stmt = $pdo->prepare("UPDATE edu_contents SET is_offer='0' WHERE is_offer='1' AND category_code = :category_code and issue_type_code = :issue_type_code ");
$stmt->execute([':category_code' => $category_code, ':issue_type_code' => $issue_type_code]);
}
}
if ($catName === '비즈트렌드') {
$start_date = isset($_POST['start_date_bzt']) ? $_POST['start_date_bzt'] : null;
// 사내도서여부: 체크 시 '1', 미체크 시 '0'
$book_yn = isset($_POST['book_yn']) && $_POST['book_yn'] === '1' ? '1' : '0';
}
if ($category_code === '' || $title === '') {
echo json_encode(['success' => false, 'message' => '카테고리와 콘텐츠명은 필수입니다.']);
exit;
}
$image_name = null;
if ($content_id !== '') {
$stmtImg = $pdo->prepare("SELECT image_name FROM edu_contents WHERE content_id = :id");
$stmtImg->execute([':id' => $content_id]);
$image_name = $stmtImg->fetchColumn();
}
if (isset($_FILES['image_name']) && $_FILES['image_name']['error'] === UPLOAD_ERR_OK) {
if ($_FILES['image_name']['size'] > 4 * 1024 * 1024) {
echo json_encode(['success' => false, 'message' => '이미지 파일은 4MB 이하만 등록 가능합니다.']);
exit;
}
$finfo = @finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$mime = @finfo_file($finfo, $_FILES['image_name']['tmp_name']);
@finfo_close($finfo);
if ($mime && strpos($mime, 'image/') !== 0) {
echo json_encode(['success' => false, 'message' => '이미지 파일만 등록 가능합니다.']);
exit;
}
}
$upload_dir = __DIR__ . '/../../uploads/biztrend/';
if (!is_dir($upload_dir)) {
@mkdir($upload_dir, 0777, true);
}
$ext = strtolower(pathinfo($_FILES['image_name']['name'], PATHINFO_EXTENSION));
if (!$ext)
$ext = 'jpg';
$new_filename = 'img_' . time() . '_' . mt_rand(1000, 9999) . '.' . $ext;
$target_file = $upload_dir . $new_filename;
if (move_uploaded_file($_FILES['image_name']['tmp_name'], $target_file)) {
if ($image_name && file_exists($upload_dir . $image_name)) {
@unlink($upload_dir . $image_name);
}
$image_name = $new_filename;
} else {
echo json_encode(['success' => false, 'message' => '이미지 업로드에 실패했습니다.']);
exit;
}
}
if ($content_id !== '') {
$sql = "UPDATE edu_contents SET
category_code = :category_code,
category_group = :category_group,
title = :title,
description = :description,
description1 = :description1,
description2 = :description2,
description3 = :description3,
content_url = :content_url,
thumbnail_url = :thumbnail_url,
content_tm = :content_tm,
base_year = :base_year,
start_date = :start_date,
end_date = :end_date,
sort_order = :sort_order,
goal_code = :goal_code,
issue_type_code = :issue_type_code,
is_offer = :is_offer,
offer_id = :offer_id,
is_active = :is_active,
book_yn = :book_yn,
image_name = :image_name,
updated_by = :updated_by,
updated_at = NOW()
WHERE content_id = :content_id";
} else {
$content_id = generate_content_id($pdo);
$sql = "INSERT INTO edu_contents (
content_id, category_code, category_group, title, description,description1, description2, description3,
content_url, thumbnail_url, content_tm, base_year, start_date, end_date, sort_order,
goal_code, issue_type_code, is_offer, offer_id, is_active, book_yn, image_name, created_by , created_at , updated_by , updated_at
) VALUES (
:content_id, :category_code, :category_group, :title, :description, :description1, :description2, :description3,
:content_url, :thumbnail_url, :content_tm, :base_year, :start_date, :end_date, :sort_order,
:goal_code, :issue_type_code, :is_offer, :offer_id, :is_active, :book_yn, :image_name, :created_by, NOW(), :updated_by, NOW()
)";
}
try {
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':image_name', $image_name !== '' && $image_name !== null ? $image_name : null, $image_name !== '' && $image_name !== null ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':content_id', $content_id);
$stmt->bindValue(':category_code', $category_code !== '' ? $category_code : null, $category_code !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':category_group', $category_group !== '' ? $category_group : null, $category_group !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':title', $title);
$stmt->bindValue(':description', $description !== '' ? $description : null, $description !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':description1', $description1 !== '' ? $description1 : null, $description1 !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':description2', $description2 !== '' ? $description2 : null, $description2 !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':description3', $description3 !== '' ? $description3 : null, $description3 !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':content_url', $content_url !== '' ? $content_url : null, $content_url !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':thumbnail_url', $thumbnail_url !== '' ? $thumbnail_url : null, $thumbnail_url !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
if ($content_tm === null) {
$stmt->bindValue(':content_tm', null, PDO::PARAM_NULL);
} else {
$stmt->bindValue(':content_tm', $content_tm, PDO::PARAM_INT);
}
$stmt->bindValue(':base_year', $base_year !== '' ? $base_year : null, $base_year !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':start_date', $start_date !== '' ? $start_date : null, $start_date !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':end_date', $end_date !== '' ? $end_date : null, $end_date !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
if ($sort_order === null || $sort_order === '') {
$stmt->bindValue(':sort_order', null, PDO::PARAM_NULL);
} else {
$stmt->bindValue(':sort_order', (int) $sort_order, PDO::PARAM_INT);
}
$stmt->bindValue(':goal_code', $goal_code !== '' ? $goal_code : null, $goal_code !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':issue_type_code', $issue_type_code !== '' ? $issue_type_code : null, $issue_type_code !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':is_offer', $is_offer);
$stmt->bindValue(':offer_id', $offer_id !== '' ? $offer_id : null, $offer_id !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':is_active', $is_active);
$stmt->bindValue(':book_yn', $book_yn);
// created_by는 INSERT 모드에서만 필요함
if (!isset($_POST['content_id']) || $_POST['content_id'] === '') {
$stmt->bindValue(':created_by', $login_member_id);
}
$stmt->bindValue(':updated_by', $login_member_id);
$stmt->execute();
echo json_encode(['success' => true]);
} catch (Exception $e) {
// 에러 내용을 JSON 으로 반환하여 화면에서 확인할 수 있게 합니다.
echo json_encode(['success' => false, 'message' => '에러 발생: ' . $e->getMessage()]);
}
exit;
+14
View File
@@ -0,0 +1,14 @@
<?php
$host = getenv('EDU_DB_HOST') ?: 'edu_db';
$db = getenv('EDU_DB_NAME') ?: 'edu';
$user = getenv('EDU_DB_USER') ?: 'edu1234';
$pass = getenv('EDU_DB_PASS') ?: 'edu1234';
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$pdo = new PDO($dsn, $user, $pass, $options);
+29
View File
@@ -0,0 +1,29 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once '../../bbs/db_conn.php';
// PDO 연결 생성
$pdo = db_conn();
try {
// JSON ?먮뒗 POST ?뚮씪誘명꽣 ?쎄린
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
$input = $_POST;
}
$group_code = $input['group_code'] ?? '';
$code = $input['code'] ?? '';
if (empty($group_code) || empty($code)) {
echo json_encode(['success' => false, 'message' => 'parameters required']);
exit;
}
$stmt = $pdo->prepare("DELETE FROM edu_codes WHERE group_code = ? AND code = ?");
$result = $stmt->execute([$group_code, $code]);
echo json_encode(['success' => (bool)$result]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
?>
+20
View File
@@ -0,0 +1,20 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once '../../bbs/db_conn.php';
// PDO 연결 생성
$pdo = db_conn();
try {
$group_code = $_POST['group_code'] ?? '';
if (empty($group_code)) {
echo json_encode(['success' => false, 'message' => 'group_code required']);
exit;
}
$stmt = $pdo->prepare("DELETE FROM edu_code_group WHERE group_code = ?");
$result = $stmt->execute([$group_code]);
echo json_encode(['success' => (bool)$result]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
?>
+66
View File
@@ -0,0 +1,66 @@
<?php
require_once __DIR__ . '/../../bbs/auth.php';
edu_require_login();
include_once __DIR__ . '/../../bbs/db_conn.php';
header('Content-Type: application/json');
$year = $_GET['year'] ?? date('Y');
$month = $_GET['month'] ?? '';
$comp = $_GET['access_comp'] ?? '';
if (!$month && !isset($_GET['fr_date'])) {
echo json_encode(['success' => false, 'message' => '조회 정보가 없습니다.']);
exit;
}
try {
$pdo = db_conn();
$fr_date = $_GET['fr_date'] ?? '';
$to_date = $_GET['to_date'] ?? '';
// 법인별 접속자 리스트 조회
$sql = "
SELECT
fn_get_code_name(CONCAT('CO100', u.sys_comp_code)) as comp_name,
u.sys_comp_code,
u.name,
u.dept_name,
u.rank_name,
a.accessed_at
FROM edu_access_logs a
JOIN edu_users u ON a.member_id = u.member_id and a.sys_comp_code = u.sys_comp_code
WHERE 1=1
";
$params = [];
if ($fr_date && $to_date) {
$sql .= " AND a.accessed_at BETWEEN ? AND ?";
$params[] = "$fr_date 00:00:00";
$params[] = "$to_date 23:59:59";
} elseif ($month) {
$sql .= " AND YEAR(a.accessed_at) = ? AND MONTH(a.accessed_at) = ?";
$params[] = $year;
$params[] = $month;
} else {
$sql .= " AND YEAR(a.accessed_at) = ?";
$params[] = $year;
}
if ($comp !== '') {
$sql .= " AND u.sys_comp_code = ?";
$params[] = $comp;
}
$sql .= " ORDER BY a.accessed_at DESC, u.sys_comp_code ASC, u.member_id ASC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$logs = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'data' => $logs]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => '데이터 조회 중 오류가 발생했습니다.', 'error' => $e->getMessage()]);
}
+15
View File
@@ -0,0 +1,15 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once '../../bbs/db_conn.php';
// PDO 연결 생성
$pdo = db_conn();
try {
$stmt = $pdo->query("SELECT * FROM edu_code_group ORDER BY group_code");
$groups = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['groups' => $groups], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
echo json_encode(['error' => $e->getMessage()], JSON_UNESCAPED_UNICODE);
}
?>
+18
View File
@@ -0,0 +1,18 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once '../../bbs/db_conn.php';
$pdo = db_conn();
$group = $_GET['group_code'] ?? '';
try {
if ($group !== '') {
$stmt = $pdo->prepare("SELECT * FROM edu_codes WHERE group_code = ? ORDER BY code");
$stmt->execute([$group]);
} else {
$stmt = $pdo->query("SELECT * FROM edu_codes ORDER BY group_code, code");
}
$codes = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['codes' => $codes], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
echo json_encode(['error' => $e->getMessage()], JSON_UNESCAPED_UNICODE);
}
?>
+48
View File
@@ -0,0 +1,48 @@
<?php
require_once __DIR__ . '/../../bbs/auth.php';
edu_require_login();
include_once __DIR__ . '/../../bbs/db_conn.php';
header('Content-Type: application/json');
$corp_code = $_GET['corp_code'] ?? '';
$fr_date = $_GET['fr_date'] ?? '';
$to_date = $_GET['to_date'] ?? '';
$type = $_GET['type'] ?? 'avg';
if (!$fr_date || !$to_date) {
echo json_encode(['success' => false, 'message' => '필수 정보가 누락되었습니다.']);
exit;
}
try {
$pdo = db_conn();
$sql = "
SELECT
u.member_id,
u.name,
u.dept_name,
c.title as content_title,
CONCAT(
LPAD(FLOOR(h.watch_tm / 3600), 2, '0'), '시간 ',
LPAD(FLOOR((h.watch_tm % 3600) / 60), 2, '0'), '분'
) as formatted_watch_tm,
h.last_viewed_at
FROM edu_learning_histories h
JOIN edu_users u ON h.member_id = u.member_id AND h.sys_comp_code = u.sys_comp_code
JOIN edu_contents c ON h.content_id = c.content_id
WHERE (? = '' OR u.sys_comp_code = ?) AND h.last_viewed_at BETWEEN ? AND ?
ORDER BY u.name ASC , h.last_viewed_at DESC
";
$stmt = $pdo->prepare($sql);
$stmt->execute([$corp_code, $corp_code, "$fr_date 00:00:00", "$to_date 23:59:59"]);
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'data' => $data]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => '데이터 조회 중 오류가 발생했습니다.', 'error' => $e->getMessage()]);
}
+209
View File
@@ -0,0 +1,209 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../bbs/db_conn.php';
require_once __DIR__ . '/../../bbs/auth.php';
edu_require_login();
$pdo = db_conn();
$year = $_GET['year'] ?? date('Y');
$quarter_raw = $_GET['quarter'] ?? ceil(date('n') / 3);
// Convert quarter code (e.g., CA200Q01, CA200001) to integer 1~4
if (is_string($quarter_raw) && strpos($quarter_raw, 'CA200') === 0) {
$quarter = (int) substr($quarter_raw, -1);
} else {
$quarter = (int) $quarter_raw;
}
if ($quarter < 1 || $quarter > 4)
$quarter = 1;
// Calculate start and end dates for the quarter
$p_fr_dt = $year . '-' . sprintf('%02d', ($quarter - 1) * 3 + 1) . '-01';
$p_to_dt = date('Y-m-t', strtotime($year . '-' . sprintf('%02d', $quarter * 3) . '-01'));
$p_fr_dt_full = $p_fr_dt . ' 00:00:00';
$p_to_dt_full = $p_to_dt . ' 23:59:59';
$response = [
'success' => true,
'data' => []
];
try {
// 1. KPI
$stmt_kpi = $pdo->prepare("CALL proc_get_dashboard_kpi(?, ?)");
$stmt_kpi->execute([$year, $quarter]);
$kpi = $stmt_kpi->fetch(PDO::FETCH_ASSOC);
while ($stmt_kpi->nextRowset()) {
} // Clear extra rowsets
$stmt_kpi->closeCursor();
// Ensure all variables exist, with defaults
$kpi = $kpi ?: [];
// DEBUG: Write kpi to file
file_put_contents(__DIR__ . '/kpi_debug.json', json_encode($kpi, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$kpi = array_change_key_case($kpi, CASE_LOWER);
// Add computed metrics for KPI based on User's input
$access_qty = isset($kpi['access_qty']) ? (float) $kpi['access_qty'] : 0;
$completed_qty = isset($kpi['completed_qty']) ? (float) $kpi['completed_qty'] : 0;
$access_qty_u = isset($kpi['access_qty_u']) ? (float) $kpi['access_qty_u'] : 0;
$completed_qty_u = isset($kpi['completed_qty_u']) ? (float) $kpi['completed_qty_u'] : 0;
$kpi['computed_content_per_user'] = $access_qty > 0 ? round($completed_qty / $access_qty, 1) : 0;
$prev_content_per_user = $access_qty_u > 0 ? round($completed_qty_u / $access_qty_u, 1) : 0;
$kpi['computed_content_per_user_diff'] = round($kpi['computed_content_per_user'] - $prev_content_per_user, 1);
$legal_qty_all = isset($kpi['legal_qty_all']) ? (int) $kpi['legal_qty_all'] : 0;
$legal_qty_y = isset($kpi['legal_qty_y']) ? (int) $kpi['legal_qty_y'] : 0;
$kpi['computed_legal_uncompleted'] = $legal_qty_all - $legal_qty_y;
$kpi['computed_legal_rate'] = $legal_qty_all > 0 ? round(($legal_qty_y / $legal_qty_all) * 100) : 0;
$quarter_qty = isset($kpi['quarter_qty']) ? (int) $kpi['quarter_qty'] : 0;
$goals_qty = isset($kpi['goals_qty']) ? (int) $kpi['goals_qty'] : (isset($kpi['goal_qty']) ? (int) $kpi['goal_qty'] : 0);
$goals_completed_qty = isset($kpi['goals_completed_qty']) ? (int) $kpi['goals_completed_qty'] : (isset($kpi['goal_completed_qty']) ? (int) $kpi['goal_completed_qty'] : 0);
$kpi['myclass_target_qty'] = $goals_qty;
$kpi['myclass_target_rate'] = $quarter_qty > 0 ? round(($goals_qty / $quarter_qty) * 100) : 0;
$kpi['myclass_achieve_qty'] = $goals_completed_qty;
$kpi['myclass_achieve_rate'] = $goals_qty > 0 ? round(($goals_completed_qty / $goals_qty) * 100) : 0;
$response['data']['kpi'] = $kpi;
// 2. 법인별 접속률
$stmt_corp = $pdo->prepare("
SELECT c.code_name
, COUNT(DISTINCT b.member_id) as all_qty
, IFNULL(d.access_qty, 0) AS access_qty
, IFNULL(ROUND(IFNULL(d.access_qty, 0) / NULLIF(COUNT(DISTINCT b.member_id), 0) * 100,0),0) as access_rate
, IFNULL(COUNT(DISTINCT b.member_id) - IFNULL(d.access_qty, 0),0) AS no_access_qty
FROM edu_codes c
LEFT JOIN edu_users b ON c.code = b.sys_comp_code AND (b.end_date IS NULL OR b.end_date > CURDATE())
LEFT JOIN (SELECT COUNT(0) AS access_qty , sys_comp_code
FROM (SELECT a.member_id, a.sys_comp_code
FROM edu_access_logs a
WHERE 1=1
AND accessed_at >= ?
AND accessed_at <= ?
GROUP BY a.member_id, a.sys_comp_code) x
GROUP BY sys_comp_code ) d ON c.code = d.sys_comp_code
WHERE c.group_code = 'CO100' AND c.is_active = '1'
GROUP BY c.code, c.code_name
ORDER BY c.code ASC
");
$stmt_corp->execute([$p_fr_dt, $p_to_dt]);
$response['data']['corp_access_rate'] = $stmt_corp->fetchAll(PDO::FETCH_ASSOC);
// 3. 학습자 접속 빈도
$stmt_freq = $pdo->prepare("
SELECT
SUM(CASE WHEN IFNULL(a.qty, 0) >= 12 THEN 1 ELSE 0 END) AS active_lv01,
SUM(CASE WHEN IFNULL(a.qty, 0) BETWEEN 5 AND 11 THEN 1 ELSE 0 END) AS normal_lv02,
SUM(CASE WHEN IFNULL(a.qty, 0) BETWEEN 1 AND 4 THEN 1 ELSE 0 END) AS low_lv03,
SUM(CASE WHEN IFNULL(a.qty, 0) = 0 THEN 1 ELSE 0 END) AS none_lv04
FROM edu_users u
LEFT JOIN (
SELECT member_id, sys_comp_code, COUNT(DISTINCT DATE(accessed_at)) AS qty
FROM edu_access_logs
WHERE accessed_at BETWEEN ? AND ?
GROUP BY member_id, sys_comp_code
) a ON u.member_id = a.member_id AND u.sys_comp_code = a.sys_comp_code
WHERE 1=1
AND (end_date IS NULL OR end_date > CURDATE())
");
$stmt_freq->execute([$p_fr_dt, $p_to_dt]);
$response['data']['access_freq'] = $stmt_freq->fetch(PDO::FETCH_ASSOC);
// 4. 접속 시간대
$stmt_time = $pdo->prepare("
SELECT
SUM(CASE WHEN substring(accessed_at,12,5)>= '09:00' AND substring(accessed_at,12,5)<= '17:00' THEN 1 ELSE 0 END)-
SUM(CASE WHEN substring(accessed_at,12,5)>= '11:30' AND substring(accessed_at,12,5)<= '13:30' THEN 1 ELSE 0 END) AS worktime
, SUM(CASE WHEN substring(accessed_at,12,5)>= '11:30' AND substring(accessed_at,12,5)<= '13:30' THEN 1 ELSE 0 END) AS lunchtime
, SUM(CASE WHEN substring(accessed_at,12,5) < '09:00' or substring(accessed_at,12,5) > '17:00' THEN 1 ELSE 0 END) AS outtime
, SUM(CASE WHEN 1=1 THEN 1 ELSE 0 END) AS alltime
FROM edu_access_logs
WHERE 1=1
AND accessed_at BETWEEN ? AND ?
");
$stmt_time->execute([$p_fr_dt_full, $p_to_dt_full]);
$response['data']['access_time'] = $stmt_time->fetch(PDO::FETCH_ASSOC);
// 5. 접속 방법
$stmt_device = $pdo->prepare("
SELECT
SUM(CASE WHEN device_type = 'PC' THEN 1 ELSE 0 END) AS pc
, SUM(CASE WHEN device_type = 'Mobile' THEN 1 ELSE 0 END) AS mobile
, SUM(CASE WHEN 1=1 THEN 1 ELSE 0 END) AS alldevice
FROM edu_access_logs
WHERE 1=1
AND accessed_at BETWEEN ? AND ?
");
$stmt_device->execute([$p_fr_dt_full, $p_to_dt_full]);
$response['data']['access_device'] = $stmt_device->fetch(PDO::FETCH_ASSOC);
// 6. 카테고리별 이용현황
$stmt_usage = $pdo->prepare("
SELECT
SUM(CASE WHEN 1=1 THEN 1 ELSE 0 END) AS tot_qty
, SUM(CASE WHEN a.category_code = 'CA10001' THEN 1 ELSE 0 END) AS myclass_use_qty
, SUM(CASE WHEN a.category_code = 'CA10004' THEN 1 ELSE 0 END) AS leader_use_qty
, SUM(CASE WHEN a.category_code = 'CA10005' THEN 1 ELSE 0 END) AS insight_use_qty
, SUM(CASE WHEN a.category_code = 'CA10006' THEN 1 ELSE 0 END) AS biz_use_qty
FROM edu_contents a
JOIN edu_learning_histories b ON a.content_id = b.content_id
WHERE 1=1
AND b.last_viewed_at BETWEEN ? AND ?
AND a.category_code IN('CA10001','CA10004','CA10005','CA10006')
");
$stmt_usage->execute([$p_fr_dt_full, $p_to_dt_full]);
$response['data']['content_usage'] = $stmt_usage->fetch(PDO::FETCH_ASSOC);
// 7. 이번분기 신규콘텐츠 등록수량
$stmt_new = $pdo->prepare("
SELECT
IFNULL(SUM(category_code = 'CA10001'), 0) AS myclass_new_qty,
IFNULL(SUM(category_code = 'CA10004'), 0) AS leader_new_qty,
IFNULL(SUM(category_code = 'CA10005'), 0) AS insight_new_qty,
IFNULL(SUM(category_code = 'CA10006'), 0) AS biz_new_qty
FROM edu_contents
WHERE created_at BETWEEN ? AND ?
");
$stmt_new->execute([$p_fr_dt_full, $p_to_dt_full]);
$response['data']['new_content_qty'] = $stmt_new->fetch(PDO::FETCH_ASSOC);
// 8. 가장 많이 본 콘텐츠
$stmt_popular = $pdo->prepare("
SELECT
b.category_code
, fn_get_code_name(b.category_code) AS category_name
, b.title AS content_title
, COUNT(a.content_id) as view_count
, SUM(CASE WHEN a.completed_at IS NOT NULL OR a.watch_tm/a.content_tm >= 0.7 THEN 1 ELSE 0 END)/COUNT(a.content_id)*100 AS completed_rate
, c.comment_cnt
FROM edu_learning_histories a
JOIN edu_contents b ON a.content_id = b.content_id
LEFT JOIN ( SELECT y.category_code , x.content_id , COUNT(0) AS comment_cnt
FROM edu_comments x
JOIN edu_contents y ON x.content_id = y.content_id
WHERE x.created_at BETWEEN ? AND ?
GROUP BY y.category_code , x.content_id) c ON a.content_id = c.content_id
WHERE 1=1
AND b.category_code IN('CA10001','CA10004','CA10005','CA10006')
AND a.last_viewed_at BETWEEN ? AND ?
GROUP BY b.category_code, b.content_id, b.title
ORDER BY COUNT(a.content_id) DESC
LIMIT 50
");
$stmt_popular->execute([$p_fr_dt_full, $p_to_dt_full, $p_fr_dt_full, $p_to_dt_full]);
$response['data']['popular_contents'] = $stmt_popular->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) {
$response['success'] = false;
$response['message'] = $e->getMessage();
$response['trace'] = $e->getTraceAsString();
}
echo json_encode($response);
+214
View File
@@ -0,0 +1,214 @@
<?php
/**
* get_legal_cert_print.php - 수료증 정보 조회 API
*/
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../bbs/db_conn.php';
require_once __DIR__ . '/../../bbs/auth.php';
edu_require_login();
$year = $_GET['year'] ?? $_GET['_YEAR'] ?? '';
$comp = $_GET['comp'] ?? $_GET['_COMP'] ?? '';
$member_id = $_GET['member_id'] ?? $_GET['_MEMBER_ID'] ?? '';
$category_group = $_GET['category_group'] ?? $_GET['_CATEGORY_GROUP'] ?? '';
if (empty($year) || empty($comp) || empty($member_id) || empty($category_group)) {
echo json_encode([
'success' => false,
'message' => '필수 파라미터가 부족합니다. (year, comp, member_id, category_group)'
], JSON_UNESCAPED_UNICODE);
exit;
}
try {
$pdo = db_conn();
$category_groups = explode(',', $category_group);
$decodedItems = [];
$calls_log = [];
foreach ($category_groups as $cat) {
$cat = trim($cat);
if (empty($cat))
continue;
$member_ids = [];
if ($member_id === 'ALL') {
// 1. Get total active legal contents count for the category group
$stmt_cnt = $pdo->prepare("
SELECT COUNT(0) AS cnt
FROM edu_contents
WHERE base_year = ?
AND category_code = 'CA10003'
AND category_group = ?
AND is_active = '1';
");
$stmt_cnt->execute([$year, $cat]);
$total_legal_cnt = (int)$stmt_cnt->fetchColumn();
if ($total_legal_cnt > 0) {
// 2. Query completed users using the SQL logic from 개발.md
$stmt_users = $pdo->prepare("
SELECT b.member_id
FROM edu_learning_histories b
INNER JOIN edu_contents a ON a.content_id = b.content_id
INNER JOIN edu_users c ON b.member_id = c.member_id AND b.sys_comp_code = c.sys_comp_code
WHERE a.base_year = ?
AND a.category_code = 'CA10003'
AND a.is_active = '1'
AND b.completed_at IS NOT NULL
AND a.category_group = ?
AND (? = '' OR c.belong_comp = ?)
GROUP BY b.member_id, c.belong_comp
HAVING COUNT(b.content_id) = ?;
");
$stmt_users->execute([$year, $cat, $comp, $comp, $total_legal_cnt]);
$member_ids = $stmt_users->fetchAll(PDO::FETCH_COLUMN);
}
} else {
$member_ids = [$member_id];
}
foreach ($member_ids as $m_id) {
$calls_log[] = [
'proc' => 'proc_get_legal_education_certificate',
'params' => [
'year' => $year,
'comp' => $comp,
'member_id' => $m_id,
'category_group' => $cat
]
];
$stmt = $pdo->prepare("CALL proc_get_legal_education_certificate(?, ?, ?, ?);");
$stmt->execute([
$year,
$comp,
$m_id,
$cat
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
while ($stmt->nextRowset()) {
} // 소진
if ($row) {
$decodedRow = [];
foreach ($row as $key => $val) {
$decodedKey = strtolower($key);
if (is_object($val) && isset($val->type) && $val->type === 'Buffer') {
$decodedRow[$decodedKey] = pack('C*', ...$val->data);
} else {
$decodedRow[$decodedKey] = $val;
}
}
// 각 아이템별 동적 스탬프 및 로고 워터마크 이미지 검증 (admin/file 경로 우선, 기존 img 경로 차선 Fallback)
$belong_comp_code = $decodedRow['belong_comp_code'] ?? $decodedRow['sys_comp_code'] ?? $comp;
$logo_url = "";
$stamp_url = "";
if (!empty($belong_comp_code)) {
// /www/admin/file/logo 및 /www/admin/file/stamp 컨테이너 내 절대 파일 경로
$abs_logo_svg = '/www/admin/file/logo/' . $belong_comp_code . '_logo.svg';
$abs_logo_png = '/www/admin/file/logo/' . $belong_comp_code . '_logo.png';
$abs_stamp_svg = '/www/admin/file/stamp/' . $belong_comp_code . '_stamp.svg';
$abs_stamp_png = '/www/admin/file/stamp/' . $belong_comp_code . '_stamp.png';
// 로컬 상대 파일 경로 (__DIR__ 기준 fallback)
$admin_logo_svg = __DIR__ . '/../file/logo/' . $belong_comp_code . '_logo.svg';
$admin_logo_png = __DIR__ . '/../file/logo/' . $belong_comp_code . '_logo.png';
$admin_stamp_svg = __DIR__ . '/../file/stamp/' . $belong_comp_code . '_stamp.svg';
$admin_stamp_png = __DIR__ . '/../file/stamp/' . $belong_comp_code . '_stamp.png';
// 기존 img/ 절대 파일 경로
$img_logo_svg = __DIR__ . '/../../img/' . $belong_comp_code . '_logo.svg';
$img_logo_png = __DIR__ . '/../../img/' . $belong_comp_code . '_logo.png';
$img_stamp_svg = __DIR__ . '/../../img/' . $belong_comp_code . '_stamp.svg';
$img_stamp_png = __DIR__ . '/../../img/' . $belong_comp_code . '_stamp.png';
// 로고 매핑 (절대경로 및 로컬경로 존재 확인 후 웹 URL 설정 - /admin/file/logo/ 우선)
if (file_exists($abs_logo_svg)) {
$logo_url = "/admin/file/logo/" . $belong_comp_code . "_logo.svg";
} elseif (file_exists($admin_logo_svg)) {
$logo_url = "/admin/file/logo/" . $belong_comp_code . "_logo.svg";
} elseif (file_exists($abs_logo_png)) {
$logo_url = "/admin/file/logo/" . $belong_comp_code . "_logo.png";
} elseif (file_exists($admin_logo_png)) {
$logo_url = "/admin/file/logo/" . $belong_comp_code . "_logo.png";
} elseif (file_exists($img_logo_svg)) {
$logo_url = "/img/" . $belong_comp_code . "_logo.svg";
} elseif (file_exists($img_logo_png)) {
$logo_url = "/img/" . $belong_comp_code . "_logo.png";
}
// 직인 매핑 (절대경로 및 로컬경로 존재 확인 후 웹 URL 설정 - /admin/file/stamp/ 우선)
if (file_exists($abs_stamp_svg)) {
$stamp_url = "/admin/file/stamp/" . $belong_comp_code . "_stamp.svg";
} elseif (file_exists($admin_stamp_svg)) {
$stamp_url = "/admin/file/stamp/" . $belong_comp_code . "_stamp.svg";
} elseif (file_exists($abs_stamp_png)) {
$stamp_url = "/admin/file/stamp/" . $belong_comp_code . "_stamp.png";
} elseif (file_exists($admin_stamp_png)) {
$stamp_url = "/admin/file/stamp/" . $belong_comp_code . "_stamp.png";
} elseif (file_exists($img_stamp_svg)) {
$stamp_url = "/img/" . $belong_comp_code . "_stamp.svg";
} elseif (file_exists($img_stamp_png)) {
$stamp_url = "/img/" . $belong_comp_code . "_stamp.png";
}
}
$decodedRow['logo_url'] = $logo_url;
$decodedRow['stamp_url'] = $stamp_url;
$decodedItems[] = $decodedRow;
}
}
}
if (empty($decodedItems)) {
echo json_encode([
'success' => false,
'message' => '해당 조건의 수료 정보가 존재하지 않습니다.',
'debug' => [
'api_params' => [
'year' => $year,
'comp' => $comp,
'member_id' => $member_id,
'category_group' => $category_group
],
'processed_categories' => $category_groups,
'total_legal_cnt' => $total_legal_cnt ?? null,
'matched_member_ids' => $member_ids ?? [],
'procedure_calls' => $calls_log,
'step_info' => 'No certificates could be loaded or formatted.'
]
], JSON_UNESCAPED_UNICODE);
exit;
}
echo json_encode([
'success' => true,
'data' => $decodedItems,
'debug' => [
'procedure_calls' => $calls_log
]
], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
error_log('[get_legal_cert_print] Error: ' . $e->getMessage());
echo json_encode([
'success' => false,
'message' => '서버 내부 오류가 발생했습니다.',
'detail' => $e->getMessage(),
'debug' => [
'procedure_calls' => $calls_log ?? []
]
], JSON_UNESCAPED_UNICODE);
exit;
}
+40
View File
@@ -0,0 +1,40 @@
<?php
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
require_once __DIR__ . '/../../bbs/db_conn.php';
header('Content-Type: application/json; charset=utf-8');
$sys_comp_code = $_GET['sys_comp_code'] ?? '';
$member_id = $_GET['member_id'] ?? '';
$year = $_GET['year'] ?? date('Y');
if (!$sys_comp_code || !$member_id) {
echo json_encode(['success' => false, 'message' => '파라미터가 부족합니다.'], JSON_UNESCAPED_UNICODE);
exit;
}
try {
$pdo = db_conn();
$stmt = $pdo->prepare("CALL proc_get_member_learning_status(:sys_comp_code, :year, :member_id, 'CA10003')");
$stmt->execute([
':sys_comp_code' => $sys_comp_code,
':year' => $year,
':member_id' => $member_id
]);
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
while ($stmt->nextRowset()) {}
echo json_encode(['success' => true, 'items' => $items], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
error_log('[get_legal_edu_detail] ' . $e->getMessage());
echo json_encode([
'success' => false,
'message' => '서버 에러',
'detail' => $e->getMessage()
], JSON_UNESCAPED_UNICODE);
exit;
}
+135
View File
@@ -0,0 +1,135 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../bbs/db_conn.php';
require_once __DIR__ . '/../../bbs/auth.php';
edu_require_login();
$pdo = db_conn();
$year = $_GET['year'] ?? date('Y');
$quarter_raw = $_GET['quarter'] ?? ceil(date('n') / 3);
// Convert quarter code (e.g., CA200Q01, CA200001) to integer 1~4
if (is_string($quarter_raw) && strpos($quarter_raw, 'CA200') === 0) {
$quarter = (int) substr($quarter_raw, -1);
} else {
$quarter = (int) $quarter_raw;
}
if ($quarter < 1 || $quarter > 4)
$quarter = 1;
// Calculate start and end dates for the quarter
$p_fr_dt = $year . '-' . sprintf('%02d', ($quarter - 1) * 3 + 1) . '-01';
$p_to_dt = date('Y-m-t', strtotime($year . '-' . sprintf('%02d', $quarter * 3) . '-01'));
$p_fr_dt_full = $p_fr_dt . ' 00:00:00';
$p_to_dt_full = $p_to_dt . ' 23:59:59';
$response = [
'success' => true,
'data' => []
];
try {
// 1. 전체학습자(quarter_qty)
$stmt_q1 = $pdo->prepare("
SELECT COUNT(*) as quarter_qty FROM edu_users
WHERE join_date <= ?
AND (end_date >= ? OR end_date IS NULL)
");
$stmt_q1->execute([$p_to_dt_full, $p_fr_dt_full]);
$quarter_qty = (int) $stmt_q1->fetchColumn();
// 2. 목표선택자(goals_qty)
$stmt_q2 = $pdo->prepare("
SELECT COUNT(b.member_id) as goals_qty
FROM edu_learning_goals a
JOIN edu_user_learning_goals b ON a.goal_code = b.goal_code
WHERE 1=1
AND b.is_active = '1'
AND a.base_year = ? AND RIGHT(a.quarter, 1) = ?
");
$stmt_q2->execute([$year, $quarter]);
$goals_qty = (int) $stmt_q2->fetchColumn();
// 3. 목표달성자(goals_completed_qty)
$stmt_q3 = $pdo->prepare("
SELECT COUNT( b.member_id) as goals_completed_qty
FROM edu_learning_goals a
JOIN edu_user_learning_goals b ON a.goal_code = b.goal_code
WHERE a.base_year = ? AND RIGHT(a.quarter, 1) = ? AND b.completed_date IS NOT NULL
");
$stmt_q3->execute([$year, $quarter]);
$goals_completed_qty = (int) $stmt_q3->fetchColumn();
// 4. 목표별선택률
$stmt_q4 = $pdo->prepare("
SELECT a.sort_order
, a.title
, IFNULL(b.goal_qty, 0) as goal_qty
FROM edu_learning_goals a
LEFT JOIN ( SELECT goal_code , COUNT(0) AS goal_qty
FROM edu_user_learning_goals a
WHERE 1=1
AND is_active = '1'
AND RIGHT(a.quarter, 1) = ?
GROUP BY goal_code) b ON a.goal_code = b.goal_code
WHERE 1=1
AND a.is_active = '1'
AND RIGHT(a.quarter, 1) = ?
ORDER BY a.sort_order
");
$stmt_q4->execute([$quarter, $quarter]);
$goal_selection_rate = $stmt_q4->fetchAll(PDO::FETCH_ASSOC);
// 5. 법인별 비교
$stmt_q5 = $pdo->prepare("
SELECT fn_get_code_name(CONCAT('CO100', a.code)) AS comp_name
, IFNULL(b.comp_qty,0) AS comp_qty
, COUNT(DISTINCT u.member_id) as all_qty
FROM edu_codes a
LEFT JOIN edu_users u ON a.code = u.sys_comp_code AND (u.end_date >= ? OR u.end_date IS NULL)
LEFT JOIN (
SELECT sys_comp_code
, COUNT(0) AS comp_qty
FROM edu_user_learning_goals x
WHERE 1=1
AND is_active = '1'
AND RIGHT(x.quarter, 1) = ?
GROUP BY sys_comp_code ) b ON a.code = b.sys_comp_code
WHERE 1=1
AND a.group_code = 'CO100'
GROUP BY a.code, fn_get_code_name(CONCAT('CO100', a.code)), b.comp_qty
ORDER BY a.code
");
$stmt_q5->execute([$p_fr_dt_full, $quarter]);
$comp_selection_rate = $stmt_q5->fetchAll(PDO::FETCH_ASSOC);
// Calculate derived metrics based on User's explicit formulas
$target_rate = $quarter_qty > 0 ? round(($goals_qty / $quarter_qty) * 100) : 0;
$achieve_rate = $goals_qty > 0 ? round(($goals_completed_qty / $goals_qty) * 100) : 0;
$unselected_qty = $quarter_qty - $goals_qty;
$unselected_rate = $quarter_qty > 0 ? round(($unselected_qty / $quarter_qty) * 100, 1) : 0;
$response['data'] = [
'kpi' => [
'quarter_qty' => $quarter_qty,
'goals_qty' => $goals_qty,
'goals_completed_qty' => $goals_completed_qty,
'target_rate' => $target_rate,
'achieve_rate' => $achieve_rate,
'unselected_qty' => $unselected_qty,
'unselected_rate' => $unselected_rate
],
'goals' => $goal_selection_rate,
'comps' => $comp_selection_rate
];
} catch (PDOException $e) {
$response['success'] = false;
$response['error'] = 'DB Query Error: ' . $e->getMessage();
} catch (Exception $e) {
$response['success'] = false;
$response['error'] = 'Error: ' . $e->getMessage();
}
echo json_encode($response, JSON_UNESCAPED_UNICODE);
+73
View File
@@ -0,0 +1,73 @@
<?php
require_once __DIR__ . '/../../bbs/db_conn.php';
ini_set('display_errors', '0');
ini_set('display_startup_errors', '0');
error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
header('Content-Type: application/json; charset=utf-8');
try {
$pdo = db_conn();
// 파라미터 받기
$offer_date_fr = $_GET['offer_date_fr'] ?? '';
$offer_date_to = $_GET['offer_date_to'] ?? '';
$member_id = $_GET['member_id'] ?? '';
$reason = $_GET['reason'] ?? '';
$status_code = $_GET['status_code'] ?? '';
// 쿼리 빌드
$sql = "
SELECT
o.offer_id,
o.reference_url,
o.reason,
o.status_code,
COALESCE(c.code_name, o.status_code) AS status_name,
o.reason_return,
o.member_id,
u.name,
DATE(o.created_at) AS offer_date
FROM edu_content_offer o
LEFT JOIN edu_users u ON o.member_id = u.member_id AND o.sys_comp_code = u.sys_comp_code
LEFT JOIN edu_codes c ON c.group_code = 'OF100' AND c.base_code = o.status_code
WHERE 1=1
";
$params = [];
if ($offer_date_fr) {
$sql .= " AND DATE(o.created_at) >= ?";
$params[] = $offer_date_fr;
}
if ($offer_date_to) {
$sql .= " AND DATE(o.created_at) <= ?";
$params[] = $offer_date_to;
}
if ($member_id) {
$sql .= " AND (o.member_id LIKE ? OR u.name LIKE ?)";
$params[] = '%' . $member_id . '%';
$params[] = '%' . $member_id . '%';
}
if ($reason) {
$sql .= " AND o.reason LIKE ?";
$params[] = '%' . $reason . '%';
}
if ($status_code) {
$sql .= " AND o.status_code = ?";
$params[] = $status_code;
}
$sql .= " ORDER BY o.created_at DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$offers = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'offers' => $offers], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
error_log('[GET_OFFERS ERROR] ' . $e->getMessage());
echo json_encode(['success' => false, 'message' => '서버 오류가 발생했습니다.'], JSON_UNESCAPED_UNICODE);
}
?>
+41
View File
@@ -0,0 +1,41 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once '../../bbs/db_conn.php';
// PDO 연결 생성
$pdo = db_conn();
try {
$base_year = $_GET['base_year'] ?? '';
if (empty($base_year)) {
echo json_encode(['start_date' => '', 'end_date' => '']);
exit;
}
$stmt = $pdo->prepare("
SELECT start_date, end_date
FROM edu_contents
WHERE category_code = 'CA10003'
AND base_year = ?
GROUP BY start_date, end_date
ORDER BY start_date DESC, end_date DESC
LIMIT 1
");
$stmt->execute([$base_year]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
if ($result) {
echo json_encode([
'start_date' => $result['start_date'],
'end_date' => $result['end_date']
]);
} else {
echo json_encode(['start_date' => '', 'end_date' => '']);
}
} catch (Exception $e) {
echo json_encode(['error' => $e->getMessage()]);
}
?>
+84
View File
@@ -0,0 +1,84 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once '../../bbs/db_conn.php';
// PDO 연결 생성
$pdo = db_conn();
try {
// 寃€???뚮씪誘명꽣 諛쏄린
$belong_comp = $_GET['belong_comp'] ?? '';
$working_comp = $_GET['working_comp'] ?? '';
$member_id = $_GET['member_id'] ?? '';
$name = $_GET['name'] ?? '';
$dept_name = $_GET['dept_name'] ?? '';
$auth_level = $_GET['auth_level'] ?? '';
// SQL 荑쇰━ 以€鍮?
$sql = "
SELECT
u.member_id,
u.name,
u.dept_name,
u.rank_name,
u.auth_level,
COALESCE(c1.code_name, u.belong_comp) AS belong_comp_name,
COALESCE(c2.code_name, u.working_comp) AS working_comp_name,
fn_get_code_name(u.auth_level) AS auth_level_name
FROM edu_users u
LEFT JOIN edu_codes c1 ON c1.group_code = 'CO100' AND c1.code = u.belong_comp
LEFT JOIN edu_codes c2 ON c2.group_code = 'CO100' AND c2.code = u.working_comp
WHERE 1=1
";
$params = [];
$types = '';
if (!empty($belong_comp)) {
$sql .= " AND u.belong_comp = ?";
$params[] = $belong_comp;
$types .= 's';
}
if (!empty($working_comp)) {
$sql .= " AND u.working_comp = ?";
$params[] = $working_comp;
$types .= 's';
}
if (!empty($member_id)) {
$sql .= " AND u.member_id LIKE ?";
$params[] = '%' . $member_id . '%';
$types .= 's';
}
if (!empty($name)) {
$sql .= " AND u.name LIKE ?";
$params[] = '%' . $name . '%';
$types .= 's';
}
if (!empty($dept_name)) {
$sql .= " AND u.dept_name LIKE ?";
$params[] = '%' . $dept_name . '%';
$types .= 's';
}
if (!empty($auth_level)) {
$sql .= " AND u.auth_level = ?";
$params[] = $auth_level;
$types .= 's';
}
$sql .= " ORDER BY u.name ASC";
$stmt = $pdo->prepare($sql);
if (!empty($params)) {
$stmt->execute($params);
} else {
$stmt->execute();
}
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'users' => $users], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()], JSON_UNESCAPED_UNICODE);
}
?>
+40
View File
@@ -0,0 +1,40 @@
<?php
// 데이터베이스 연결 설정 파일을 불러옵니다.
require __DIR__ . '/../../bbs/db_conn.php';
// PDO 연결 생성
$pdo = db_conn();
// POST 요청이 아닌 경우 접근을 차단하고 목록 페이지로 돌려보냅니다.
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../skin/content_upload.php');
exit;
}
// POST로 전달받은 학습목표코드(goal_code)가 있으면 공백을 제거하여 변수에 저장하고, 없으면 빈 문자열을 할당합니다.
$goal_code = isset($_POST['goal_code']) ? trim($_POST['goal_code']) : '';
// 학습목표코드가 정상적으로 전달되었는지 확인합니다.
if ($goal_code !== '') {
try {
// 해당 학습목표코드를 삭제하는 DELETE 쿼리를 준비합니다.
$stmt = $pdo->prepare("DELETE FROM edu_learning_goals WHERE goal_code = :goal_code");
// 전달받은 학습목표코드를 바인딩하여 쿼리를 실행합니다.
$stmt->execute([':goal_code' => $goal_code]);
// Ajax 요청에 맞춰 JSON 응답을 반환합니다.
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => true]);
exit;
} catch (PDOException $e) {
// DB 쿼리 실행 중 에러가 발생하면 JSON 에러 응답을 반환합니다.
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'message' => 'DB 오류: ' . $e->getMessage()]);
exit;
}
}
// 전달된 코드가 없는 경우 실패 응답을 반환합니다.
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'message' => 'goal_code 가 없습니다.']);
exit;
+121
View File
@@ -0,0 +1,121 @@
<?php
// DB 접속 설정을 포함합니다.
require __DIR__ . '/../../bbs/db_conn.php';
// PDO 연결 생성
$pdo = db_conn();
// POST 요청이 아닐 경우 비정상 접근으로 간주하고 업로드 페이지로 돌려보냅니다.
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../skin/content_upload.php');
exit;
}
/**
* 신규 학습목표 코드를 생성하는 함수입니다.
* 예) 2026년에 첫 등록이면 2026-001, 이후 2026-002 등으로 자동 번호를 부여합니다.
*/
function generate_goal_code(PDO $pdo, string $year): string
{
$prefix = $year . '-'; // 연도- 문자열 접두사 생성 (예: 2026-)
// 해당 연도의 접두사로 시작하는 코드 중 가장 큰 값(가장 최근 번호)을 찾습니다.
$stmt = $pdo->prepare("SELECT MAX(goal_code) AS max_code FROM edu_learning_goals WHERE goal_code LIKE :pfx");
$stmt->execute([':pfx' => $prefix . '%']);
$max = $stmt->fetchColumn();
$next = 1; // 기본 순번은 1로 설정합니다.
// 이전에 등록된 코드가 있다면, 마지막 세 자리 숫자를 추출하여 1을 더해줍니다.
if ($max) {
$seq = (int) substr($max, -3);
$next = $seq + 1;
}
// 생성된 번호를 3자리 형식(001, 002 등)으로 포맷팅한 후 반환합니다.
return $prefix . str_pad((string) $next, 3, '0', STR_PAD_LEFT);
}
// POST로 전달된 값들을 변수에 대입합니다. 빈 값 처리와 trim을 수행합니다.
$goal_code = isset($_POST['goal_code']) ? trim($_POST['goal_code']) : '';
$title = isset($_POST['title']) ? trim($_POST['title']) : '';
$base_year = isset($_POST['base_year']) ? trim($_POST['base_year']) : date('Y');
$quarter = isset($_POST['quarter']) ? trim($_POST['quarter']) : null;
$is_active = isset($_POST['is_active']) ? '1' : '0';
$sort_order = isset($_POST['sort_order']) && $_POST['sort_order'] !== '' ? (int) $_POST['sort_order'] : null;
$remarks = isset($_POST['remarks']) ? trim($_POST['remarks']) : null;
// 필수 입력 항목인 제목과 기준년도가 누락되었다면 에러 파라미터와 함께 되돌아갑니다.
if ($title === '' || $base_year === '') {
header('Location: ../skin/content_upload.php?goal_error=required');
exit;
}
$goal_no = isset($_POST['goal_no']) ? trim($_POST['goal_no']) : null;
$qParam = $quarter !== '' ? $quarter : null;
$gnParam = $goal_no !== '' ? $goal_no : null;
// Validate uniqueness of year, quarter and goal_no
if ($gnParam !== null && $qParam !== null) {
$dupSql = "SELECT COUNT(*) FROM edu_learning_goals WHERE base_year = :by AND quarter = :q AND goal_no = :gn";
$dupParams = [':by' => $base_year, ':q' => $qParam, ':gn' => $gnParam];
if ($goal_code !== '') {
$dupSql .= " AND goal_code != :gc";
$dupParams[':gc'] = $goal_code;
}
$chk = $pdo->prepare($dupSql);
$chk->execute($dupParams);
if ($chk->fetchColumn() > 0) {
echo json_encode(['success' => false, 'message' => '해당 년도/분기에 이미 같은 책장번호가 사용되었습니다.']);
exit;
}
}
// 기존에 존재하는 학습목표 코드(goal_code)가 넘겨져왔다면, 수정(UPDATE) 동작을 수행합니다.
if ($goal_code !== '') {
// 업데이트할 필드들을 지정하는 쿼리입니다.
$sql = "UPDATE edu_learning_goals SET title=:title, quarter=:quarter, base_year=:base_year, is_active=:is_active, sort_order=:sort_order, remarks=:remarks, goal_no=:goal_no WHERE goal_code=:goal_code";
$stmt = $pdo->prepare($sql);
// 파라미터를 바인딩합니다.
$stmt->bindValue(':title', $title);
$stmt->bindValue(':quarter', $quarter !== '' ? $quarter : null, $quarter !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':base_year', $base_year);
$stmt->bindValue(':is_active', $is_active);
// 정렬 순서 파라미터는 null 허용이므로 분기 처리하여 바인딩합니다.
if ($sort_order === null) {
$stmt->bindValue(':sort_order', null, PDO::PARAM_NULL);
} else {
$stmt->bindValue(':sort_order', $sort_order, PDO::PARAM_INT);
}
$stmt->bindValue(':remarks', $remarks !== '' ? $remarks : null, $remarks !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':goal_no', $goal_no !== '' ? $goal_no : null, $goal_no !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':goal_code', $goal_code);
// 수정 쿼리를 실행합니다.
$stmt->execute();
}
// 전송된 코드가 없다면, 신규 등록(INSERT) 동작을 수행합니다.
else {
// 위의 생성 함수를 통해 새로운 코드를 채번합니다.
$goal_code = generate_goal_code($pdo, $base_year);
$sql = "INSERT INTO edu_learning_goals (goal_code, title, quarter, base_year, is_active, sort_order, remarks, goal_no, created_at) VALUES (:goal_code, :title, :quarter, :base_year, :is_active, :sort_order, :remarks, :goal_no, NOW())";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':goal_code', $goal_code);
$stmt->bindValue(':title', $title);
$stmt->bindValue(':quarter', $quarter !== '' ? $quarter : null, $quarter !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':base_year', $base_year);
$stmt->bindValue(':is_active', $is_active);
if ($sort_order === null) {
$stmt->bindValue(':sort_order', null, PDO::PARAM_NULL);
} else {
$stmt->bindValue(':sort_order', $sort_order, PDO::PARAM_INT);
}
$stmt->bindValue(':remarks', $remarks !== '' ? $remarks : null, $remarks !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':goal_no', $goal_no !== '' ? $goal_no : null, $goal_no !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
// 등록 쿼리를 실행합니다.
$stmt->execute();
}
// AJAX JSON response
echo json_encode(['success' => true]);
exit;
+53
View File
@@ -0,0 +1,53 @@
<?php
require __DIR__ . '/../../bbs/db_conn.php';
header('Content-Type: application/json; charset=utf-8');
// PDO 연결 생성
$pdo = db_conn();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => 'Invalid request']);
exit;
}
$content_id = isset($_POST['content_id']) ? trim($_POST['content_id']) : '';
$keywords_csv = isset($_POST['keywords']) ? trim($_POST['keywords']) : '';
$admin_id = 'admin'; // User login ID not provided in context, defaulting to 'admin'
if ($content_id === '') {
echo json_encode(['success' => false, 'message' => 'missing content_id']);
exit;
}
try {
$pdo->beginTransaction();
// Set all existing active keywords to inactive first
$updateStmt = $pdo->prepare("UPDATE edu_content_keywords SET is_active='0', updated_at=NOW(), updated_by=:admin WHERE content_id=:c AND is_active='1'");
$updateStmt->execute([':c' => $content_id, ':admin' => $admin_id]);
if ($keywords_csv !== '') {
$keywords = explode(',', $keywords_csv);
$insertStmt = $pdo->prepare("INSERT INTO edu_content_keywords (content_id, keyword_code, is_active, created_by, created_at, updated_by, updated_at)
VALUES (:c, :k, '1', :admin1, NOW(), :admin2, NOW())
ON DUPLICATE KEY UPDATE is_active='1', updated_by=:admin3, updated_at=NOW()");
foreach ($keywords as $k) {
$k = trim($k);
if ($k !== '') {
$insertStmt->execute([
':c' => $content_id,
':k' => $k,
':admin1' => $admin_id,
':admin2' => $admin_id,
':admin3' => $admin_id
]);
}
}
}
$pdo->commit();
echo json_encode(['success' => true]);
} catch (Exception $e) {
$pdo->rollBack();
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+12
View File
@@ -0,0 +1,12 @@
{
"quarter_qty": 3085,
"access_qty": 85,
"quarter_qty_u": 3207,
"access_qty_u": 1192,
"legal_qty_all": 2805,
"legal_qty_y": 476,
"goals_qty": 41,
"goals_completed_qty": 2,
"completed_qty": 4,
"completed_qty_u": 541
}
+129
View File
@@ -0,0 +1,129 @@
<?php
require_once __DIR__ . '/../../bbs/db_conn.php';
$search_comp = $_GET['comp'] ?? '';
$search_year = $_GET['year'] ?? date('Y');
$search_dept = $_GET['dept'] ?? '';
$search_name = $_GET['name'] ?? '';
$search_comp_status = $_GET['comp_status'] ?? '';
try {
$pdo = db_conn();
// G1 메인 쿼리와 동일한 쿼리 사용
// $sql_inner = "SELECT a.sys_comp_code, a.belong_comp
// , (SELECT code_name FROM edu_codes c WHERE c.group_code = 'CO100' AND c.code = a.belong_comp LIMIT 1) AS comp_name
// , a.name, a.member_id
// , a.dept_name
// , IFNULL(b.formatted_tm, '00시간 00분') AS all_tm
// , CASE WHEN a.member_id IN (
// SELECT u2.member_id FROM edu_users u2 WHERE NOT EXISTS (
// SELECT 1 FROM edu_contents c2 WHERE c2.category_code = 'CA10003' AND c2.base_year = ? AND c2.is_active = '1'
// AND NOT EXISTS (SELECT 1 FROM edu_learning_histories h2 WHERE h2.content_id = c2.content_id AND h2.member_id = u2.member_id AND h2.sys_comp_code = u2.sys_comp_code AND h2.completed_at IS NOT NULL AND h2.completed_at != '')
// )
// ) THEN '수료' ELSE '미수료' END AS completion_status
// , fn_get_progress_rate(a.sys_comp_code, ?, a.member_id, 'CA10003', '') AS progress_rate
// , fn_get_completion_date(a.sys_comp_code, ?, a.member_id, 'CA10003', '') AS completion_date
// FROM edu_users a
// LEFT JOIN (
// SELECT x.sys_comp_code, x.member_id,
// CONCAT(LPAD(FLOOR(SUM(CASE WHEN x.completed_at IS NOT NULL AND x.completed_at <> '' THEN x.content_tm ELSE x.watch_tm END)/3600),2,'0'),'시간 ',
// LPAD(FLOOR((SUM(CASE WHEN x.completed_at IS NOT NULL AND x.completed_at <> '' THEN x.content_tm ELSE x.watch_tm END)%3600)/60),2,'0'),'분') AS formatted_tm
// FROM edu_learning_histories x JOIN edu_contents y ON x.content_id = y.content_id
// WHERE y.category_code = 'CA10003' AND YEAR(x.first_viewed_at) = ?
// GROUP BY x.sys_comp_code, x.member_id
// ) b ON a.member_id = b.member_id AND a.sys_comp_code = b.sys_comp_code
// WHERE (a.end_date IS NULL OR a.end_date = '' OR (a.end_date > '1000-01-01' AND YEAR(a.end_date) >= ?))
// AND (? = '' OR a.belong_comp = ?)
// AND a.dept_name LIKE CONCAT('%', ?, '%')
// AND a.name LIKE CONCAT('%', ?, '%')";
$sql_inner = "SELECT a.sys_comp_code, a.belong_comp
, (SELECT code_name FROM edu_codes c WHERE c.group_code = 'CO100' AND c.code = a.belong_comp LIMIT 1) AS comp_name
, a.name, a.member_id
, a.dept_name
, IFNULL(b.formatted_tm, '00시간 00분') AS all_tm
, CASE WHEN a.member_id IN (
SELECT u2.member_id FROM edu_users u2 WHERE NOT EXISTS (
SELECT 1 FROM edu_contents c2 WHERE c2.category_code = 'CA10003' AND c2.base_year = ? AND c2.is_active = '1'
AND NOT EXISTS (SELECT 1 FROM edu_learning_histories h2 WHERE h2.content_id = c2.content_id AND h2.member_id = u2.member_id AND h2.sys_comp_code = u2.sys_comp_code AND h2.completed_at IS NOT NULL AND h2.completed_at != '')
)
) THEN '수료' ELSE '미수료' END AS completion_status
, fn_get_progress_rate(a.sys_comp_code, ?, a.member_id, 'CA10003', '') AS progress_rate -- 진행율
, fn_get_completion_date(a.sys_comp_code, ?, a.member_id, 'CA10003', '') AS completion_date -- 학습완료일
FROM edu_users a
LEFT JOIN (
SELECT z.sys_comp_code, x.member_id,
CONCAT(LPAD(FLOOR(SUM(CASE WHEN x.completed_at IS NOT NULL AND x.completed_at <> '' THEN x.content_tm ELSE x.watch_tm END)/3600),2,'0'),'시간 ',
LPAD(FLOOR((SUM(CASE WHEN x.completed_at IS NOT NULL AND x.completed_at <> '' THEN x.content_tm ELSE x.watch_tm END)%3600)/60),2,'0'),'분') AS formatted_tm
FROM edu_learning_histories x JOIN edu_contents y ON x.content_id = y.content_id
JOIN edu_users z ON x.sys_comp_code = z.working_comp AND x.member_id = z.member_id
WHERE y.category_code = 'CA10003' AND YEAR(x.first_viewed_at) = ?
GROUP BY x.sys_comp_code, x.member_id
) b ON a.member_id = b.member_id AND a.sys_comp_code = b.sys_comp_code
WHERE (a.end_date IS NULL OR a.end_date = '' OR (a.end_date > '1000-01-01' AND YEAR(a.end_date) >= ?))
and a.sys_comp_code = a.belong_comp
AND (? = '' OR a.belong_comp = ?)
AND a.dept_name LIKE CONCAT('%', ?, '%')
AND a.name LIKE CONCAT('%', ?, '%')";
if ($search_comp_status === 'Y') {
$sql = "SELECT * FROM ($sql_inner) t WHERE completion_status = '수료'";
} elseif ($search_comp_status === 'N') {
$sql = "SELECT * FROM ($sql_inner) t WHERE completion_status = '미수료'";
} else {
$sql = "SELECT * FROM ($sql_inner) t";
}
$stmt = $pdo->prepare($sql);
$stmt->execute([$search_year, $search_year, $search_year, $search_year, $search_year, $search_comp, $search_comp, $search_dept, $search_name]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Exception $e) {
die("DB Error");
}
header("Content-Type: application/vnd.ms-excel; charset=utf-8");
header("Content-Disposition: attachment; filename=legal_edu_result_" . date('Ymd') . ".xls");
header("Expires: 0");
header("Cache-Control: must-revalidate, post-check=0, pre-check=0");
header("Pragma: public");
echo '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head><body>';
echo '<table border="1">';
echo '<thead><tr>';
echo '<th>NO</th>';
echo '<th>소속법인</th>';
echo '<th>성명</th>';
echo '<th>사번</th>';
echo '<th>부서</th>';
echo '<th>학습시간</th>';
echo '<th>진도율</th>';
echo '<th>교육이수일</th>';
echo '<th>수료구분</th>';
echo '</tr></thead>';
echo '<tbody>';
$idx = 1;
foreach ($rows as $row) {
echo '<tr>';
echo '<td>' . $idx++ . '</td>';
echo '<td>' . htmlspecialchars($row['comp_name'] ?? '') . '</td>';
echo '<td>' . htmlspecialchars($row['name'] ?? '') . '</td>';
echo '<td>' . htmlspecialchars($row['member_id'] ?? '') . '</td>';
echo '<td>' . htmlspecialchars($row['dept_name'] ?? '') . '</td>';
echo '<td>' . htmlspecialchars($row['all_tm'] ?? '') . '</td>';
// 진행율 처리
$progress = $row['progress_rate'] ?? '0%';
$progress_value = is_numeric($progress) ? (int) $progress : (int) preg_replace('/[^0-9]/', '', $progress);
echo '<td>' . $progress_value . '%</td>';
echo '<td>' . htmlspecialchars($row['completion_date'] ?? '-') . '</td>';
echo '<td>' . htmlspecialchars($row['completion_status']) . '</td>';
echo '</tr>';
}
echo '</tbody></table></body></html>';
+614
View File
@@ -0,0 +1,614 @@
<?php
require_once __DIR__ . '/../../bbs/auth.php';
edu_require_login();
require_once __DIR__ . '/../../bbs/db_conn.php';
$search_comp = $_GET['comp'] ?? '';
if (empty($search_comp) && !isset($_GET['comp'])) {
$search_comp = $sys_comp_code;
}
$search_year = $_GET['year'] ?? date('Y');
// 권한에 따른 법인 제한
if ($auth_level === 'LE10002') {
$search_comp = $sys_comp_code;
}
try {
$pdo = db_conn();
// 1) proc_get_legal_total_report 호출
$stmt_total = $pdo->prepare("CALL proc_get_legal_total_report(?, '', ?)");
$stmt_total->execute([$search_comp, $search_year]);
$total_data = $stmt_total->fetch(PDO::FETCH_ASSOC);
while ($stmt_total->nextRowset()) {}
unset($stmt_total);
// 만약 전체조회 등 데이터가 없을 경우 기본값 세팅
if (!$total_data) {
$total_data = [
'title01' => $search_year . '년 법정의무교육',
'title02' => '교육결과보고서',
'edu_purpose' => '',
'corp_name' => '',
'edu_term' => '',
'edu_category' => '',
'all_user_qty' => 0,
'all_completed_qty' => 0,
'rmks' => ''
];
}
// 2) proc_get_legal_report_by_year_category_sum 호출
$stmt_sum = $pdo->prepare("CALL proc_get_legal_report_by_year_category_sum(?, '', ?)");
$stmt_sum->execute([$search_comp, $search_year]);
$sum_rows = $stmt_sum->fetchAll(PDO::FETCH_ASSOC);
while ($stmt_sum->nextRowset()) {}
unset($stmt_sum);
// 3) proc_get_legal_report 호출 (과정별 시트 데이터)
$stmt_detail = $pdo->prepare("CALL proc_get_legal_report(?, '', ?)");
$stmt_detail->execute([$search_comp, $search_year]);
$detail_rows = $stmt_detail->fetchAll(PDO::FETCH_ASSOC);
while ($stmt_detail->nextRowset()) {}
unset($stmt_detail);
} catch (Exception $e) {
die("DB Error: " . $e->getMessage());
}
// HTTP Header 설정 - Excel 파일 다운로드 유도
$safe_corp_name = preg_replace('/[\/\\:*?"<>|]/', '', $total_data['corp_name'] ?? '');
$safe_corp_name = trim($safe_corp_name);
if (empty($safe_corp_name)) {
$safe_corp_name = '법정의무교육';
}
$download_filename = $safe_corp_name . "_교육결과보고서_" . date('Ymd') . ".xls";
header("Content-Type: application/vnd.ms-excel; charset=utf-8");
header("Content-Disposition: attachment; filename=\"" . $download_filename . "\"; filename*=UTF-8''" . rawurlencode($download_filename));
header("Expires: 0");
header("Cache-Control: must-revalidate, post-check=0, pre-check=0");
header("Pragma: public");
// XML Spreadsheet 2003 양식 작성 시작
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n";
echo '<?mso-application progid="Excel.Sheet"?>' . "\n";
?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:x="urn:schemas-microsoft-com:office:excel"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:html="http://www.w3.org/TR/REC-html40">
<DocumentProperties xmlns="urn:schemas-microsoft-com:office:office">
<Author>Baron Consultant</Author>
<LastAuthor>Baron Consultant</LastAuthor>
<Created><?php echo date('Y-m-d\TH:i:s\Z'); ?></Created>
<Version>16.00</Version>
</DocumentProperties>
<OfficeDocumentSettings xmlns="urn:schemas-microsoft-com:office:office">
<AllowPNG/>
</OfficeDocumentSettings>
<ExcelWorkbook xmlns="urn:schemas-microsoft-com:office:excel">
<WindowHeight>9000</WindowHeight>
<WindowWidth>13600</WindowWidth>
<WindowTopX>0</WindowTopX>
<WindowTopY>0</WindowTopY>
<ProtectStructure>False</ProtectStructure>
<ProtectWindows>False</ProtectWindows>
</ExcelWorkbook>
<Styles>
<!-- 기본 폰트 및 정렬 -->
<Style ss:ID="Default" ss:Name="Normal">
<Alignment ss:Vertical="Center"/>
<Borders/>
<Font ss:FontName="맑은 고딕" x:CharSet="129" ss:Size="10" ss:Color="#000000"/>
<Interior/>
<NumberFormat/>
<Protection/>
</Style>
<!-- 메인 대제목 -->
<Style ss:ID="sMainTitle">
<Alignment ss:Horizontal="Center" ss:Vertical="Center" ss:WrapText="1"/>
<Font ss:FontName="맑은 고딕" ss:Size="18" ss:Bold="1" ss:Color="#111111"/>
</Style>
<!-- 소제목 (1. 교육목적 등) -->
<Style ss:ID="sSecTitle">
<Alignment ss:Horizontal="Left" ss:Vertical="Center"/>
<Font ss:FontName="맑은 고딕" ss:Size="11" ss:Bold="1" ss:Color="#114b3d"/>
</Style>
<!-- 교육목적 단락용 스타일 (자동 줄바꿈, 옅은 회색 배경) -->
<Style ss:ID="sPurposeText">
<Alignment ss:Horizontal="Left" ss:Vertical="Top" ss:WrapText="1"/>
<Borders>
<Border ss:Position="Top" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#cccccc"/>
<Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#cccccc"/>
<Border ss:Position="Left" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#cccccc"/>
<Border ss:Position="Right" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#cccccc"/>
</Borders>
<Font ss:FontName="맑은 고딕" ss:Size="9.5" ss:Color="#333333"/>
<Interior ss:Color="#fafafa" ss:Pattern="Solid"/>
</Style>
<!-- 테이블 헤더 (진회색 배경) -->
<Style ss:ID="sTblHeader">
<Alignment ss:Horizontal="Center" ss:Vertical="Center" ss:WrapText="1"/>
<Borders>
<Border ss:Position="Top" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
<Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
<Border ss:Position="Left" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
<Border ss:Position="Right" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
</Borders>
<Font ss:FontName="맑은 고딕" ss:Size="10" ss:Bold="1" ss:Color="#333333"/>
<Interior ss:Color="#e8ecef" ss:Pattern="Solid"/>
</Style>
<!-- 테이블 헤더 (종합시트용 민트/그린 계열) -->
<Style ss:ID="sTblHeaderTeal">
<Alignment ss:Horizontal="Center" ss:Vertical="Center" ss:WrapText="1"/>
<Borders>
<Border ss:Position="Top" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#cccccc"/>
<Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="2" ss:Color="#114b3d"/>
<Border ss:Position="Left" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#cccccc"/>
<Border ss:Position="Right" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#cccccc"/>
</Borders>
<Font ss:FontName="맑은 고딕" ss:Size="10" ss:Bold="1" ss:Color="#333333"/>
<Interior ss:Color="#f0f5f4" ss:Pattern="Solid"/>
</Style>
<!-- 테이블 셀 (가운데 정렬, 얇은 테두리) -->
<Style ss:ID="sCellCenter">
<Alignment ss:Horizontal="Center" ss:Vertical="Center" ss:WrapText="1"/>
<Borders>
<Border ss:Position="Top" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#dddddd"/>
<Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#dddddd"/>
<Border ss:Position="Left" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#dddddd"/>
<Border ss:Position="Right" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#dddddd"/>
</Borders>
<Font ss:FontName="맑은 고딕" ss:Size="10" ss:Color="#333333"/>
</Style>
<!-- 테이블 셀 (좌측 정렬, 얇은 테두리) -->
<Style ss:ID="sCellLeft">
<Alignment ss:Horizontal="Left" ss:Vertical="Center" ss:WrapText="1"/>
<Borders>
<Border ss:Position="Top" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#dddddd"/>
<Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#dddddd"/>
<Border ss:Position="Left" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#dddddd"/>
<Border ss:Position="Right" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#dddddd"/>
</Borders>
<Font ss:FontName="맑은 고딕" ss:Size="10" ss:Color="#333333"/>
</Style>
<!-- 상세 시트 - 교육내용 박스용 스타일 -->
<Style ss:ID="sDescContent">
<Alignment ss:Horizontal="Left" ss:Vertical="Top" ss:WrapText="1"/>
<Borders>
<Border ss:Position="Top" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
<Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
<Border ss:Position="Left" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
<Border ss:Position="Right" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
</Borders>
<Font ss:FontName="맑은 고딕" ss:Size="9.5" ss:Color="#444444"/>
<Interior ss:Color="#fafafa" ss:Pattern="Solid"/>
</Style>
<!-- 하단 팩스/주소 표시용 소형 폰트 -->
<Style ss:ID="sFooter">
<Alignment ss:Horizontal="Center" ss:Vertical="Center"/>
<Font ss:FontName="맑은 고딕" ss:Size="8.5" ss:Color="#666666"/>
</Style>
<!-- "위와 같이 제출합니다" 문장용 -->
<Style ss:ID="sSubmitText">
<Alignment ss:Horizontal="Center" ss:Vertical="Center"/>
<Font ss:FontName="맑은 고딕" ss:Size="11" ss:Bold="1" ss:Color="#111111"/>
</Style>
<!-- 회사명 하단 푸터 -->
<Style ss:ID="sCompanyFooter">
<Alignment ss:Horizontal="Center" ss:Vertical="Center"/>
<Font ss:FontName="맑은 고딕" ss:Size="14" ss:Bold="1" ss:Color="#114b3d"/>
</Style>
<!-- 결재박스 헤더 스타일 -->
<Style ss:ID="sApprHeader">
<Alignment ss:Horizontal="Center" ss:Vertical="Center" ss:WrapText="1"/>
<Borders>
<Border ss:Position="Top" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
<Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
<Border ss:Position="Left" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
<Border ss:Position="Right" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
</Borders>
<Font ss:FontName="맑은 고딕" ss:Size="9.5" ss:Color="#333333"/>
<Interior ss:Color="#f5f7f8" ss:Pattern="Solid"/>
</Style>
<!-- 결재박스 세로 병합 "결재" 스타일 -->
<Style ss:ID="sApprVertical">
<Alignment ss:Horizontal="Center" ss:Vertical="Center" ss:WrapText="1"/>
<Borders>
<Border ss:Position="Top" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
<Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
<Border ss:Position="Left" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
<Border ss:Position="Right" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
</Borders>
<Font ss:FontName="맑은 고딕" ss:Size="9.5" ss:Bold="1" ss:Color="#333333"/>
<Interior ss:Color="#f5f7f8" ss:Pattern="Solid"/>
</Style>
<!-- 상세시트 결재 및 제목 박스용 세부 스타일 -->
<Style ss:ID="sCourseTitleBlock">
<Alignment ss:Horizontal="Center" ss:Vertical="Center" ss:WrapText="1"/>
<Borders>
<Border ss:Position="Top" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
<Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
<Border ss:Position="Left" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
<Border ss:Position="Right" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#b2b2b2"/>
</Borders>
<Font ss:FontName="맑은 고딕" ss:Size="14" ss:Bold="1" ss:Color="#111111"/>
<Interior ss:Color="#f5f7f8" ss:Pattern="Solid"/>
</Style>
</Styles>
<!-- ========================================================================= -->
<!-- [시트 1 : 교육결과보고서(종합)] 시작 -->
<!-- ========================================================================= -->
<Worksheet ss:Name="교육결과보고서(종합)">
<Table ss:ExpandedColumnCount="9" ss:ExpandedRowCount="40" x:FullColumns="1" x:FullRows="1" ss:DefaultColumnWidth="54" ss:DefaultRowHeight="20">
<!-- 열 너비 설정 (A: Spacer, B: 헤더/과정명, C-H: 데이터 및 요약) -->
<Column ss:Width="20"/> <!-- A: Spacer -->
<Column ss:Width="130"/> <!-- B -->
<Column ss:Width="100"/> <!-- C -->
<Column ss:Width="80"/> <!-- D -->
<Column ss:Width="80"/> <!-- E -->
<Column ss:Width="80"/> <!-- F -->
<Column ss:Width="80"/> <!-- G -->
<Column ss:Width="80"/> <!-- H -->
<Row ss:Height="10"/> <!-- spacer row -->
<!-- 대제목 -->
<Row ss:Height="25">
<Cell ss:Index="2" ss:MergeAcross="6" ss:StyleID="sMainTitle">
<Data ss:Type="String"><?php echo htmlspecialchars($total_data['title01'] ?? '', ENT_QUOTES, 'UTF-8'); ?></Data>
</Cell>
</Row>
<Row ss:Height="25">
<Cell ss:Index="2" ss:MergeAcross="6" ss:StyleID="sMainTitle">
<Data ss:Type="String"><?php echo htmlspecialchars($total_data['title02'] ?? '', ENT_QUOTES, 'UTF-8'); ?></Data>
</Cell>
</Row>
<Row ss:Height="15"/> <!-- spacer row -->
<!-- 1. 교육목적 -->
<Row ss:Height="20">
<Cell ss:Index="2" ss:MergeAcross="6" ss:StyleID="sSecTitle">
<Data ss:Type="String">1. 교육목적</Data>
</Cell>
</Row>
<Row ss:Height="70">
<Cell ss:Index="2" ss:MergeAcross="6" ss:StyleID="sPurposeText">
<Data ss:Type="String"><?php echo str_replace(["\r\n", "\r", "\n"], "&#10;", htmlspecialchars($total_data['edu_purpose'] ?? '', ENT_QUOTES, 'UTF-8')); ?></Data>
</Cell>
</Row>
<Row ss:Height="15"/> <!-- spacer row -->
<!-- 2. 교육 운영 개요 -->
<Row ss:Height="20">
<Cell ss:Index="2" ss:MergeAcross="6" ss:StyleID="sSecTitle">
<Data ss:Type="String">2. 교육 운영 개요</Data>
</Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:StyleID="sTblHeader"><Data ss:Type="String">사업장명</Data></Cell>
<Cell ss:MergeAcross="5" ss:StyleID="sCellCenter">
<Data ss:Type="String"><?php echo htmlspecialchars($total_data['corp_name'] ?? '', ENT_QUOTES, 'UTF-8'); ?></Data>
</Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:StyleID="sTblHeader"><Data ss:Type="String">교육일시</Data></Cell>
<Cell ss:MergeAcross="5" ss:StyleID="sCellCenter">
<Data ss:Type="String"><?php echo htmlspecialchars($total_data['edu_term'] ?? '', ENT_QUOTES, 'UTF-8'); ?></Data>
</Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:StyleID="sTblHeader"><Data ss:Type="String">교육구분</Data></Cell>
<Cell ss:MergeAcross="5" ss:StyleID="sCellCenter">
<Data ss:Type="String"><?php echo htmlspecialchars($total_data['edu_category'] ?? '', ENT_QUOTES, 'UTF-8'); ?></Data>
</Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:MergeDown="1" ss:StyleID="sTblHeader"><Data ss:Type="String">참석인원</Data></Cell>
<Cell ss:Index="3" ss:MergeAcross="1" ss:StyleID="sTblHeader"><Data ss:Type="String">대상인원</Data></Cell>
<Cell ss:Index="5" ss:MergeAcross="1" ss:StyleID="sTblHeader"><Data ss:Type="String">실시인원</Data></Cell>
<Cell ss:Index="7" ss:MergeAcross="1" ss:StyleID="sTblHeader"><Data ss:Type="String">미실시인원</Data></Cell>
</Row>
<Row ss:Height="22">
<?php
$all_qty = (int)($total_data['all_user_qty'] ?? 0);
$comp_qty = (int)($total_data['all_completed_qty'] ?? 0);
$incomp_qty = max(0, $all_qty - $comp_qty);
?>
<Cell ss:Index="3" ss:MergeAcross="1" ss:StyleID="sCellCenter"><Data ss:Type="String"><?php echo $all_qty; ?>명</Data></Cell>
<Cell ss:Index="5" ss:MergeAcross="1" ss:StyleID="sCellCenter"><Data ss:Type="String"><?php echo $comp_qty; ?>명</Data></Cell>
<Cell ss:Index="7" ss:MergeAcross="1" ss:StyleID="sCellCenter"><Data ss:Type="String"><?php echo $incomp_qty; ?>명</Data></Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:StyleID="sTblHeader"><Data ss:Type="String">비고</Data></Cell>
<Cell ss:MergeAcross="5" ss:StyleID="sCellCenter">
<Data ss:Type="String"><?php echo htmlspecialchars($total_data['rmks'] ?? '', ENT_QUOTES, 'UTF-8'); ?></Data>
</Cell>
</Row>
<Row ss:Height="15"/> <!-- spacer row -->
<!-- 3. 과정별 교육 이수 현황 -->
<Row ss:Height="20">
<Cell ss:Index="2" ss:MergeAcross="6" ss:StyleID="sSecTitle">
<Data ss:Type="String">3. 과정별 교육 이수 현황</Data>
</Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:MergeAcross="1" ss:StyleID="sTblHeaderTeal"><Data ss:Type="String">과정별</Data></Cell>
<Cell ss:Index="4" ss:MergeAcross="1" ss:StyleID="sTblHeaderTeal"><Data ss:Type="String">교육인원</Data></Cell>
<Cell ss:Index="6" ss:StyleID="sTblHeaderTeal"><Data ss:Type="String">수료인원</Data></Cell>
<Cell ss:Index="7" ss:MergeAcross="1" ss:StyleID="sTblHeaderTeal"><Data ss:Type="String">수료율</Data></Cell>
</Row>
<?php if (empty($sum_rows)): ?>
<Row ss:Height="22">
<Cell ss:Index="2" ss:MergeAcross="6" ss:StyleID="sCellCenter"><Data ss:Type="String">데이터가 없습니다.</Data></Cell>
</Row>
<?php else: ?>
<?php foreach ($sum_rows as $row): ?>
<Row ss:Height="22">
<Cell ss:Index="2" ss:MergeAcross="1" ss:StyleID="sCellLeft">
<Data ss:Type="String"><?php echo htmlspecialchars($row['edu_name'] ?? '', ENT_QUOTES, 'UTF-8'); ?></Data>
</Cell>
<Cell ss:Index="4" ss:MergeAcross="1" ss:StyleID="sCellCenter">
<Data ss:Type="String"><?php echo (int)($row['all_user_qty'] ?? 0); ?>명</Data>
</Cell>
<Cell ss:Index="6" ss:StyleID="sCellCenter">
<Data ss:Type="String"><?php echo (int)($row['completed_qty'] ?? 0); ?>명</Data>
</Cell>
<Cell ss:Index="7" ss:MergeAcross="1" ss:StyleID="sCellCenter">
<?php
$rate = $row['completed_rate'] ?? '0';
if (is_numeric($rate)) {
$rate = number_format((float)$rate, 1);
}
?>
<Data ss:Type="String"><?php echo $rate; ?>%</Data>
</Cell>
</Row>
<?php endforeach; ?>
<?php endif; ?>
<Row ss:Height="30"/> <!-- spacer row -->
<!-- 최하단 주소 및 연락처 푸터 -->
<Row ss:Height="20">
<Cell ss:Index="2" ss:MergeAcross="6" ss:StyleID="sFooter">
<Data ss:Type="String">서울시 송파구 오금로 554 한맥B/D TEL | 02-1234-5600 FAX | 02-1234-5600</Data>
</Cell>
</Row>
</Table>
<WorksheetOptions xmlns="urn:schemas-microsoft-com:office:excel">
<PageSetup>
<Layout x:Orientation="Portrait"/>
<Header x:Margin="0.3"/>
<Footer x:Margin="0.3"/>
<PageMargins x:Bottom="0.75" x:Left="0.7" x:Right="0.7" x:Top="0.75"/>
</PageSetup>
<FitToPage/>
<Print>
<FitWidth>1</FitWidth>
<FitHeight>1</FitHeight>
<PaperSizeIndex>9</PaperSizeIndex>
</Print>
</WorksheetOptions>
</Worksheet>
<!-- ========================================================================= -->
<!-- [시트 2~ : 교육과정별 결과보고서] 시작 -->
<!-- ========================================================================= -->
<?php foreach ($detail_rows as $dIdx => $dRow): ?>
<?php
$rawName = $dRow['edu_name'] ?? '교육과정';
// 특수문자 제거
$cleanName = str_replace([':', '\\', '/', '?', '*', '[', ']'], '', $rawName);
// 인덱스를 붙여 유일성 보장 및 31글자 제한 내 절단
$sheetName = ($dIdx + 1) . '. ' . $cleanName;
$sheetName = mb_strimwidth($sheetName, 0, 31, "");
?>
<Worksheet ss:Name="<?php echo htmlspecialchars($sheetName, ENT_QUOTES, 'UTF-8'); ?>">
<Table ss:ExpandedColumnCount="11" ss:ExpandedRowCount="40" x:FullColumns="1" x:FullRows="1" ss:DefaultColumnWidth="54" ss:DefaultRowHeight="20">
<!-- 열 너비 설정 (상세 시트 최적화) -->
<Column ss:Width="20"/> <!-- A: Spacer -->
<Column ss:Width="50"/> <!-- B -->
<Column ss:Width="50"/> <!-- C -->
<Column ss:Width="50"/> <!-- D -->
<Column ss:Width="50"/> <!-- E -->
<Column ss:Width="70"/> <!-- F -->
<Column ss:Width="70"/> <!-- G -->
<Column ss:Width="70"/> <!-- H -->
<Column ss:Width="70"/> <!-- I -->
<Column ss:Width="100"/> <!-- J -->
<Row ss:Height="10"/> <!-- spacer row -->
<!-- 타이틀 블록 & 결재란 병합 영역 -->
<Row ss:Height="22">
<!-- 타이틀 (B2~D4 세로 가로 병합) -->
<?php
$titleText = $search_year . "년\n" . ($dRow['edu_name'] ?? '') . "\n결과보고서";
?>
<Cell ss:Index="2" ss:MergeAcross="2" ss:MergeDown="2" ss:StyleID="sCourseTitleBlock">
<Data ss:Type="String"><?php echo str_replace("\n", "&#10;", htmlspecialchars($titleText, ENT_QUOTES, 'UTF-8')); ?></Data>
</Cell>
<!-- 결재 세로 병합 (E2~E4) -->
<Cell ss:Index="5" ss:MergeDown="2" ss:StyleID="sApprVertical">
<Data ss:Type="String">결&#10;재</Data>
</Cell>
<!-- 작성, 검토, 승인 헤더 -->
<Cell ss:Index="6" ss:StyleID="sApprHeader"><Data ss:Type="String">작성</Data></Cell>
<Cell ss:Index="7" ss:StyleID="sApprHeader"><Data ss:Type="String">검토</Data></Cell>
<Cell ss:Index="8" ss:StyleID="sApprHeader"><Data ss:Type="String">승인</Data></Cell>
<!-- 담당자 및 정성호 -->
<Cell ss:Index="9" ss:StyleID="sApprHeader"><Data ss:Type="String">담당자</Data></Cell>
<Cell ss:Index="10" ss:StyleID="sCellCenter"><Data ss:Type="String">정성호</Data></Cell>
</Row>
<Row ss:Height="25">
<!-- F3, G3, H3 결재란 사인/도장 공간 (MergeDown으로 세로 2개 셀 병합 영역) -->
<Cell ss:Index="6" ss:MergeDown="1" ss:StyleID="sCellCenter"/>
<Cell ss:Index="7" ss:MergeDown="1" ss:StyleID="sCellCenter"/>
<Cell ss:Index="8" ss:MergeDown="1" ss:StyleID="sCellCenter"/>
<!-- 작성일자 -->
<Cell ss:Index="9" ss:StyleID="sApprHeader"><Data ss:Type="String">작성일자</Data></Cell>
<Cell ss:Index="10" ss:StyleID="sCellCenter">
<Data ss:Type="String"><?php echo date('Y. m. d'); ?></Data>
</Cell>
</Row>
<Row ss:Height="25">
<!-- 보존기간 -->
<Cell ss:Index="9" ss:StyleID="sApprHeader"><Data ss:Type="String">보존기간</Data></Cell>
<Cell ss:Index="10" ss:StyleID="sCellCenter"><Data ss:Type="String">3년</Data></Cell>
</Row>
<Row ss:Height="15"/> <!-- spacer row -->
<!-- 1. 과정개요 -->
<Row ss:Height="20">
<Cell ss:Index="2" ss:MergeAcross="8" ss:StyleID="sSecTitle">
<Data ss:Type="String">1. 과정개요</Data>
</Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:MergeAcross="2" ss:StyleID="sTblHeader"><Data ss:Type="String">교 육 명</Data></Cell>
<Cell ss:Index="5" ss:MergeAcross="5" ss:StyleID="sCellCenter">
<Data ss:Type="String"><?php echo htmlspecialchars($dRow['edu_name'] ?? '', ENT_QUOTES, 'UTF-8'); ?></Data>
</Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:MergeAcross="2" ss:StyleID="sTblHeader"><Data ss:Type="String">교육기간</Data></Cell>
<Cell ss:Index="5" ss:MergeAcross="5" ss:StyleID="sCellCenter">
<Data ss:Type="String"><?php echo htmlspecialchars($dRow['edu_term'] ?? '', ENT_QUOTES, 'UTF-8'); ?></Data>
</Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:MergeAcross="2" ss:StyleID="sTblHeader"><Data ss:Type="String">교육방법</Data></Cell>
<Cell ss:Index="5" ss:MergeAcross="5" ss:StyleID="sCellCenter">
<Data ss:Type="String"><?php echo htmlspecialchars($dRow['edu_category'] ?? '', ENT_QUOTES, 'UTF-8'); ?></Data>
</Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:MergeAcross="2" ss:StyleID="sTblHeader"><Data ss:Type="String">교육인원</Data></Cell>
<Cell ss:Index="5" ss:MergeAcross="5" ss:StyleID="sCellCenter">
<Data ss:Type="String"><?php echo (int)($dRow['all_user_qty'] ?? 0); ?> 명</Data>
</Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:MergeAcross="2" ss:StyleID="sTblHeader"><Data ss:Type="String">이수기준</Data></Cell>
<Cell ss:Index="5" ss:MergeAcross="5" ss:StyleID="sCellCenter">
<Data ss:Type="String">진도율 100% 달성 및 교육기간 내 수강 완료</Data>
</Cell>
</Row>
<Row ss:Height="15"/> <!-- spacer row -->
<!-- 2. 교육결과 -->
<Row ss:Height="20">
<Cell ss:Index="2" ss:MergeAcross="8" ss:StyleID="sSecTitle">
<Data ss:Type="String">2. 교육결과</Data>
</Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:MergeAcross="2" ss:StyleID="sTblHeader"><Data ss:Type="String">교육기간</Data></Cell>
<Cell ss:Index="5" ss:MergeAcross="5" ss:StyleID="sCellCenter">
<Data ss:Type="String"><?php echo htmlspecialchars($dRow['edu_term'] ?? '', ENT_QUOTES, 'UTF-8'); ?></Data>
</Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:MergeAcross="2" ss:StyleID="sTblHeader"><Data ss:Type="String">교육대상자</Data></Cell>
<Cell ss:Index="5" ss:MergeAcross="5" ss:StyleID="sCellCenter">
<Data ss:Type="String"><?php echo (int)($dRow['all_user_qty'] ?? 0); ?>명</Data>
</Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:MergeAcross="2" ss:StyleID="sTblHeader"><Data ss:Type="String">수료자</Data></Cell>
<Cell ss:Index="5" ss:MergeAcross="5" ss:StyleID="sCellCenter">
<Data ss:Type="String"><?php echo (int)($dRow['completed_qty'] ?? 0); ?>명</Data>
</Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:MergeAcross="2" ss:StyleID="sTblHeader"><Data ss:Type="String">미수료자</Data></Cell>
<Cell ss:Index="5" ss:MergeAcross="5" ss:StyleID="sCellCenter">
<Data ss:Type="String"><?php echo (int)($dRow['incomplete_qty'] ?? 0); ?>명</Data>
</Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:MergeAcross="2" ss:StyleID="sTblHeader"><Data ss:Type="String">수료율</Data></Cell>
<Cell ss:Index="5" ss:MergeAcross="5" ss:StyleID="sCellCenter">
<?php
$cRate = $dRow['completed_rate'] ?? '0';
if (is_numeric($cRate)) {
$cRate = number_format((float)$cRate, 1);
}
?>
<Data ss:Type="String"><?php echo $cRate; ?>%</Data>
</Cell>
</Row>
<Row ss:Height="15"/> <!-- spacer row -->
<!-- 3. 교육내용 -->
<Row ss:Height="20">
<Cell ss:Index="2" ss:MergeAcross="8" ss:StyleID="sSecTitle">
<Data ss:Type="String">3. 교육내용</Data>
</Cell>
</Row>
<Row ss:Height="22">
<Cell ss:Index="2" ss:MergeAcross="8" ss:StyleID="sTblHeader"><Data ss:Type="String">교육내용</Data></Cell>
</Row>
<Row ss:Height="120">
<Cell ss:Index="2" ss:MergeAcross="8" ss:MergeDown="4" ss:StyleID="sDescContent">
<Data ss:Type="String"><?php echo str_replace(["\r\n", "\r", "\n"], "&#10;", htmlspecialchars($dRow['edu_desc'] ?? '', ENT_QUOTES, 'UTF-8')); ?></Data>
</Cell>
</Row>
<!-- MergedDown rows (height spacers) -->
<Row ss:Height="22"/>
<Row ss:Height="22"/>
<Row ss:Height="22"/>
<Row ss:Height="22"/>
<Row ss:Height="25"/> <!-- spacer row -->
<!-- 제출 문구 -->
<Row ss:Height="25">
<Cell ss:Index="2" ss:MergeAcross="8" ss:StyleID="sSubmitText">
<Data ss:Type="String">위와 같이 <?php echo $search_year; ?>년 <?php echo htmlspecialchars($dRow['edu_name'] ?? '', ENT_QUOTES, 'UTF-8'); ?> 결과보고서를 제출합니다.</Data>
</Cell>
</Row>
<Row ss:Height="20"/> <!-- spacer row -->
<!-- 회사 푸터 -->
<Row ss:Height="30">
<Cell ss:Index="2" ss:MergeAcross="8" ss:StyleID="sCompanyFooter">
<Data ss:Type="String"><?php echo htmlspecialchars($total_data['corp_name'] ?? '', ENT_QUOTES, 'UTF-8'); ?></Data>
</Cell>
</Row>
</Table>
<WorksheetOptions xmlns="urn:schemas-microsoft-com:office:excel">
<PageSetup>
<Layout x:Orientation="Portrait"/>
<Header x:Margin="0.3"/>
<Footer x:Margin="0.3"/>
<PageMargins x:Bottom="0.75" x:Left="0.7" x:Right="0.7" x:Top="0.75"/>
</PageSetup>
<FitToPage/>
<Print>
<FitWidth>1</FitWidth>
<FitHeight>1</FitHeight>
<PaperSizeIndex>9</PaperSizeIndex>
</Print>
</WorksheetOptions>
</Worksheet>
<?php endforeach; ?>
</Workbook>
+31
View File
@@ -0,0 +1,31 @@
<?php
require __DIR__ . '/../../bbs/db_conn.php';
header('Content-Type: application/json; charset=utf-8');
// PDO 연결 생성
$pdo = db_conn();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => '잘못된 요청입니다.']);
exit;
}
$content_id = isset($_POST['content_id']) ? trim($_POST['content_id']) : '';
$seqRaw = isset($_POST['seq']) ? trim((string)$_POST['seq']) : '';
$seq = ($seqRaw !== '' ? (int)$seqRaw : null);
if ($content_id === '' || $seq === null) {
echo json_encode(['success' => false, 'message' => '콘텐츠 ID가 필요합니다.']);
exit;
}
try {
$stmt = $pdo->prepare("DELETE FROM edu_content_memos WHERE content_id = :c AND seq = :s");
$stmt->execute([':c' => $content_id, ':s' => $seq]);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => 'DB 에러: ' . $e->getMessage()]);
}
exit;
+64
View File
@@ -0,0 +1,64 @@
<?php
require __DIR__ . '/../../bbs/db_conn.php';
header('Content-Type: application/json; charset=utf-8');
// PDO 연결 생성
$pdo = db_conn();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => '잘못된 요청입니다.']);
exit;
}
$content_id = isset($_POST['content_id']) ? trim($_POST['content_id']) : '';
$seqRaw = isset($_POST['seq']) ? trim((string)$_POST['seq']) : '';
$seq = ($seqRaw !== '' ? (int)$seqRaw : null);
$title = isset($_POST['title']) ? trim($_POST['title']) : '';
$is_active = isset($_POST['is_active']) ? '1' : '0';
if ($content_id === '') {
echo json_encode(['success' => false, 'message' => '콘텐츠 ID가 필요합니다.']);
exit;
}
try {
if ($seq === null) {
// 신규 등록: seq를 순차증감(최대 3개)
$stmt = $pdo->prepare("SELECT COALESCE(MAX(seq), 0) FROM edu_content_memos WHERE content_id = :c");
$stmt->execute([':c' => $content_id]);
$nextSeq = (int)$stmt->fetchColumn() + 1;
if ($nextSeq > 3) {
echo json_encode(['success' => false, 'message' => '포스트잇은 최대 3개까지만 등록할 수 있습니다.']);
exit;
}
$ins = $pdo->prepare("INSERT INTO edu_content_memos (content_id, seq, title, is_active, created_by, created_at, updated_by, updated_at)
VALUES (:c, :s, :t, :a, 'admin', NOW(), 'admin', NOW())");
$ins->execute([':c' => $content_id, ':s' => $nextSeq, ':t' => $title, ':a' => $is_active]);
echo json_encode(['success' => true, 'seq' => $nextSeq]);
exit;
}
if ($seq < 1 || $seq > 3) {
echo json_encode(['success' => false, 'message' => 'seq는 1~3 범위만 가능합니다.']);
exit;
}
// 수정: 존재하면 UPDATE, 없으면 INSERT(지정 seq)
$sql = "INSERT INTO edu_content_memos (content_id, seq, title, is_active, created_by, created_at, updated_by, updated_at)
VALUES (:c, :s, :t, :a, 'admin', NOW(), 'admin', NOW())
ON DUPLICATE KEY UPDATE
title = VALUES(title),
is_active = VALUES(is_active),
updated_by = 'admin',
updated_at = NOW()";
$stmt = $pdo->prepare($sql);
$stmt->execute([':c' => $content_id, ':s' => $seq, ':t' => $title, ':a' => $is_active]);
echo json_encode(['success' => true, 'seq' => $seq]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => 'DB 에러: ' . $e->getMessage()]);
}
exit;
+29
View File
@@ -0,0 +1,29 @@
<?php
require __DIR__ . '/../../bbs/db_conn.php';
header('Content-Type: application/json; charset=utf-8');
// PDO 연결 생성
$pdo = db_conn();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => '잘못된 요청입니다.']);
exit;
}
$code = isset($_POST['code']) ? trim($_POST['code']) : '';
$content = isset($_POST['content']) ? trim($_POST['content']) : '';
if ($code === '' || !in_array($code, ['100', '200', '900'])) {
echo json_encode(['success' => false, 'message' => '잘못된 코드입니다.']);
exit;
}
try {
$stmt = $pdo->prepare("UPDATE edu_codes SET desc01 = :content, updated_by = 'admin', updated_at = NOW() WHERE group_code = 'AL100' AND code = :code");
$stmt->execute([':content' => $content, ':code' => $code]);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => 'DB 에러: ' . $e->getMessage()]);
}
exit;
+109
View File
@@ -0,0 +1,109 @@
<?php
require __DIR__ . '/../../bbs/db_conn.php';
header('Content-Type: application/json; charset=utf-8');
// PDO 연결 생성
$pdo = db_conn();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => '잘못된 요청입니다.']);
exit;
}
$code = isset($_POST['code']) ? trim($_POST['code']) : '';
$endDate = isset($_POST['end_date']) ? trim($_POST['end_date']) : '';
$action = isset($_POST['action']) ? trim($_POST['action']) : 'count'; // 'count' 또는 'send'
if ($code === '' || !in_array($code, ['100', '200', '900'])) {
echo json_encode(['success' => false, 'message' => '잘못된 코드입니다.']);
exit;
}
//$typeCode = 'AL100' . str_pad($code, 3, '0', STR_PAD_LEFT) . '0';
$typeCode = 'AL100' . $code;
try {
// 대상 사용자 조회
if ($code === '100') {
// 미수료자: 법정교육 콘텐츠를 수강해야 하지만 학습 이력이 없는 사용자
$userStmt = $pdo->prepare("
SELECT u.member_id, u.name, u.sys_comp_code
FROM edu_contents a
CROSS JOIN edu_users u
WHERE a.category_code = 'CA10003' -- 법정교육 카테고리
AND a.base_year = YEAR(NOW()) -- 기준년도
AND a.is_active = '1' -- 콘텐츠 활성 여부
AND NOT EXISTS (
SELECT 1 FROM edu_learning_histories b
WHERE b.content_id = a.content_id -- 콘텐츠ID
AND b.member_id = u.member_id -- 사용자ID
AND b.sys_comp_code = u.sys_comp_code -- 사영여부
AND b.completed_at is not null -- 학습완료일시
)
GROUP BY u.member_id, u.name, u.sys_comp_code
");
} else {
// 법정교육 안내 및 전체 공지: 활성 사용자
$userStmt = $pdo->prepare("
SELECT member_id, name, sys_comp_code
FROM edu_users
WHERE (end_date IS NULL OR end_date > CURDATE())
");
}
$userStmt->execute();
$users = $userStmt->fetchAll(PDO::FETCH_ASSOC);
$userCount = count($users);
// 대상자 수만 반환하는 경우
if ($action === 'count') {
echo json_encode(['success' => true, 'count' => $userCount]);
exit;
}
// 실제 알람 발송
$pdo->beginTransaction();
// 법정교육 기간 정보 조회 (메시지 치환용)
$periodStmt = $pdo->prepare("SELECT start_date, end_date FROM edu_contents WHERE category_code = 'CA10003' AND base_year = YEAR(NOW()) LIMIT 1");
$periodStmt->execute();
$period = $periodStmt->fetch(PDO::FETCH_ASSOC);
$startDate = $period['start_date'] ?? '';
$periodEndDate = $period['end_date'] ?? '';
// 기본 메시지 조회
$msgStmt = $pdo->prepare("SELECT desc01 FROM edu_codes WHERE group_code = 'AL100' AND code = :code");
$msgStmt->execute([':code' => $code]);
$baseMessage = $msgStmt->fetchColumn();
foreach ($users as $user) {
// seq 최대값 +1
$seqStmt = $pdo->prepare("SELECT COALESCE(MAX(seq), 0) + 1 FROM edu_notifications WHERE member_id = :mid");
$seqStmt->execute([':mid' => $user['member_id']]);
$seq = $seqStmt->fetchColumn();
// 메시지 치환
$message = str_replace(
['{학습자명}', '{시작일}', '{종료일}'],
[$user['name'], $startDate, $periodEndDate],
$baseMessage
);
// 알람 저장
$insStmt = $pdo->prepare("INSERT INTO edu_notifications (member_id, corp_code, seq, type_code, message, sent_at, end_date) VALUES (:mid, :corp, :seq, :type, :msg, NOW(), :end)");
$insStmt->execute([
':mid' => $user['member_id'],
':corp' => $user['sys_comp_code'],
':seq' => $seq,
':type' => $typeCode,
':msg' => $message,
':end' => $endDate
]);
}
$pdo->commit();
echo json_encode(['success' => true, 'count' => $userCount]);
} catch (PDOException $e) {
$pdo->rollBack();
echo json_encode(['success' => false, 'message' => 'DB 에러: ' . $e->getMessage()]);
}
exit;
+30
View File
@@ -0,0 +1,30 @@
<?php
require __DIR__ . '/../../bbs/db_conn.php';
header('Content-Type: application/json; charset=utf-8');
// PDO 연결 생성
$pdo = db_conn();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => '잘못된 요청입니다.']);
exit;
}
$goal_code = isset($_POST['goal_code']) ? trim($_POST['goal_code']) : '';
$seqRaw = isset($_POST['seq']) ? trim((string)$_POST['seq']) : '';
$seq = ($seqRaw !== '' ? (int)$seqRaw : null);
if ($goal_code === '' || $seq === null) {
echo json_encode(['success' => false, 'message' => '학습목표 코드가 필요합니다.']);
exit;
}
try {
$stmt = $pdo->prepare("DELETE FROM edu_recommended_goals WHERE goal_code = :g AND seq = :s");
$stmt->execute([':g' => $goal_code, ':s' => $seq]);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => 'DB 에러: ' . $e->getMessage()]);
}
exit;
+50
View File
@@ -0,0 +1,50 @@
<?php
require __DIR__ . '/../../bbs/db_conn.php';
header('Content-Type: application/json; charset=utf-8');
// PDO 연결 생성
$pdo = db_conn();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => 'Invalid request']);
exit;
}
$keywords_csv = isset($_POST['keywords']) ? trim($_POST['keywords']) : '';
$admin_id = 'admin'; // User login ID not provided in context, defaulting to 'admin'
try {
$pdo->beginTransaction();
// Set all existing active keywords to inactive first
$updateStmt = $pdo->prepare("UPDATE edu_recommend_keywords SET is_active='0', updated_at=NOW(), updated_by=:admin WHERE is_active='1'");
$updateStmt->execute([ ':admin' => $admin_id]);
if ($keywords_csv !== '') {
$keywords = explode(',', $keywords_csv);
if (count($keywords) > 2) {
echo json_encode(['success' => false, 'message' => '최대 2개의 키워드만 선택 가능합니다.']);
exit;
}
$insertStmt = $pdo->prepare("INSERT INTO edu_recommend_keywords ( keyword_code, is_active, created_by, created_at, updated_by, updated_at)
VALUES ( :k, '1', :admin1, NOW(), :admin2, NOW())
ON DUPLICATE KEY UPDATE is_active='1', updated_by=:admin3, updated_at=NOW()");
foreach ($keywords as $k) {
$k = trim($k);
if ($k !== '') {
$insertStmt->execute([
':k' => $k,
':admin1' => $admin_id,
':admin2' => $admin_id,
':admin3' => $admin_id
]);
}
}
}
$pdo->commit();
echo json_encode(['success' => true]);
} catch (Exception $e) {
$pdo->rollBack();
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+62
View File
@@ -0,0 +1,62 @@
<?php
require __DIR__ . '/../../bbs/db_conn.php';
header('Content-Type: application/json; charset=utf-8');
// PDO 연결 생성
$pdo = db_conn();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => '잘못된 요청입니다.']);
exit;
}
$goal_code = isset($_POST['goal_code']) ? trim($_POST['goal_code']) : '';
$seqRaw = isset($_POST['seq']) ? trim((string)$_POST['seq']) : '';
$seq = ($seqRaw !== '' ? (int)$seqRaw : null);
$title = isset($_POST['title']) ? trim($_POST['title']) : '';
$title2 = isset($_POST['title2']) ? trim($_POST['title2']) : '';
$is_active = isset($_POST['is_active']) ? '1' : '0';
if ($goal_code === '') {
echo json_encode(['success' => false, 'message' => '학습목표 코드가 필요합니다.']);
exit;
}
try {
if ($seq === null) {
if ($seq === null) {
// 신규 등록: seq를 순차증감(최대 3개)
$stmt = $pdo->prepare("SELECT COALESCE(MAX(seq), 0) FROM edu_recommended_goals WHERE goal_code = :g");
$stmt->execute([':g' => $goal_code]);
$nextSeq = (int)$stmt->fetchColumn() + 1;
if ($nextSeq > 3) {
echo json_encode(['success' => false, 'message' => '추천이유는 최대 3개까지만 등록할 수 있습니다.']);
exit;
}
$ins = $pdo->prepare("INSERT INTO edu_recommended_goals (goal_code, seq, title, title2, is_active, created_by, created_at, updated_by, updated_at)
VALUES (:g, :s, :t, :t2, :a, 'admin', NOW(), 'admin', NOW())");
$ins->execute([':g' => $goal_code, ':s' => $nextSeq, ':t' => $title, ':t2' => $title2, ':a' => $is_active]);
echo json_encode(['success' => true, 'seq' => $nextSeq]);
exit;
}
}
// 수정: 존재하면 UPDATE, 없으면 INSERT(지정 seq)
$sql = "INSERT INTO edu_recommended_goals (goal_code, seq, title, title2, is_active, created_by, created_at, updated_by, updated_at)
VALUES (:g, :s, :t, :t2, :a, 'admin', NOW(), 'admin', NOW())
ON DUPLICATE KEY UPDATE
title = VALUES(title),
title2 = VALUES(title2),
is_active = VALUES(is_active),
updated_by = 'admin',
updated_at = NOW()";
$stmt = $pdo->prepare($sql);
$stmt->execute([':g' => $goal_code, ':s' => $seq, ':t' => $title, ':t2' => $title2, ':a' => $is_active]);
echo json_encode(['success' => true, 'seq' => $seq]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => 'DB 에러: ' . $e->getMessage()]);
}
exit;
+30
View File
@@ -0,0 +1,30 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once '../../bbs/db_conn.php';
// PDO 연결 생성
$pdo = db_conn();
try {
$group_code = $_POST['group_code'] ?? '';
$code = $_POST['code'] ?? '';
$base_code = $_POST['base_code'] ?? '';
$code_name = $_POST['code_name'] ?? '';
// ?ъ슜?щ?: ?대씪?댁뼵?몃뒗 1 ?먮뒗 0??蹂대궦??
$is_active = ($_POST['is_active'] ?? '') === '1' ? '1' : '0';
$desc01 = $_POST['desc01'] ?? '';
if (empty($group_code) || empty($code)) {
echo json_encode(['success' => false, 'message' => '?꾩닔 ?꾨뱶媛€ ?꾨씫?섏뿀?듬땲??']);
exit;
}
// code_name 而щ읆 異붽?
$stmt = $pdo->prepare("REPLACE INTO edu_codes (group_code, code, base_code, code_name, is_active, desc01, updated_at) VALUES (?, ?, ?, ?, ?, ?, NOW())");
$result = $stmt->execute([$group_code, $code, $base_code, $code_name, $is_active, $desc01]);
echo json_encode(['success' => (bool)$result]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
?>
+27
View File
@@ -0,0 +1,27 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once '../../bbs/db_conn.php';
// PDO 연결 생성
$pdo = db_conn();
try {
$group_code = $_POST['group_code'] ?? '';
$group_name = $_POST['group_name'] ?? '';
$is_active = ($_POST['is_active'] ?? '') === '1' ? '1' : '0';
$comment = $_POST['comment'] ?? '';
$desc01 = $_POST['desc01'] ?? '';
if (empty($group_code) || empty($group_name)) {
echo json_encode(['success' => false, 'message' => '?꾩닔 ?꾨뱶媛€ ?꾨씫?섏뿀?듬땲??']);
exit;
}
$stmt = $pdo->prepare("REPLACE INTO edu_code_group (group_code, group_name, is_active, comment, desc01, updated_at) VALUES (?, ?, ?, ?, ?, NOW())");
$result = $stmt->execute([$group_code, $group_name, $is_active, $comment, $desc01]);
echo json_encode(['success' => (bool)$result]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
?>
+42
View File
@@ -0,0 +1,42 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once '../../bbs/db_conn.php';
// PDO 연결 생성
$pdo = db_conn();
try {
$input = json_decode(file_get_contents('php://input'), true);
$base_year = $input['base_year'] ?? '';
$start_date = $input['start_date'] ?? '';
$end_date = $input['end_date'] ?? '';
if (empty($base_year) || empty($start_date) || empty($end_date)) {
echo json_encode(['success' => false, 'message' => '?꾩닔 ?뚮씪誘명꽣媛€ ?꾨씫?섏뿀?듬땲??']);
exit;
}
// edu_contents ?뚯씠釉붿뿉??踰뺤젙援먯쑁(CA10003) 移댄뀒怨좊━ ??ぉ ?낅뜲?댄듃
$stmt = $pdo->prepare("
UPDATE edu_contents
SET
start_date = ?,
end_date = ?,
updated_at = NOW()
WHERE 1=1
AND category_code = 'CA10003'
AND base_year = ?
");
$result = $stmt->execute([$start_date, $end_date, $base_year]);
if ($result) {
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'message' => '?€???ㅽ뙣']);
}
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
?>
+9
View File
@@ -0,0 +1,9 @@
<?php
require __DIR__ . '/../../bbs/db_conn.php';
$pdo = db_conn();
$stmt = $pdo->prepare("CALL proc_get_learner_status('2026', '', '', '', '', '')");
$stmt->execute();
$row = $stmt->fetch(PDO::FETCH_ASSOC);
echo '<pre>';
print_r($row);
echo '</pre>';
+9
View File
@@ -0,0 +1,9 @@
<?php
$ch = curl_init('http://localhost/html/admin/bbs/content_save.php');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
'title' => 'Test title',
'category_code' => 'CA10001'
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
echo "Response: " . curl_exec($ch);
+33
View File
@@ -0,0 +1,33 @@
<?php
require_once __DIR__ . '/../../bbs/db_conn.php';
header('Content-Type: application/json; charset=utf-8');
try {
$pdo = db_conn();
$input = json_decode(file_get_contents('php://input'), true);
$offer_id = $input['offer_id'] ?? '';
$status_code = $input['status_code'] ?? '';
$reason_return = $input['reason_return'] ?? '';
if (!$offer_id || !$status_code) {
echo json_encode(['success' => false, 'message' => '필수 파라미터가 누락되었습니다.'], JSON_UNESCAPED_UNICODE);
exit;
}
$sql = "UPDATE edu_content_offer SET status_code = ?, reason_return = ? WHERE offer_id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$status_code, $reason_return, $offer_id]);
if ($stmt->rowCount() > 0) {
echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE);
} else {
echo json_encode(['success' => false, 'message' => '업데이트된 행이 없습니다.'], JSON_UNESCAPED_UNICODE);
}
} catch (Exception $e) {
error_log('[UPDATE_OFFER ERROR] ' . $e->getMessage());
echo json_encode(['success' => false, 'message' => '서버 오류가 발생했습니다.'], JSON_UNESCAPED_UNICODE);
}
?>
+30
View File
@@ -0,0 +1,30 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once '../../bbs/db_conn.php';
// PDO 연결 생성
$pdo = db_conn();
try {
$input = json_decode(file_get_contents('php://input'), true);
$member_id = $input['member_id'] ?? '';
$auth_level = $input['auth_level'] ?? '';
if (empty($member_id) || empty($auth_level)) {
echo json_encode(['success' => false, 'message' => '필수 파라미터가 누락되었습니다.']);
exit;
}
$stmt = $pdo->prepare("UPDATE edu_users SET auth_level = ? WHERE member_id = ? ");
$result = $stmt->execute([$auth_level, $member_id]);
if ($result) {
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'message' => '업데이트 실패']);
}
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
?>
+130
View File
@@ -0,0 +1,130 @@
<?php
/**
* YouTube Data API v3 프록시 + Gemini AI 무료 요약 (최종 완성본)
*/
require __DIR__ . '/../../bbs/auth.php';
edu_require_login();
header('Content-Type: application/json; charset=utf-8');
// ★ 서비스 키 설정
define('YOUTUBE_API_KEY', 'AIzaSyDfj_8oN6cbkmxn2zS77_CG-OB6xF8ywpI');
define('GEMINI_API_KEY', 'AIzaSyAlQXuhXroeoWmh0u_N3Qq9tgUMLYuucWI');
$video_id = isset($_GET['video_id']) ? trim($_GET['video_id']) : '';
$video_id = extract_yt_id($video_id);
if (!$video_id) {
echo json_encode(['success' => false, 'message' => '유효하지 않은 ID']);
exit;
}
/**
* YouTube ID 추출 함수
*/
function extract_yt_id($input)
{
if (preg_match('/^[a-zA-Z0-9_-]{11}$/', $input))
return $input;
if (preg_match('#youtu\.be/([a-zA-Z0-9_-]{11})#', $input, $m))
return $m[1];
if (preg_match('#[?&]v=([a-zA-Z0-9_-]{11})#', $input, $m))
return $m[1];
return '';
}
/**
* Gemini AI 요약 함수 (보안 및 통신 강화)
*/
function get_gemini_summary($text)
{
if (empty(GEMINI_API_KEY) || GEMINI_API_KEY === 'YOUR_GEMINI_API_KEY_HERE')
return null;
// $url = 'https://generativelanguage.googleapis.com/v1/models/gemini-1.5-flash:generateContent?key=' . GEMINI_API_KEY;
$url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=' . GEMINI_API_KEY;
$data = [
'contents' => [
[
'parts' => [['text' => "너는 교육 콘텐츠 에디터야. 아래 유튜브 설명에서 타임라인(00:00)과 구독/좋아요 요청은 완전히 제외하고, 핵심 내용만 직장인을 위해 3문장 이내로 요약해줘:\n\n" . mb_substr($text, 0, 3000, 'UTF-8')]]
]
]
];
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36');
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code === 200 && $response) {
$res = json_decode($response, true);
$result = $res['candidates'][0]['content']['parts'][0]['text'] ?? null;
return $result ? trim($result) : null;
}
return null;
}
// 1. YouTube API 호출 (데이터 가져오기 로직 복구)
$api_url = "https://www.googleapis.com/youtube/v3/videos?id={$video_id}&part=snippet,contentDetails&key=" . YOUTUBE_API_KEY;
$yt_res = @file_get_contents($api_url);
$yt_data = json_decode($yt_res, true);
if (empty($yt_data['items'])) {
echo json_encode(['success' => false, 'message' => '영상을 찾을 수 없습니다.']);
exit;
}
$item = $yt_data['items'][0];
$yt_title = $item['snippet']['title'] ?? '';
$yt_desc_raw = $item['snippet']['description'] ?? '';
$duration = $item['contentDetails']['duration'] ?? 'PT0S';
// 2. 요약 처리 (AI 시도)
$summary = get_gemini_summary($yt_desc_raw);
// [핵심] AI가 실패하거나, 결과에 여전히 타임라인(00:00)이 포함된 경우 강력한 수동 필터 작동
if (!$summary || preg_match('/[0-9]{1,2}:[0-9]{2}/', $summary)) {
$text = $yt_desc_raw;
// (1) 타임라인 제거 (줄 시작이 숫자:숫자인 모든 줄 삭제)
$text = preg_replace('/^[ ]*[0-9]{1,2}:[0-9]{2}.*$/m', '', $text);
// (2) 구독/좋아요/광고 문구가 포함된 문장 삭제
$patterns = ['구독', '좋아요', '알림설정', '인스타그램', '페이스북', '문의'];
foreach ($patterns as $p) {
$text = preg_replace('/[^.!?\n]*?' . $p . '[^.!?\n]*?[.!?\n]?/u', '', $text);
}
// (3) 해시태그 및 이모지 제거
$text = preg_replace('/#[^\s#]+/u', '', $text);
$text = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $text);
// (4) 공백 정리 및 200자 절삭
$text = preg_replace('/\s+/', ' ', trim($text));
$summary = mb_substr($text, 0, 200, 'UTF-8');
if (mb_strlen($text, 'UTF-8') > 200)
$summary .= '...';
}
// 3. 재생 시간 계산 (ISO 8601 -> 초)
preg_match('/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/', $duration, $m);
$content_tm = ((int) ($m[1] ?? 0) * 3600) + ((int) ($m[2] ?? 0) * 60) + (int) ($m[3] ?? 0);
// 최종 결과 출력
echo json_encode([
'success' => true,
'yt_title' => $yt_title,
'description' => $summary,
'content_tm' => $content_tm,
'duration_fmt' => sprintf('%d:%02d', intdiv($content_tm, 60), $content_tm % 60),
], JSON_UNESCAPED_UNICODE);
exit;
+260
View File
@@ -0,0 +1,260 @@
<svg width="230" height="230" viewBox="0 0 230 230" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M114.834 -0.000174937L0 114.833L114.834 229.667L229.667 114.833L114.834 -0.000174937Z" fill="#F08300"/>
<path d="M114.822 229.671L15.2109 130.061C15.2109 130.061 45.3494 62.4111 114.822 62.4111C184.294 62.4111 214.554 128.764 214.554 128.764L114.822 229.671Z" fill="#F08300"/>
<path d="M114.822 62.8579C184.133 62.8579 214.393 128.968 214.393 128.968L114.822 229.673L15.4141 130.264C15.4141 130.264 45.512 62.8579 114.822 62.8579Z" fill="#F08300"/>
<path d="M114.817 63.2627C183.965 63.2627 214.184 129.17 214.184 129.17L114.817 229.672L15.5703 130.426C15.5703 130.426 45.6682 63.2222 114.817 63.2222V63.2627Z" fill="#F08400"/>
<path d="M114.825 63.665C183.771 63.665 213.99 129.37 213.99 129.37L114.825 229.71L15.7812 130.666C15.7812 130.666 45.8792 63.665 114.825 63.665Z" fill="#F08400"/>
<path d="M114.825 64.0728C183.609 64.0728 213.829 129.575 213.829 129.575L114.825 229.713L15.9844 130.872C15.9844 130.872 46.0418 64.1133 114.825 64.1133V64.0728Z" fill="#F08500"/>
<path d="M114.812 64.4761C183.434 64.4761 213.613 129.776 213.613 129.776L114.812 229.711L16.1328 131.032C16.1328 131.032 46.1902 64.4761 114.812 64.4761Z" fill="#F08500"/>
<path d="M114.82 64.8804C183.239 64.8804 213.418 129.937 213.418 129.937L114.82 229.67L16.3438 131.193C16.3438 131.193 46.4012 64.8398 114.82 64.8398V64.8804Z" fill="#F08600"/>
<path d="M114.805 65.2866C183.062 65.2866 213.241 130.141 213.241 130.141L114.805 229.711L16.5312 131.437C16.5312 131.437 46.5481 65.3271 114.805 65.3271V65.2866Z" fill="#F08600"/>
<path d="M114.823 65.6904C182.918 65.6904 213.056 130.342 213.056 130.342L114.823 229.71L16.7109 131.598C16.7109 131.598 46.7279 65.6904 114.823 65.6904Z" fill="#F08700"/>
<path d="M114.823 66.0586C182.757 66.0586 212.854 130.508 212.854 130.508L114.823 229.673L16.9141 131.764C16.9141 131.764 46.931 66.0586 114.823 66.0586Z" fill="#F18700"/>
<path d="M114.808 66.4619C182.539 66.4619 212.677 130.709 212.677 130.709L114.808 229.671L17.1016 131.964C17.1016 131.964 47.078 66.5024 114.808 66.5024V66.4619Z" fill="#F18800"/>
<path d="M114.826 66.8662C182.395 66.8662 212.492 130.87 212.492 130.87L114.826 229.63L17.2812 132.085C17.2812 132.085 47.2576 66.8257 114.826 66.8257V66.8662Z" fill="#F18800"/>
<path d="M114.811 67.2729C182.217 67.2729 212.275 131.074 212.275 131.074L114.811 229.672L17.4688 132.33C17.4688 132.33 47.4452 67.2729 114.811 67.2729Z" fill="#F18902"/>
<path d="M114.812 67.6768C182.015 67.6768 212.113 131.275 212.113 131.275L114.812 229.671L17.6719 132.491C17.6719 132.491 47.6078 67.6768 114.812 67.6768Z" fill="#F18905"/>
<path d="M114.806 68.084C181.848 68.084 211.905 131.48 211.905 131.48L114.806 229.673L17.8281 132.695C17.8281 132.695 47.764 68.084 114.806 68.084Z" fill="#F18A07"/>
<path d="M114.814 68.4873C181.694 68.4873 211.711 131.681 211.711 131.681L114.814 229.671L18.0391 132.896C18.0391 132.896 47.9344 68.4873 114.814 68.4873Z" fill="#F18A0A"/>
<path d="M114.815 68.8911C181.492 68.8911 211.549 131.882 211.549 131.882L114.815 229.71L18.2422 133.097C18.2422 133.097 48.1376 68.8911 114.815 68.8911Z" fill="#F18B0C"/>
<path d="M114.817 69.2983C181.332 69.2983 211.349 132.046 211.349 132.046L114.817 229.672L18.4062 133.262C18.4062 133.262 48.3016 69.2983 114.817 69.2983Z" fill="#F18B0F"/>
<path d="M114.81 69.7017C181.163 69.7017 211.139 132.247 211.139 132.247L114.81 229.67L18.6016 133.462C18.6016 133.462 48.4564 69.7017 114.81 69.7017Z" fill="#F18C11"/>
<path d="M114.818 70.1064C180.969 70.1064 210.986 132.449 210.986 132.449L114.818 229.67L18.8125 133.624C18.8125 133.624 48.6673 70.0659 114.818 70.0659V70.1064Z" fill="#F18C13"/>
<path d="M114.812 70.5127C180.801 70.5127 210.777 132.653 210.777 132.653L114.812 229.671L18.9688 133.828C18.9688 133.828 48.8236 70.5127 114.812 70.5127Z" fill="#F18D15"/>
<path d="M114.813 70.9165C180.639 70.9165 210.575 132.854 210.575 132.854L114.813 229.71L19.1719 134.069C19.1719 134.069 48.9862 70.957 114.813 70.957V70.9165Z" fill="#F18D16"/>
<path d="M114.815 71.3247C180.479 71.3247 210.415 133.019 210.415 133.019L114.815 229.673L19.3359 134.194C19.3359 134.194 49.1504 71.2842 114.774 71.2842L114.815 71.3247Z" fill="#F18E18"/>
<path d="M114.815 71.7275C180.277 71.7275 210.213 133.22 210.213 133.22L114.815 229.671L19.5391 134.394C19.5391 134.394 49.3534 71.7275 114.815 71.7275Z" fill="#F18F1A"/>
<path d="M114.824 72.1348C180.124 72.1348 210.019 133.424 210.019 133.424L114.824 229.673L19.75 134.599C19.75 134.599 49.5239 72.1348 114.824 72.1348Z" fill="#F28F1B"/>
<path d="M114.818 72.5381C179.956 72.5381 209.851 133.625 209.851 133.625L114.818 229.712L19.9062 134.8C19.9062 134.8 49.6802 72.5381 114.778 72.5381H114.818Z" fill="#F2901C"/>
<path d="M114.819 72.9419C179.754 72.9419 209.649 133.826 209.649 133.826L114.819 229.71L20.1094 135.001C20.1094 135.001 49.8833 72.9824 114.819 72.9824V72.9419Z" fill="#F2901E"/>
<path d="M114.827 73.3491C179.6 73.3491 209.455 133.991 209.455 133.991L114.827 229.672L20.3203 135.165C20.3203 135.165 50.0536 73.3491 114.827 73.3491Z" fill="#F2911F"/>
<path d="M114.821 73.7534C179.433 73.7534 209.287 134.192 209.287 134.192L114.821 229.671L20.4766 135.327C20.4766 135.327 50.21 73.7129 114.821 73.7129V73.7534Z" fill="#F29120"/>
<path d="M114.822 74.1597C179.231 74.1597 209.085 134.396 209.085 134.396L114.822 229.672L20.6797 135.53C20.6797 135.53 50.413 74.1597 114.822 74.1597Z" fill="#F29222"/>
<path d="M114.807 74.5635C179.053 74.5635 208.868 134.597 208.868 134.597L114.807 229.712L20.8672 135.772C20.8672 135.772 50.56 74.604 114.807 74.604V74.5635Z" fill="#F29223"/>
<path d="M114.824 74.9673C178.909 74.9673 208.723 134.799 208.723 134.799L114.824 229.71L21.0469 135.933C21.0469 135.933 50.7397 74.9673 114.824 74.9673Z" fill="#F29324"/>
<path d="M114.809 75.3745C178.692 75.3745 208.506 135.003 208.506 135.003L114.809 229.712L21.2344 136.137C21.2344 136.137 50.9272 75.3745 114.809 75.3745Z" fill="#F29325"/>
<path d="M114.81 75.7778C178.53 75.7778 208.304 135.164 208.304 135.164L114.81 229.67L21.4375 136.298C21.4375 136.298 51.0898 75.7778 114.81 75.7778Z" fill="#F29426"/>
<path d="M114.812 76.1855C178.37 76.1855 208.144 135.369 208.144 135.369L114.812 229.713L21.6016 136.503C21.6016 136.503 51.2538 76.1855 114.812 76.1855Z" fill="#F29428"/>
<path d="M114.813 76.5889C178.168 76.5889 207.942 135.569 207.942 135.569L114.813 229.712L21.8047 136.704C21.8047 136.704 51.457 76.5889 114.813 76.5889Z" fill="#F29529"/>
<path d="M114.821 76.9927C178.014 76.9927 207.748 135.771 207.748 135.771L114.821 229.71L22.0156 136.864C22.0156 136.864 51.6275 76.9927 114.821 76.9927Z" fill="#F2952A"/>
<path d="M114.815 77.3608C177.847 77.3608 207.58 135.936 207.58 135.936L114.815 229.673L22.1719 137.03C22.1719 137.03 51.7837 77.3608 114.815 77.3608Z" fill="#F3962B"/>
<path d="M114.816 77.7646C177.685 77.7646 207.378 136.097 207.378 136.097L114.816 229.632L22.375 137.191C22.375 137.191 51.9463 77.7241 114.816 77.7241V77.7646Z" fill="#F3962C"/>
<path d="M114.824 78.167C177.491 78.167 207.184 136.297 207.184 136.297L114.824 229.669L22.5859 137.391C22.5859 137.391 52.1572 78.167 114.824 78.167Z" fill="#F3972D"/>
<path d="M114.818 78.5747C177.323 78.5747 207.016 136.502 207.016 136.502L114.818 229.672L22.7422 137.596C22.7422 137.596 52.3134 78.5747 114.818 78.5747Z" fill="#F3972E"/>
<path d="M114.811 78.978C177.154 78.978 206.806 136.703 206.806 136.703L114.811 229.67L22.9375 137.797C22.9375 137.797 52.4683 78.978 114.811 78.978Z" fill="#F39830"/>
<path d="M114.82 79.3857C176.96 79.3857 206.612 136.908 206.612 136.908L114.82 229.673L23.1484 137.961C23.1484 137.961 52.6793 79.3857 114.82 79.3857Z" fill="#F39831"/>
<path d="M114.814 79.7896C176.792 79.7896 206.444 137.069 206.444 137.069L114.814 229.672L23.3047 138.163C23.3047 138.163 52.8355 79.7896 114.814 79.7896Z" fill="#F39932"/>
<path d="M114.822 80.1934C176.638 80.1934 206.25 137.27 206.25 137.27L114.822 229.67L23.5156 138.364C23.5156 138.364 53.006 80.1934 114.822 80.1934Z" fill="#F39933"/>
<path d="M114.816 80.6006C176.43 80.6006 206.042 137.475 206.042 137.475L114.816 229.672L23.6719 138.528C23.6719 138.528 53.1623 80.6006 114.776 80.6006H114.816Z" fill="#F39A34"/>
<path d="M114.817 81.0039C176.268 81.0039 205.88 137.676 205.88 137.676L114.817 229.671L23.875 138.729C23.875 138.729 53.3652 81.0039 114.817 81.0039Z" fill="#F39A35"/>
<path d="M114.825 81.4116C176.115 81.4116 205.686 137.881 205.686 137.881L114.825 229.673L24.0859 138.934C24.0859 138.934 53.5357 81.4116 114.825 81.4116Z" fill="#F39B36"/>
<path d="M114.819 81.8149C175.907 81.8149 205.478 138.081 205.478 138.081L114.819 229.712L24.2422 139.135C24.2422 139.135 53.692 81.8149 114.779 81.8149H114.819Z" fill="#F39B37"/>
<path d="M114.82 82.2188C175.745 82.2188 205.316 138.242 205.316 138.242L114.82 229.67L24.4453 139.295C24.4453 139.295 53.8951 82.2188 114.82 82.2188Z" fill="#F39C38"/>
<path d="M114.813 82.6255C175.576 82.6255 205.107 138.446 205.107 138.446L114.813 229.672L24.6406 139.5C24.6406 139.5 54.0498 82.6255 114.813 82.6255Z" fill="#F49C39"/>
<path d="M114.823 83.0303C175.424 83.0303 204.914 138.649 204.914 138.649L114.823 229.672L24.8125 139.661C24.8125 139.661 54.2219 82.9897 114.782 82.9897L114.823 83.0303Z" fill="#F49D3A"/>
<path d="M114.823 83.4365C175.222 83.4365 204.752 138.852 204.752 138.852L114.823 229.713L25.0156 139.906C25.0156 139.906 54.4248 83.477 114.823 83.477V83.4365Z" fill="#F49D3B"/>
<path d="M114.808 83.8403C175.045 83.8403 204.535 139.054 204.535 139.054L114.808 229.712L25.2031 140.107C25.2031 140.107 54.5719 83.8808 114.808 83.8808V83.8403Z" fill="#F49E3C"/>
<path d="M114.826 84.2446C174.9 84.2446 204.35 139.215 204.35 139.215L114.826 229.671L25.3828 140.228C25.3828 140.228 54.7516 84.2041 114.785 84.2041L114.826 84.2446Z" fill="#F49E3D"/>
<path d="M114.811 84.6514C174.683 84.6514 204.173 139.419 204.173 139.419L114.811 229.672L25.5703 140.432C25.5703 140.432 54.9391 84.6514 114.811 84.6514Z" fill="#F49F3E"/>
<path d="M114.819 85.0547C174.529 85.0547 203.979 139.62 203.979 139.62L114.819 229.671L25.7812 140.633C25.7812 140.633 55.1094 85.0547 114.819 85.0547Z" fill="#F49F3F"/>
<path d="M114.813 85.4619C174.361 85.4619 203.77 139.825 203.77 139.825L114.813 229.713L25.9375 140.837C25.9375 140.837 55.2658 85.4619 114.813 85.4619Z" fill="#F4A040"/>
<path d="M114.814 85.8657C174.159 85.8657 203.609 140.026 203.609 140.026L114.814 229.712L26.1406 141.039C26.1406 141.039 55.4688 85.9062 114.814 85.9062V85.8657Z" fill="#F4A041"/>
<path d="M114.822 86.2705C174.006 86.2705 203.415 140.188 203.415 140.188L114.822 229.671L26.3516 141.16C26.3516 141.16 55.6393 86.23 114.822 86.23V86.2705Z" fill="#F4A142"/>
<path d="M114.817 86.6777C173.838 86.6777 203.207 140.392 203.207 140.392L114.817 229.673L26.5078 141.364C26.5078 141.364 55.7955 86.6372 114.817 86.6372V86.6777Z" fill="#F4A143"/>
<path d="M114.817 87.0801C173.636 87.0801 203.045 140.592 203.045 140.592L114.817 229.711L26.7109 141.605C26.7109 141.605 55.9987 87.1206 114.817 87.1206V87.0801Z" fill="#F4A244"/>
<path d="M114.818 87.4873C173.474 87.4873 202.843 140.797 202.843 140.797L114.818 229.713L26.9141 141.769C26.9141 141.769 56.1612 87.4873 114.818 87.4873Z" fill="#F4A245"/>
<path d="M114.82 87.8911C173.314 87.8911 202.643 140.998 202.643 140.998L114.82 229.712L27.0781 141.97C27.0781 141.97 56.3254 87.8911 114.82 87.8911Z" fill="#F4A346"/>
<path d="M114.82 88.2944C173.153 88.2944 202.481 141.199 202.481 141.199L114.82 229.71L27.2812 142.171C27.2812 142.171 56.4879 88.2944 114.82 88.2944Z" fill="#F4A347"/>
<path d="M114.821 88.7031C172.951 88.7031 202.279 141.364 202.279 141.364L114.821 229.673L27.4844 142.296C27.4844 142.296 56.6911 88.6626 114.821 88.6626V88.7031Z" fill="#F5A448"/>
<path d="M114.806 89.0664C172.774 89.0664 202.102 141.525 202.102 141.525L114.846 229.672L27.6719 142.497C27.6719 142.497 56.8786 89.0664 114.846 89.0664H114.806Z" fill="#F5A449"/>
<path d="M114.824 89.4697C172.629 89.4697 201.917 141.726 201.917 141.726L114.824 229.67L27.8516 142.698C27.8516 142.698 57.0178 89.4697 114.824 89.4697Z" fill="#F5A54A"/>
<path d="M114.824 89.8774C172.427 89.8774 201.715 141.931 201.715 141.931L114.824 229.673L28.0547 142.863C28.0547 142.863 57.2208 89.8774 114.824 89.8774Z" fill="#F5A54B"/>
<path d="M114.809 90.2808C172.25 90.2808 201.538 142.132 201.538 142.132L114.85 229.671L28.2422 143.064C28.2422 143.064 57.4084 90.2808 114.85 90.2808H114.809Z" fill="#F5A64C"/>
<path d="M114.827 90.688C172.106 90.688 201.353 142.296 201.353 142.296L114.827 229.673L28.4219 143.268C28.4219 143.268 57.5475 90.688 114.827 90.688Z" fill="#F5A64D"/>
<path d="M114.821 91.0918C171.898 91.0918 201.145 142.497 201.145 142.497L114.821 229.672L28.5781 143.429C28.5781 143.429 57.704 91.0918 114.781 91.0918H114.821Z" fill="#F5A74E"/>
<path d="M114.812 91.4946C171.727 91.4946 200.974 142.698 200.974 142.698L114.853 229.67L28.8125 143.629C28.8125 143.629 57.9382 91.4946 114.853 91.4946H114.812Z" fill="#F5A74F"/>
<path d="M114.807 91.9023C171.559 91.9023 200.766 142.903 200.766 142.903L114.807 229.672L28.9688 143.834C28.9688 143.834 58.054 91.9023 114.807 91.9023Z" fill="#F5A850"/>
<path d="M114.824 92.3062C171.374 92.3062 200.581 143.104 200.581 143.104L114.824 229.671L29.1484 143.995C29.1484 143.995 58.2337 92.3062 114.784 92.3062H114.824Z" fill="#F5A851"/>
<path d="M114.823 92.7134C171.211 92.7134 200.418 143.309 200.418 143.309L114.864 229.714L29.3906 144.24C29.3906 144.24 58.4759 92.7539 114.864 92.7539L114.823 92.7134Z" fill="#F5A952"/>
<path d="M114.818 93.1172C171.044 93.1172 200.21 143.469 200.21 143.469L114.818 229.672L29.5469 144.401C29.5469 144.401 58.5915 93.1172 114.818 93.1172Z" fill="#F5A953"/>
<path d="M114.812 93.5205C170.876 93.5205 200.001 143.67 200.001 143.67L114.812 229.67L29.7031 144.561C29.7031 144.561 58.7478 93.5205 114.771 93.5205H114.812Z" fill="#F5AA54"/>
<path d="M114.826 93.9277C170.688 93.9277 199.854 143.875 199.854 143.875L114.867 229.672L29.9609 144.766C29.9609 144.766 59.0056 93.9277 114.867 93.9277H114.826Z" fill="#F5AA55"/>
<path d="M114.813 94.3315C170.512 94.3315 199.638 144.076 199.638 144.076L114.813 229.711L30.1094 145.008C30.1094 145.008 59.1136 94.3721 114.813 94.3721V94.3315Z" fill="#F5AB56"/>
<path d="M114.815 94.7388C170.352 94.7388 199.438 144.281 199.438 144.281L114.815 229.714L30.2734 145.172C30.2734 145.172 59.2777 94.7388 114.775 94.7388H114.815Z" fill="#F6AB57"/>
<path d="M114.822 95.1426C170.157 95.1426 199.283 144.442 199.283 144.442L114.863 229.672L30.5234 145.333C30.5234 145.333 59.5277 95.1426 114.863 95.1426H114.822Z" fill="#F6AC57"/>
<path d="M114.816 95.5454C169.989 95.5454 199.074 144.642 199.074 144.642L114.816 229.67L30.6797 145.533C30.6797 145.533 59.6433 95.5454 114.816 95.5454Z" fill="#F6AC58"/>
<path d="M114.81 95.9502C169.821 95.9502 198.866 144.844 198.866 144.844L114.81 229.669L30.8359 145.695C30.8359 145.695 59.7996 95.9097 114.77 95.9097L114.81 95.9502Z" fill="#F6AD59"/>
<path d="M114.825 96.3569C169.633 96.3569 198.718 145.048 198.718 145.048L114.866 229.711L31.0938 145.94C31.0938 145.94 60.0574 96.3974 114.866 96.3974L114.825 96.3569Z" fill="#F6AD5A"/>
<path d="M114.819 96.7642C169.466 96.7642 198.51 145.253 198.51 145.253L114.819 229.714L31.25 146.104C31.25 146.104 60.1732 96.7642 114.819 96.7642Z" fill="#F6AE5B"/>
<path d="M114.814 97.1684C169.298 97.1684 198.302 145.414 198.302 145.414L114.814 229.672L31.4062 146.265C31.4062 146.265 60.3294 97.1279 114.814 97.1279V97.1684Z" fill="#F6AE5C"/>
<path d="M114.813 97.5713C169.094 97.5713 198.139 145.615 198.139 145.615L114.853 229.67L31.6484 146.465C31.6484 146.465 60.5311 97.5713 114.853 97.5713H114.813Z" fill="#F6AF5D"/>
<path d="M114.823 97.9785C168.942 97.9785 197.946 145.819 197.946 145.819L114.823 229.713L31.8203 146.67C31.8203 146.67 60.7029 97.9785 114.823 97.9785Z" fill="#F6AF5E"/>
<path d="M114.817 98.3823C168.774 98.3823 197.738 146.021 197.738 146.021L114.817 229.711L31.9766 146.871C31.9766 146.871 60.8592 98.3823 114.817 98.3823Z" fill="#F6B05F"/>
<path d="M114.816 98.7896C168.611 98.7896 197.575 146.225 197.575 146.225L114.856 229.714L32.2188 147.076C32.2188 147.076 61.0609 98.8301 114.856 98.8301L114.816 98.7896Z" fill="#F6B060"/>
<path d="M114.81 99.1934C168.403 99.1934 197.367 146.426 197.367 146.426L114.81 229.712L32.375 147.237C32.375 147.237 61.2172 99.1934 114.81 99.1934Z" fill="#F6B161"/>
<path d="M114.82 99.5981C168.251 99.5981 197.174 146.588 197.174 146.588L114.82 229.671L32.5469 147.398C32.5469 147.398 61.389 99.5576 114.82 99.5576V99.5981Z" fill="#F6B162"/>
<path d="M114.811 100.005C168.08 100.005 197.003 146.792 197.003 146.792L114.852 229.714L32.7812 147.643C32.7812 147.643 61.5829 100.045 114.852 100.045L114.811 100.005Z" fill="#F7B263"/>
<path d="M114.823 100.368C167.889 100.368 196.812 146.953 196.812 146.953L114.823 229.671L32.9141 147.763C32.9141 147.763 61.7158 100.368 114.782 100.368H114.823Z" fill="#F7B264"/>
<path d="M114.823 100.771C167.727 100.771 196.61 147.154 196.61 147.154L114.823 229.67L33.1172 147.964C33.1172 147.964 61.9188 100.771 114.823 100.771Z" fill="#F7B365"/>
<path d="M114.814 101.179C167.557 101.179 196.439 147.359 196.439 147.359L114.855 229.673L33.3516 148.169C33.3516 148.169 62.1127 101.179 114.855 101.179H114.814Z" fill="#F7B366"/>
<path d="M114.818 101.583C167.358 101.583 196.24 147.519 196.24 147.519L114.818 229.671L33.4766 148.33C33.4766 148.33 62.2377 101.583 114.777 101.583H114.818Z" fill="#F7B467"/>
<path d="M114.811 101.986C167.188 101.986 196.031 147.72 196.031 147.72L114.811 229.669L33.6719 148.53C33.6719 148.53 62.433 101.986 114.811 101.986Z" fill="#F7B468"/>
<path d="M114.825 102.394C167.041 102.394 195.883 147.925 195.883 147.925L114.866 229.672L33.9297 148.735C33.9297 148.735 62.6504 102.394 114.866 102.394H114.825Z" fill="#F7B569"/>
<path d="M114.813 102.797C166.826 102.797 195.669 148.127 195.669 148.127L114.813 229.67L34.0391 148.896C34.0391 148.896 62.7598 102.797 114.773 102.797H114.813Z" fill="#F7B56A"/>
<path d="M114.814 103.205C166.665 103.205 195.467 148.331 195.467 148.331L114.814 229.673L34.2422 149.101C34.2422 149.101 62.9628 103.205 114.814 103.205Z" fill="#F7B66B"/>
<path d="M114.829 103.608C166.518 103.608 195.319 148.492 195.319 148.492L114.869 229.671L34.5 149.302C34.5 149.302 63.1801 103.608 114.869 103.608H114.829Z" fill="#F7B66C"/>
<path d="M114.816 104.011C166.343 104.011 195.105 148.692 195.105 148.692L114.816 229.669L34.6094 149.462C34.6094 149.462 63.2895 104.011 114.776 104.011H114.816Z" fill="#F7B76D"/>
<path d="M114.817 104.418C166.142 104.418 194.903 148.897 194.903 148.897L114.817 229.671L34.8125 149.667C34.8125 149.667 63.4926 104.418 114.817 104.418Z" fill="#F7B86E"/>
<path d="M114.808 104.822C165.971 104.822 194.732 149.098 194.732 149.098L114.849 229.67L35.0469 149.868C35.0469 149.868 63.6865 104.822 114.849 104.822H114.808Z" fill="#F7B86F"/>
<path d="M114.812 105.23C165.812 105.23 194.533 149.303 194.533 149.303L114.812 229.713L35.1719 150.073C35.1719 150.073 63.8116 105.27 114.771 105.27L114.812 105.23Z" fill="#F7B970"/>
<path d="M114.82 105.634C165.618 105.634 194.339 149.505 194.339 149.505L114.82 229.712L35.3828 150.274C35.3828 150.274 64.0224 105.674 114.82 105.674V105.634Z" fill="#F7B971"/>
<path d="M114.812 106.038C165.447 106.038 194.168 149.666 194.168 149.666L114.852 229.67L35.6172 150.395C35.6172 150.395 64.2163 105.998 114.852 105.998L114.812 106.038Z" fill="#F8BA72"/>
<path d="M114.815 106.444C165.289 106.444 193.969 149.87 193.969 149.87L114.815 229.672L35.7422 150.599C35.7422 150.599 64.3413 106.444 114.775 106.444H114.815Z" fill="#F8BA73"/>
<path d="M114.823 106.848C165.095 106.848 193.775 150.071 193.775 150.071L114.823 229.67L35.9531 150.8C35.9531 150.8 64.5117 106.848 114.823 106.848Z" fill="#F8BB74"/>
<path d="M114.815 107.255C164.924 107.255 193.604 150.276 193.604 150.276L114.855 229.713L36.1875 151.005C36.1875 151.005 64.7462 107.255 114.855 107.255H114.815Z" fill="#F8BB75"/>
<path d="M114.818 107.659C164.765 107.659 193.405 150.477 193.405 150.477L114.818 229.712L36.3125 151.206C36.3125 151.206 64.8712 107.659 114.778 107.659H114.818Z" fill="#F8BC76"/>
<path d="M114.827 108.062C164.571 108.062 193.211 150.637 193.211 150.637L114.827 229.669L36.5234 151.366C36.5234 151.366 65.0415 108.062 114.827 108.062Z" fill="#F8BC77"/>
<path d="M114.818 108.471C164.401 108.471 193.04 150.843 193.04 150.843L114.858 229.673L36.7578 151.532C36.7578 151.532 65.2759 108.431 114.858 108.431L114.818 108.471Z" fill="#F8BC78"/>
<path d="M114.821 108.873C164.242 108.873 192.841 151.043 192.841 151.043L114.821 229.71L36.8828 151.772C36.8828 151.772 65.4009 108.873 114.821 108.873Z" fill="#F8BD79"/>
<path d="M114.814 109.281C164.073 109.281 192.631 151.248 192.631 151.248L114.814 229.713L37.0781 151.977C37.0781 151.977 65.5557 109.321 114.814 109.321V109.281Z" fill="#F8BD7A"/>
<path d="M114.807 109.684C163.863 109.684 192.462 151.449 192.462 151.449L114.847 229.711L37.2734 152.137C37.2734 152.137 65.751 109.684 114.807 109.684Z" fill="#F8BE7B"/>
<path d="M114.809 110.089C163.703 110.089 192.262 151.61 192.262 151.61L114.809 229.67L37.4375 152.299C37.4375 152.299 65.9151 110.048 114.809 110.048V110.089Z" fill="#F8BE7C"/>
<path d="M114.817 110.495C163.549 110.495 192.067 151.814 192.067 151.814L114.817 229.672L37.6484 152.503C37.6484 152.503 66.0855 110.495 114.817 110.495Z" fill="#F8BF7D"/>
<path d="M114.81 110.899C163.34 110.899 191.898 152.015 191.898 152.015L114.851 229.711L37.8438 152.704C37.8438 152.704 66.2808 110.899 114.81 110.899Z" fill="#F8BF7E"/>
<path d="M114.812 111.306C163.18 111.306 191.698 152.22 191.698 152.22L114.812 229.713L38.0078 152.909C38.0078 152.909 66.4449 111.306 114.812 111.306Z" fill="#F8C07F"/>
<path d="M114.821 111.71C163.026 111.71 191.503 152.421 191.503 152.421L114.821 229.712L38.2188 153.11C38.2188 153.11 66.6153 111.75 114.821 111.75V111.71Z" fill="#F8C080"/>
<path d="M114.821 112.074C162.824 112.074 191.342 152.583 191.342 152.583L114.862 229.671L38.4219 153.231C38.4219 153.231 66.8184 112.074 114.821 112.074Z" fill="#F8C181"/>
<path d="M114.815 112.481C162.656 112.481 191.134 152.747 191.134 152.747L114.815 229.673L38.5781 153.436C38.5781 153.436 66.9747 112.481 114.815 112.481Z" fill="#F8C182"/>
<path d="M114.816 112.884C162.495 112.884 190.932 152.947 190.932 152.947L114.816 229.671L38.7812 153.636C38.7812 153.636 67.1373 112.925 114.816 112.925V112.884Z" fill="#F9C283"/>
<path d="M114.824 113.288C162.301 113.288 190.778 153.149 190.778 153.149L114.865 229.669L38.9922 153.797C38.9922 153.797 67.3483 113.288 114.824 113.288Z" fill="#F9C284"/>
<path d="M114.819 113.695C162.133 113.695 190.57 153.353 190.57 153.353L114.819 229.672L39.1484 154.001C39.1484 154.001 67.5045 113.695 114.819 113.695Z" fill="#F9C385"/>
<path d="M114.819 114.099C161.971 114.099 190.368 153.555 190.368 153.555L114.819 229.67L39.3516 154.203C39.3516 154.203 67.6671 114.099 114.819 114.099Z" fill="#F9C386"/>
<path d="M114.828 114.507C161.777 114.507 190.214 153.719 190.214 153.719L114.868 229.673L39.5625 154.367C39.5625 154.367 67.878 114.507 114.828 114.507Z" fill="#F9C487"/>
<path d="M114.822 114.91C161.609 114.91 190.006 153.92 190.006 153.92L114.822 229.671L39.7188 154.568C39.7188 154.568 68.0343 114.91 114.822 114.91Z" fill="#F9C488"/>
<path d="M114.822 115.315C161.448 115.315 189.804 154.122 189.804 154.122L114.822 229.671L39.9219 154.73C39.9219 154.73 68.1969 115.274 114.822 115.274V115.315Z" fill="#F9C589"/>
<path d="M114.807 115.721C161.271 115.721 189.627 154.326 189.627 154.326L114.848 229.672L40.1094 154.934C40.1094 154.934 68.3844 115.721 114.807 115.721Z" fill="#F9C58A"/>
<path d="M114.817 116.125C161.078 116.125 189.434 154.527 189.434 154.527L114.817 229.711L40.2812 155.175C40.2812 155.175 68.5563 116.166 114.817 116.166V116.125Z" fill="#F9C68A"/>
<path d="M114.81 116.529C160.909 116.529 189.224 154.688 189.224 154.688L114.81 229.67L40.4766 155.296C40.4766 155.296 68.7111 116.489 114.81 116.489V116.529Z" fill="#F9C68B"/>
<path d="M114.818 116.936C160.755 116.936 189.071 154.893 189.071 154.893L114.859 229.672L40.6875 155.5C40.6875 155.5 68.922 116.936 114.818 116.936Z" fill="#F9C78C"/>
<path d="M114.812 117.339C160.547 117.339 188.862 155.093 188.862 155.093L114.812 229.669L40.8438 155.701C40.8438 155.701 69.0377 117.339 114.812 117.339Z" fill="#F9C78D"/>
<path d="M114.813 117.747C160.385 117.747 188.66 155.299 188.66 155.299L114.813 229.672L41.0469 155.866C41.0469 155.866 69.2409 117.707 114.813 117.707V117.747Z" fill="#F9C88E"/>
<path d="M114.822 118.15C160.232 118.15 188.507 155.499 188.507 155.499L114.862 229.711L41.2578 156.107C41.2578 156.107 69.4519 118.19 114.822 118.19V118.15Z" fill="#F9C88F"/>
<path d="M114.816 118.558C160.023 118.558 188.298 155.704 188.298 155.704L114.816 229.713L41.4141 156.312C41.4141 156.312 69.5676 118.598 114.816 118.598V118.558Z" fill="#F9C990"/>
<path d="M114.816 118.962C159.862 118.962 188.097 155.865 188.097 155.865L114.816 229.672L41.6172 156.432C41.6172 156.432 69.7707 118.921 114.816 118.921V118.962Z" fill="#F9C991"/>
<path d="M114.825 119.365C159.708 119.365 187.943 156.066 187.943 156.066L114.865 229.67L41.8281 156.633C41.8281 156.633 69.9816 119.365 114.825 119.365Z" fill="#FACA92"/>
<path d="M114.811 119.772C159.492 119.772 187.727 156.27 187.727 156.27L114.811 229.712L41.9766 156.878C41.9766 156.878 70.0895 119.812 114.811 119.812V119.772Z" fill="#FACA93"/>
<path d="M114.805 120.176C159.324 120.176 187.518 156.472 187.518 156.472L114.805 229.711L42.1328 157.039C42.1328 157.039 70.2459 120.176 114.765 120.176H114.805Z" fill="#FACB94"/>
<path d="M114.82 120.583C159.177 120.583 187.371 156.676 187.371 156.676L114.861 229.713L42.3906 157.243C42.3906 157.243 70.5037 120.583 114.861 120.583H114.82Z" fill="#FACB95"/>
<path d="M114.814 120.986C159.009 120.986 187.163 156.836 187.163 156.836L114.814 229.671L42.5469 157.404C42.5469 157.404 70.6194 120.986 114.814 120.986Z" fill="#FACC96"/>
<path d="M114.808 121.391C158.801 121.391 186.954 157.039 186.954 157.039L114.808 229.671L42.7031 157.565C42.7031 157.565 70.7757 121.351 114.768 121.351L114.808 121.391Z" fill="#FACC97"/>
<path d="M114.823 121.797C158.654 121.797 186.807 157.242 186.807 157.242L114.864 229.712L42.9609 157.81C42.9609 157.81 71.0334 121.797 114.864 121.797H114.823Z" fill="#FACD98"/>
<path d="M114.817 122.201C158.486 122.201 186.599 157.443 186.599 157.443L114.817 229.711L43.1172 158.01C43.1172 158.01 71.1491 122.241 114.817 122.241V122.201Z" fill="#FACD99"/>
<path d="M114.812 122.608C158.278 122.608 186.391 157.648 186.391 157.648L114.812 229.713L43.2734 158.175C43.2734 158.175 71.3055 122.608 114.771 122.608H114.812Z" fill="#FACE9A"/>
<path d="M114.811 123.013C158.115 123.013 186.228 157.81 186.228 157.81L114.851 229.672L43.5156 158.336C43.5156 158.336 71.5477 122.972 114.851 122.972L114.811 123.013Z" fill="#FACE9B"/>
<path d="M114.821 123.375C157.962 123.375 186.035 157.97 186.035 157.97L114.821 229.67L43.6875 158.537C43.6875 158.537 71.679 123.416 114.821 123.416V123.375Z" fill="#FACF9C"/>
<path d="M114.806 123.783C157.745 123.783 185.858 158.175 185.858 158.175L114.846 229.673L43.875 158.702C43.875 158.702 71.8664 123.783 114.806 123.783Z" fill="#FACF9D"/>
<path d="M114.814 124.187C157.591 124.187 185.664 158.376 185.664 158.376L114.855 229.672L44.0859 158.903C44.0859 158.903 72.0774 124.187 114.855 124.187H114.814Z" fill="#FAD09E"/>
<path d="M114.824 124.59C157.439 124.59 185.471 158.577 185.471 158.577L114.824 229.67L44.2578 159.063C44.2578 159.063 72.2087 124.59 114.824 124.59Z" fill="#FAD09F"/>
<path d="M114.809 124.998C157.221 124.998 185.294 158.782 185.294 158.782L114.849 229.672L44.4453 159.268C44.4453 159.268 72.3963 124.998 114.809 124.998Z" fill="#FAD1A0"/>
<path d="M114.809 125.402C157.06 125.402 185.092 158.943 185.092 158.943L114.85 229.671L44.6484 159.47C44.6484 159.47 72.5995 125.402 114.85 125.402H114.809Z" fill="#FAD1A1"/>
<path d="M114.804 125.809C156.892 125.809 184.884 159.148 184.884 159.148L114.804 229.673L44.8047 159.634C44.8047 159.634 72.7151 125.809 114.804 125.809Z" fill="#FBD2A2"/>
<path d="M114.812 126.212C156.738 126.212 184.73 159.348 184.73 159.348L114.853 229.671L45.0156 159.834C45.0156 159.834 72.926 126.212 114.812 126.212Z" fill="#FBD2A3"/>
<path d="M114.813 126.615C156.537 126.615 184.528 159.549 184.528 159.549L114.853 229.669L45.2188 160.035C45.2188 160.035 73.0887 126.615 114.853 126.615H114.813Z" fill="#FBD3A4"/>
<path d="M114.807 127.023C156.369 127.023 184.32 159.754 184.32 159.754L114.807 229.712L45.375 160.24C45.375 160.24 73.2449 127.023 114.807 127.023Z" fill="#FBD3A5"/>
<path d="M114.815 127.426C156.215 127.426 184.166 159.914 184.166 159.914L114.856 229.67L45.5859 160.4C45.5859 160.4 73.4559 127.426 114.815 127.426Z" fill="#FBD4A6"/>
<path d="M114.824 127.834C156.021 127.834 183.972 160.119 183.972 160.119L114.864 229.673L45.7969 160.605C45.7969 160.605 73.6264 127.834 114.864 127.834H114.824Z" fill="#FBD4A7"/>
<path d="M114.818 128.239C155.853 128.239 183.764 160.322 183.764 160.322L114.818 229.672L45.9531 160.767C45.9531 160.767 73.7826 128.198 114.818 128.198V128.239Z" fill="#FBD5A8"/>
<path d="M114.818 128.642C155.692 128.642 183.602 160.522 183.602 160.522L114.859 229.67L46.1562 160.968C46.1562 160.968 73.9856 128.642 114.818 128.642Z" fill="#FBD5A9"/>
<path d="M114.827 129.049C155.497 129.049 183.408 160.727 183.408 160.727L114.867 229.713L46.3672 161.213C46.3672 161.213 74.1561 129.089 114.867 129.089L114.827 129.049Z" fill="#FBD6AA"/>
<path d="M114.815 129.453C155.323 129.453 183.193 160.888 183.193 160.888L114.815 229.672L46.4766 161.333C46.4766 161.333 74.2656 129.413 114.774 129.413L114.815 129.453Z" fill="#FBD6AB"/>
<path d="M114.822 129.86C155.168 129.86 183.038 161.092 183.038 161.092L114.862 229.673L46.7266 161.538C46.7266 161.538 74.5155 129.86 114.822 129.86Z" fill="#FBD6AC"/>
<path d="M114.806 130.263C154.951 130.263 182.821 161.293 182.821 161.293L114.847 229.671L46.9141 161.738C46.9141 161.738 74.6624 130.263 114.847 130.263H114.806Z" fill="#FBD7AD"/>
<path d="M114.81 130.666C154.792 130.666 182.622 161.493 182.622 161.493L114.81 229.71L47.0391 161.939C47.0391 161.939 74.7875 130.666 114.77 130.666H114.81Z" fill="#FBD7AE"/>
<path d="M114.825 131.074C154.645 131.074 182.474 161.699 182.474 161.699L114.865 229.713L47.2969 162.144C47.2969 162.144 75.0452 131.115 114.825 131.115V131.074Z" fill="#FBD8AF"/>
<path d="M114.81 131.478C154.468 131.478 182.257 161.9 182.257 161.9L114.85 229.711L47.4844 162.345C47.4844 162.345 75.1923 131.518 114.85 131.518L114.81 131.478Z" fill="#FBD8B0"/>
<path d="M114.813 131.882C154.269 131.882 182.058 162.061 182.058 162.061L114.813 229.67L47.6094 162.466C47.6094 162.466 75.3174 131.841 114.773 131.841L114.813 131.882Z" fill="#FBD9B1"/>
<path d="M114.812 132.289C154.106 132.289 181.895 162.265 181.895 162.265L114.853 229.671L47.8516 162.67C47.8516 162.67 75.5595 132.289 114.812 132.289Z" fill="#FBD9B2"/>
<path d="M114.813 132.692C153.944 132.692 181.693 162.466 181.693 162.466L114.853 229.711L48.0547 162.912C48.0547 162.912 75.7221 132.733 114.853 132.733L114.813 132.692Z" fill="#FBDAB3"/>
<path d="M114.816 133.1C153.745 133.1 181.494 162.671 181.494 162.671L114.816 229.713L48.1797 163.076C48.1797 163.076 75.8471 133.1 114.776 133.1H114.816Z" fill="#FBDAB4"/>
<path d="M114.815 133.503C153.582 133.503 181.331 162.872 181.331 162.872L114.856 229.711L48.4219 163.277C48.4219 163.277 76.0893 133.503 114.856 133.503H114.815Z" fill="#FCDBB5"/>
<path d="M114.816 133.908C153.421 133.908 181.129 163.033 181.129 163.033L114.857 229.67L48.625 163.398C48.625 163.398 76.2519 133.867 114.857 133.867L114.816 133.908Z" fill="#FCDBB6"/>
<path d="M114.827 134.314C153.23 134.314 180.938 163.237 180.938 163.237L114.827 229.712L48.7578 163.642C48.7578 163.642 76.3848 134.314 114.787 134.314H114.827Z" fill="#FCDCB7"/>
<path d="M114.811 134.678C153.051 134.678 180.759 163.398 180.759 163.398L114.851 229.671L48.9844 163.803C48.9844 163.803 76.6113 134.678 114.851 134.678H114.811Z" fill="#FCDCB8"/>
<path d="M114.819 135.085C152.897 135.085 180.565 163.603 180.565 163.603L114.86 229.673L49.1953 163.968C49.1953 163.968 76.7817 135.085 114.86 135.085H114.819Z" fill="#FCDDB9"/>
<path d="M114.831 135.489C152.706 135.489 180.374 163.804 180.374 163.804L114.831 229.671L49.3281 164.169C49.3281 164.169 76.9145 135.489 114.79 135.489H114.831Z" fill="#FCDDBA"/>
<path d="M114.814 135.893C152.528 135.893 180.195 163.966 180.195 163.966L114.855 229.63L49.5547 164.33C49.5547 164.33 77.1006 135.853 114.855 135.853L114.814 135.893Z" fill="#FCDEBB"/>
<path d="M114.815 136.299C152.366 136.299 179.993 164.169 179.993 164.169L114.855 229.672L49.7578 164.534C49.7578 164.534 77.3037 136.299 114.855 136.299H114.815Z" fill="#FCDEBC"/>
<path d="M114.81 136.703C152.2 136.703 179.786 164.371 179.786 164.371L114.81 229.67L49.875 164.735C49.875 164.735 77.421 136.703 114.77 136.703H114.81Z" fill="#FCDFBD"/>
<path d="M114.817 137.111C152.004 137.111 179.631 164.576 179.631 164.576L114.858 229.673L50.125 164.94C50.125 164.94 77.6304 137.111 114.858 137.111H114.817Z" fill="#FCDFBE"/>
<path d="M114.826 137.515C151.851 137.515 179.437 164.777 179.437 164.777L114.866 229.672L50.3359 165.101C50.3359 165.101 77.8413 137.515 114.866 137.515H114.826Z" fill="#FCE0BF"/>
<path d="M114.813 137.918C151.676 137.918 179.222 164.978 179.222 164.978L114.813 229.711L50.4453 165.342C50.4453 165.342 77.9507 137.958 114.773 137.958L114.813 137.918Z" fill="#FCE0BF"/>
<path d="M114.82 138.326C151.481 138.326 179.067 165.142 179.067 165.142L114.861 229.673L50.6953 165.507C50.6953 165.507 78.1602 138.326 114.861 138.326H114.82Z" fill="#FCE1C0"/>
<path d="M114.823 138.729C151.321 138.729 178.867 165.343 178.867 165.343L114.863 229.671L50.8594 165.667C50.8594 165.667 78.3243 138.729 114.823 138.729Z" fill="#FCE1C1"/>
<path d="M114.825 139.137C151.161 139.137 178.666 165.548 178.666 165.548L114.825 229.674L51.0234 165.872C51.0234 165.872 78.4884 139.137 114.784 139.137H114.825Z" fill="#FCE2C2"/>
<path d="M114.816 139.54C150.95 139.54 178.496 165.749 178.496 165.749L114.856 229.671L51.2578 166.073C51.2578 166.073 78.6822 139.54 114.856 139.54H114.816Z" fill="#FCE2C3"/>
<path d="M114.826 139.943C150.797 139.943 178.303 165.949 178.303 165.949L114.866 229.71L51.4297 166.273C51.4297 166.273 78.854 139.943 114.826 139.943Z" fill="#FCE3C4"/>
<path d="M114.82 140.351C150.63 140.351 178.094 166.114 178.094 166.114L114.82 229.672L51.5859 166.438C51.5859 166.438 79.0103 140.351 114.779 140.351H114.82Z" fill="#FCE3C5"/>
<path d="M114.811 140.754C150.418 140.754 177.924 166.315 177.924 166.315L114.852 229.67L51.8203 166.639C51.8203 166.639 79.2042 140.754 114.852 140.754H114.811Z" fill="#FDE4C6"/>
<path d="M114.813 141.159C150.258 141.159 177.723 166.517 177.723 166.517L114.854 229.67L51.9844 166.801C51.9844 166.801 79.3681 141.118 114.813 141.118V141.159Z" fill="#FDE4C7"/>
<path d="M114.815 141.565C150.098 141.565 177.523 166.721 177.523 166.721L114.815 229.712L52.1484 167.045C52.1484 167.045 79.5324 141.606 114.775 141.606L114.815 141.565Z" fill="#FDE5C8"/>
<path d="M114.814 141.969C149.935 141.969 177.36 166.922 177.36 166.922L114.855 229.711L52.3906 167.246C52.3906 167.246 79.734 142.009 114.855 142.009L114.814 141.969Z" fill="#FDE5C9"/>
<path d="M114.816 142.377C149.735 142.377 177.159 167.087 177.159 167.087L114.857 229.673L52.5547 167.371C52.5547 167.371 79.898 142.336 114.816 142.336V142.377Z" fill="#FDE6CA"/>
<path d="M114.818 142.78C149.575 142.78 176.959 167.288 176.959 167.288L114.818 229.671L52.7188 167.571C52.7188 167.571 80.0621 142.78 114.778 142.78H114.818Z" fill="#FDE6CB"/>
<path d="M114.818 143.184C149.412 143.184 176.796 167.489 176.796 167.489L114.858 229.67L52.9609 167.732C52.9609 167.732 80.2638 143.144 114.858 143.144L114.818 143.184Z" fill="#FDE6CC"/>
<path d="M114.829 143.591C149.221 143.591 176.605 167.693 176.605 167.693L114.829 229.712L53.0938 167.977C53.0938 167.977 80.3966 143.591 114.788 143.591H114.829Z" fill="#FDE7CD"/>
<path d="M114.814 143.994C149.044 143.994 176.387 167.894 176.387 167.894L114.814 229.71L53.2812 168.177C53.2812 168.177 80.5842 144.034 114.773 144.034L114.814 143.994Z" fill="#FDE7CE"/>
<path d="M114.821 144.401C148.889 144.401 176.232 168.099 176.232 168.099L114.861 229.713L53.5312 168.342C53.5312 168.342 80.7936 144.401 114.861 144.401H114.821Z" fill="#FDE8CF"/>
<path d="M114.809 144.806C148.674 144.806 176.017 168.26 176.017 168.26L114.809 229.671L53.6406 168.503C53.6406 168.503 80.9031 144.765 114.768 144.765L114.809 144.806Z" fill="#FDE8D0"/>
<path d="M114.809 145.212C148.512 145.212 175.815 168.464 175.815 168.464L114.809 229.713L53.8438 168.748C53.8438 168.748 81.0655 145.253 114.809 145.253V145.212Z" fill="#FDE9D1"/>
<path d="M114.824 145.616C148.365 145.616 175.668 168.666 175.668 168.666L114.865 229.712L54.1016 168.909C54.1016 168.909 81.3234 145.616 114.865 145.616H114.824Z" fill="#FDE9D2"/>
<path d="M114.812 146.02C148.15 146.02 175.453 168.866 175.453 168.866L114.812 229.71L54.2109 169.109C54.2109 169.109 81.4328 146.02 114.771 146.02H114.812Z" fill="#FDEAD3"/>
<path d="M114.812 146.388C147.989 146.388 175.251 169.032 175.251 169.032L114.812 229.673L54.4141 169.275C54.4141 169.275 81.5954 146.388 114.812 146.388Z" fill="#FDEAD4"/>
<path d="M114.812 146.792C147.826 146.792 175.089 169.193 175.089 169.193L114.852 229.632L54.6562 169.396C54.6562 169.396 81.8376 146.751 114.852 146.751L114.812 146.792Z" fill="#FDEBD5"/>
<path d="M114.815 147.194C147.668 147.194 174.89 169.393 174.89 169.393L114.815 229.67L54.7812 169.636C54.7812 169.636 81.9627 147.194 114.775 147.194H114.815Z" fill="#FDEBD6"/>
<path d="M114.777 147.602C147.427 147.602 174.648 169.598 174.648 169.598L114.777 229.673L54.9453 169.841C54.9453 169.841 82.0862 147.602 114.777 147.602Z" fill="#FDECD7"/>
<path d="M114.784 148.005C147.272 148.005 174.493 169.799 174.493 169.799L114.824 229.671L55.1953 170.002C55.1953 170.002 82.3361 148.005 114.824 148.005H114.784Z" fill="#FDECD8"/>
<path d="M114.771 148.413C147.097 148.413 174.279 170.004 174.279 170.004L114.771 229.673L55.3047 170.206C55.3047 170.206 82.4455 148.413 114.731 148.413H114.771Z" fill="#FEEDD9"/>
<path d="M114.772 148.816C146.895 148.816 174.077 170.204 174.077 170.204L114.772 229.712L55.5078 170.448C55.5078 170.448 82.6081 148.856 114.772 148.856V148.816Z" fill="#FEEDDA"/>
<path d="M114.773 149.22C146.734 149.22 173.915 170.365 173.915 170.365L114.813 229.67L55.7109 170.568C55.7109 170.568 82.8113 149.22 114.773 149.22Z" fill="#FEEEDB"/>
<path d="M114.775 149.627C146.574 149.627 173.715 170.57 173.715 170.57L114.775 229.672L55.875 170.772C55.875 170.772 82.9753 149.627 114.734 149.627H114.775Z" fill="#FEEEDC"/>
<path d="M114.775 150.031C146.372 150.031 173.513 170.771 173.513 170.771L114.775 229.671L56.0781 170.974C56.0781 170.974 83.138 150.031 114.775 150.031Z" fill="#FEEFDC"/>
<path d="M114.784 150.438C146.218 150.438 173.359 170.976 173.359 170.976L114.824 229.673L56.2891 171.138C56.2891 171.138 83.3489 150.438 114.784 150.438Z" fill="#FEEFDD"/>
<path d="M114.77 150.842C146.043 150.842 173.143 171.178 173.143 171.178L114.77 229.713L56.4375 171.38C56.4375 171.38 83.4973 150.883 114.73 150.883L114.77 150.842Z" fill="#FEEFDE"/>
<path d="M114.771 151.246C145.841 151.246 172.941 171.338 172.941 171.338L114.771 229.67L56.6406 171.54C56.6406 171.54 83.6599 151.246 114.771 151.246Z" fill="#FEF0DF"/>
<path d="M114.779 151.653C145.687 151.653 172.787 171.543 172.787 171.543L114.819 229.672L56.8516 171.705C56.8516 171.705 83.8709 151.653 114.779 151.653Z" fill="#FEF0E0"/>
<path d="M114.781 152.057C145.527 152.057 172.587 171.744 172.587 171.744L114.781 229.671L57.0156 171.906C57.0156 171.906 84.0349 152.057 114.74 152.057H114.781Z" fill="#FEF1E1"/>
<path d="M114.774 152.464C145.317 152.464 172.377 171.949 172.377 171.949L114.774 229.714L57.2109 172.111C57.2109 172.111 84.1898 152.464 114.774 152.464Z" fill="#FEF1E2"/>
<path d="M114.782 152.867C145.164 152.867 172.223 172.149 172.223 172.149L114.823 229.712L57.4219 172.311C57.4219 172.311 84.4007 152.867 114.782 152.867Z" fill="#FEF2E3"/>
<path d="M114.776 153.271C144.996 153.271 172.015 172.31 172.015 172.31L114.776 229.67L57.5781 172.472C57.5781 172.472 84.557 153.271 114.736 153.271H114.776Z" fill="#FEF2E4"/>
<path d="M114.785 153.675C144.842 153.675 171.821 172.512 171.821 172.512L114.785 229.67L57.7891 172.633C57.7891 172.633 84.7273 153.635 114.785 153.635V153.675Z" fill="#FEF3E5"/>
<path d="M114.77 154.082C144.625 154.082 171.644 172.716 171.644 172.716L114.81 229.672L57.9766 172.838C57.9766 172.838 84.9149 154.042 114.77 154.042V154.082Z" fill="#FEF3E6"/>
<path d="M114.78 154.489C144.472 154.489 171.451 172.921 171.451 172.921L114.78 229.714L58.1484 173.083C58.1484 173.083 85.0867 154.53 114.739 154.53L114.78 154.489Z" fill="#FEF4E7"/>
<path d="M114.772 154.893C144.303 154.893 171.241 173.121 171.241 173.121L114.772 229.712L58.3438 173.243C58.3438 173.243 85.2416 154.893 114.772 154.893Z" fill="#FEF4E8"/>
<path d="M114.773 155.296C144.101 155.296 171.08 173.323 171.08 173.323L114.813 229.711L58.5469 173.444C58.5469 173.444 85.4446 155.296 114.773 155.296Z" fill="#FEF5E9"/>
<path d="M114.775 155.704C143.941 155.704 170.879 173.487 170.879 173.487L114.775 229.672L58.7109 173.608C58.7109 173.608 85.5682 155.704 114.734 155.704H114.775Z" fill="#FEF5EA"/>
<path d="M114.768 156.107C143.772 156.107 170.67 173.688 170.67 173.688L114.768 229.712L58.9062 173.81C58.9062 173.81 85.7635 156.107 114.768 156.107Z" fill="#FEF6EB"/>
<path d="M114.776 156.515C143.578 156.515 170.516 173.893 170.516 173.893L114.817 229.714L59.1172 174.014C59.1172 174.014 85.9745 156.515 114.776 156.515Z" fill="#FFF6EC"/>
<path d="M114.77 156.918C143.41 156.918 170.308 174.094 170.308 174.094L114.77 229.712L59.2734 174.215C59.2734 174.215 86.0902 156.958 114.77 156.958V156.918Z" fill="#FFF7ED"/>
<path d="M114.771 157.321C143.249 157.321 170.106 174.294 170.106 174.294L114.771 229.71L59.4766 174.375C59.4766 174.375 86.2934 157.321 114.771 157.321Z" fill="#FFF7EE"/>
<path d="M114.779 157.686C143.054 157.686 169.952 174.417 169.952 174.417L114.82 229.63L59.6875 174.498C59.6875 174.498 86.5043 157.646 114.779 157.646V157.686Z" fill="#FFF7EF"/>
<path d="M114.774 158.093C142.887 158.093 169.744 174.62 169.744 174.62L114.774 229.672L59.8438 174.742C59.8438 174.742 86.6201 158.133 114.774 158.133V158.093Z" fill="#FFF8F0"/>
<path d="M114.782 158.497C142.733 158.497 169.59 174.822 169.59 174.822L114.822 229.67L60.0547 174.903C60.0547 174.903 86.8309 158.497 114.782 158.497Z" fill="#FFF8F0"/>
<path d="M114.775 158.904C142.564 158.904 169.38 175.026 169.38 175.026L114.815 229.672L60.25 175.107C60.25 175.107 87.0263 158.904 114.775 158.904Z" fill="#FFF9F1"/>
<path d="M114.777 159.308C142.363 159.308 169.18 175.228 169.18 175.228L114.777 229.671L60.4141 175.309C60.4141 175.309 87.1498 159.308 114.777 159.308Z" fill="#FFF9F2"/>
<path d="M114.785 159.711C142.21 159.711 169.026 175.388 169.026 175.388L114.826 229.669L60.625 175.469C60.625 175.469 87.3608 159.711 114.785 159.711Z" fill="#FFFAF3"/>
<path d="M114.778 160.119C142.04 160.119 168.816 175.593 168.816 175.593L114.818 229.672L60.8203 175.674C60.8203 175.674 87.5561 160.119 114.778 160.119Z" fill="#FFFAF4"/>
<path d="M114.78 160.522C141.84 160.522 168.616 175.794 168.616 175.794L114.78 229.671L60.9844 175.875C60.9844 175.875 87.6797 160.522 114.78 160.522Z" fill="#FFFBF5"/>
<path d="M114.788 160.93C141.686 160.93 168.462 175.999 168.462 175.999L114.829 229.673L61.1953 176.039C61.1953 176.039 87.8906 160.93 114.788 160.93Z" fill="#FFFBF6"/>
<path d="M114.773 161.333C141.509 161.333 168.245 176.199 168.245 176.199L114.814 229.671L61.3828 176.24C61.3828 176.24 88.0781 161.333 114.773 161.333Z" fill="#FFFCF7"/>
<path d="M114.783 161.736C141.316 161.736 168.052 176.4 168.052 176.4L114.783 229.71L61.5547 176.441C61.5547 176.441 88.2094 161.736 114.783 161.736Z" fill="#FFFCF8"/>
<path d="M114.768 162.144C141.139 162.144 167.875 176.565 167.875 176.565L114.809 229.671L61.7422 176.605C61.7422 176.605 88.397 162.144 114.768 162.144Z" fill="#FFFCF9"/>
<path d="M114.769 162.547C140.978 162.547 167.673 176.766 167.673 176.766L114.809 229.67L61.9453 176.806C61.9453 176.806 88.6 162.547 114.769 162.547Z" fill="#FFFDFA"/>
<path d="M114.771 162.952C140.777 162.952 167.472 176.968 167.472 176.968L114.771 229.67L62.1094 176.968C62.1094 176.968 88.7236 162.912 114.771 162.912V162.952Z" fill="#FFFDFB"/>
<path d="M114.779 163.358C140.624 163.358 167.319 177.172 167.319 177.172L114.819 229.712L62.3203 177.212C62.3203 177.212 88.9345 163.399 114.779 163.399V163.358Z" fill="#FFFEFC"/>
<path d="M114.772 163.762C140.454 163.762 167.109 177.373 167.109 177.373L114.812 229.71L62.5156 177.413C62.5156 177.413 89.1299 163.802 114.772 163.802V163.762Z" fill="#FFFEFD"/>
<path d="M114.774 164.17C140.294 164.17 166.909 177.538 166.909 177.538L114.774 229.673L62.6797 177.538C62.6797 177.538 89.2534 164.13 114.774 164.13V164.17Z" fill="#FFFEFD"/>
<path d="M114.782 164.573C140.1 164.573 166.755 177.739 166.755 177.739L114.823 229.671L62.8906 177.739C62.8906 177.739 89.4644 164.573 114.782 164.573Z" fill="#FFFFFE"/>
<path d="M114.775 164.98C139.931 164.98 166.545 177.943 166.545 177.943L114.815 229.673L63.0859 177.943C63.0859 177.943 89.6191 164.98 114.775 164.98Z" fill="white"/>
<path d="M166.391 178.145C166.391 178.145 139.777 165.384 114.824 165.384C89.8706 165.384 63.2969 178.145 63.2969 178.145L114.824 229.712L166.391 178.145Z" fill="white"/>
<path d="M78.9609 193.863L114.811 229.714L229.653 114.912C170.146 132.29 120.28 156.069 78.9609 193.904V193.863Z" fill="#6D6C6C"/>
</svg>

After

Width:  |  Height:  |  Size: 44 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="341" height="207" viewBox="0 0 341 207" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M138.702 4.06117C128.656 7.66645 119.136 17.5911 110.386 29.5006C105.89 35.6174 31.7993 167.595 26.8978 174.198C8.34479 199.434 0 194.735 0 200.69C0 207.577 30.8676 208.873 48.4889 202.594C58.535 199.029 68.0546 189.064 76.8044 177.155C81.2604 171.078 155.391 39.1012 160.252 32.4577C178.846 7.22085 187.15 11.9199 187.15 5.96508C187.15 -0.880881 156.282 -2.25818 138.661 4.06117" fill="#CB2E28"/>
<path d="M291.989 4.06117C281.984 7.66645 272.424 17.5911 263.674 29.5006C259.218 35.6174 185.087 167.595 180.226 174.198C161.673 199.434 153.328 194.735 153.328 200.69C153.328 207.577 184.196 208.873 201.817 202.594C211.823 199.029 221.383 189.064 230.133 177.155C234.588 171.078 308.719 39.1012 313.58 32.4577C332.133 7.22085 340.478 11.9199 340.478 5.96508C340.478 -0.880881 309.611 -2.25818 291.989 4.06117Z" fill="#1D4289"/>
<path d="M162.923 115.864C154.902 116.674 146.152 110.598 142.263 107.6C142.223 107.56 142.182 107.519 142.142 107.479C123.589 140.047 105.886 171.077 103.577 174.196C85.0245 199.433 76.6797 194.694 76.6797 200.689C76.6797 207.575 107.507 208.912 125.169 202.593C135.215 199.028 144.694 189.063 153.444 177.153C155.51 174.399 172.078 145.273 189.982 113.879C187.916 112.866 186.215 112.218 183.217 112.218C174.346 112.218 168.229 115.864 162.923 115.864Z" fill="#1D4289"/>
<path d="M215.348 4.06117C205.302 7.66645 195.783 17.5911 187.033 29.5006C185.088 32.1337 170.06 58.4643 153.289 87.9546C155.639 89.98 159.122 92.9372 163.578 92.9372C169.33 92.9372 174.111 90.1826 175.69 89.656C178.688 88.6432 187.6 86.5773 194.365 89.4939C196.755 90.5472 199.671 91.965 201.818 93.1397C219.035 62.8797 234.752 35.4149 236.899 32.4577C255.452 7.22085 263.797 11.9199 263.797 5.96508C263.797 -0.880881 232.97 -2.25818 215.308 4.06117" fill="#CB2E28"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="308" height="184" viewBox="0 0 308 184" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M114.762 47.3135H94.1431C91.1859 47.3135 89.6871 47.597 88.5528 48.3667C87.054 49.4199 86.487 51.1618 86.0819 53.4708L72.7138 128.979C72.3492 131.085 72.1063 133.394 73.8482 135.055C75.104 136.23 76.8051 136.433 79.6812 136.433H96.2896C124.727 136.433 140.688 127.561 143.807 110.061C144.9 103.945 143.482 98.719 139.594 94.5871C138.014 92.8857 136.029 91.3869 133.68 90.0906C141.295 86.3638 145.913 80.247 147.412 71.7807C148.505 65.5424 147.371 60.2762 143.968 56.2254C138.945 50.2301 129.385 47.3135 114.762 47.3135ZM126.226 109.211C124.727 117.718 115.167 122.052 97.7884 122.052H91.6315L96.0063 97.4632H101.839C114.275 97.4632 120.068 99.3266 122.742 100.906C125.902 102.77 126.955 105.241 126.226 109.211ZM129.871 70.8085C129.183 74.5353 127.117 77.3709 123.431 79.4774C119.38 81.7863 113.466 83.0016 105.81 83.0016H98.8012L102.609 61.613H113.506C123.552 61.613 127.4 63.7195 128.899 65.5019C129.304 65.9475 130.479 67.3653 129.871 70.849" fill="#0C367F"/>
<path d="M229.317 54.849C226.765 52.4185 223.403 50.5146 219.231 49.1778C215.261 47.9221 210.44 47.2739 204.89 47.2739H178.924C177.831 47.2739 176.939 47.2739 176.332 47.3549C175.116 47.436 174.064 47.76 173.172 48.3677C172.16 49.0563 171.39 50.1095 171.066 51.4058C170.904 51.9324 170.742 52.6616 170.58 53.6338L156.969 130.479C156.685 132.018 157.05 133.476 157.982 134.611C158.67 135.421 160.007 136.433 162.275 136.433H168.473C171.552 136.433 173.82 134.611 174.306 131.815L179.694 101.434H188.241C191.037 101.353 193.345 101.272 195.128 101.231C196.262 101.15 197.437 101.069 198.693 100.948C199.26 102.204 199.948 103.621 200.677 105.282C201.771 107.875 203.311 111.399 205.295 115.774C207.28 120.149 209.671 125.78 212.587 132.707C213.64 135.097 215.868 136.474 218.745 136.474H225.874C227.616 136.474 229.114 135.745 230.046 134.449C231.018 133.152 231.262 131.532 230.776 130.033L214.977 96.6135C216.354 95.9653 217.691 95.2767 219.028 94.507C221.621 93.0487 224.011 91.3473 226.158 89.4839C228.345 87.58 230.208 85.4736 231.707 83.2051C233.287 80.7746 234.34 78.182 234.826 75.5085C235.556 71.4171 235.474 67.6093 234.664 64.166C233.773 60.5608 231.991 57.4011 229.317 54.849ZM217.205 74.2122C216.881 75.9946 216.152 77.5744 214.896 79.0327C213.559 80.6125 211.817 81.9898 209.711 83.2051C207.483 84.4204 204.891 85.3926 201.933 86.0407C198.895 86.7294 195.654 87.0534 192.171 87.0534H182.165L186.662 61.5735H197.68C202.379 61.5735 206.268 61.857 209.225 62.3837C211.817 62.8698 213.843 63.6394 215.18 64.6116C216.274 65.4623 216.922 66.4345 217.246 67.7713C217.61 69.4322 217.57 71.6196 217.124 74.2122" fill="#0C367F"/>
<path d="M153.69 18.9581C226.727 18.9581 288.421 52.3372 288.421 91.8736C288.421 131.41 226.727 164.789 153.69 164.789C80.6525 164.789 18.9578 131.41 18.9578 91.8736C18.9578 52.3372 80.6525 18.9581 153.69 18.9581ZM153.69 0C68.824 0 0 41.1163 0 91.8736C0 142.631 68.824 183.707 153.69 183.707C238.555 183.707 307.339 142.59 307.339 91.8736C307.339 41.1568 238.555 0 153.69 0Z" fill="#0C367F"/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg width="414" height="207" viewBox="0 0 414 207" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M267.475 64.732L302.069 206.715H310.374L267.515 30.9072L224.414 206.715H232.678L267.475 64.732Z" fill="#92BED4"/>
<path d="M173.127 33.8247L215.296 206.716H223.56L173.167 0L122.531 206.716H130.795L173.127 33.8247Z" fill="#92BED4"/>
<path d="M172.407 79.6011L149.641 206.717H193.795L172.407 79.6011Z" fill="#92BED4"/>
<path d="M267.235 106.093L248.398 206.717H285.018L267.235 106.093Z" fill="#92BED4"/>
<path d="M413.836 52.7837C413.836 52.7837 183.423 82.9221 0 206.676H104.715C103.783 203.678 217.491 89.363 413.836 52.7837Z" fill="#4580A6"/>
<path d="M196.948 97.5732L189.156 99.4756L198.61 138.199L206.402 136.296L196.948 97.5732Z" fill="#92BED4"/>
<path d="M278.214 74.8067L270.422 76.709L276.09 99.9271L283.882 98.0248L278.214 74.8067Z" fill="#92BED4"/>
</svg>

After

Width:  |  Height:  |  Size: 875 B

+6
View File
@@ -0,0 +1,6 @@
<svg width="483" height="127" viewBox="0 0 483 127" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 4.416H26.0065L26.047 53.9581L70.8496 53.9176L70.8091 4.416L96.8561 4.37549L96.8966 126.023H70.8496L70.8091 74.2124L26.047 74.2934L26.0065 126.023H0V4.416Z" fill="#231815"/>
<path d="M127.849 126.022H100.992L147.051 9.23597C147.051 9.23597 149.927 0 161.674 0C173.422 0 176.622 9.19546 176.622 9.19546L222.275 126.103H195.458L161.674 35.8097L127.931 126.063L127.849 126.022Z" fill="#231815"/>
<path d="M139.955 126.308L156.361 84.4621C156.361 84.4621 158.71 77.4541 161.748 77.4136C164.867 77.4136 166.974 84.4621 166.974 84.4621L183.339 126.267H139.914L139.955 126.308Z" fill="#0068B7"/>
<path d="M249.624 4.37493V105.687H297.464V4.41544H323.43V105.687H369.408L407.324 9.15496C407.324 9.15496 410.2 0 421.947 0C433.695 0 436.652 9.23597 436.652 9.23597L482.549 126.022H455.731L441.432 89.6457H402.584L388.285 126.022H249.624C249.624 126.022 236.985 126.59 229.491 120.594C223.415 115.693 223.658 105.606 223.658 105.606V4.41544H249.624V4.37493ZM433.452 69.3103L421.988 35.8502L410.605 69.3508H433.452V69.3103Z" fill="#231815"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="230" height="230" viewBox="0 0 230 230" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M40.5087 114.842L114.801 40.5087L185.732 111.439L205.986 91.185L114.801 0L106.335 8.46631L94.5471 20.2543L20.2543 94.5472L9.68138 105.12L0 114.842L114.801 229.644L135.056 209.389L40.5087 114.842Z" fill="#0066B5"/>
<path d="M209.397 94.5466L185.74 118.204L114.809 47.2729L94.5547 67.5273L165.485 138.458L141.828 162.115L162.083 182.369L229.651 114.841L209.397 94.5466Z" fill="#84338E"/>
<path d="M135.063 162.113L138.628 158.549L145.474 151.703C152.766 144.411 152.766 132.542 145.474 125.25L104.398 84.1747C97.1068 76.8832 85.2379 76.8832 77.9463 84.1747L70.8979 91.2232L67.5356 94.5854L66.6443 95.4766L47.2812 114.84L138.466 206.025L158.721 185.77L135.104 162.154L135.063 162.113ZM91.1925 111.437L118.212 138.456L114.85 141.819L87.8305 114.799L91.1925 111.437Z" fill="#F7AF00"/>
</svg>

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+14
View File
@@ -0,0 +1,14 @@
<?php
$host = getenv('EDU_DB_HOST') ?: 'edu_db';
$db = getenv('EDU_DB_NAME') ?: 'edu';
$user = getenv('EDU_DB_USER') ?: 'edu1234';
$pass = getenv('EDU_DB_PASS') ?: 'edu1234';
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$pdo = new PDO($dsn, $user, $pass, $options);
File diff suppressed because it is too large Load Diff
+490
View File
@@ -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();
});
+143
View File
@@ -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('데이터 통신 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.');
});
});
+1
View File
@@ -0,0 +1 @@
// Deleted scratch file
+25
View File
@@ -0,0 +1,25 @@
<?php
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$memberId = (string)($_SESSION['member_id'] ?? '');
$authLevel = strtoupper(trim((string)($_SESSION['auth_level'] ?? '')));
if ($memberId === '') {
header('Location: /bbs/login.php');
exit;
}
if (!in_array($authLevel, ['LE10001', 'LE10002'], true)) {
http_response_code(403);
exit('접근 권한이 없습니다.');
}
$currentPage = basename((string)($_SERVER['PHP_SELF'] ?? ''));
$legalOnlyPage = 'legal_edu.php';
if ($authLevel === 'LE10002' && $currentPage !== $legalOnlyPage) {
header('Location: /admin/skin/' . $legalOnlyPage);
exit;
}
+767
View File
@@ -0,0 +1,767 @@
<?php
require_once __DIR__ . '/../../bbs/auth.php';
edu_require_login();
// 공통 상단 영역(헤더)과 데이터베이스 연결 파일을 포함합니다.
include_once 'header.php';
require_once __DIR__ . '/../../bbs/db_conn.php';
// PDO 연결 생성
$pdo = db_conn();
// 대분류 카테고리(CA100) 목록을 프로시저를 통해 DB에서 조회합니다.
$catsStmt = $pdo->prepare("CALL proc_get_code_list('CA100')");
$catsStmt->execute();
$categories = $catsStmt->fetchAll();
$catsStmt->closeCursor();
// 분기(quarter) 목록을 edu_codes 에서 desc01 기준으로 조회합니다.
// desc01 = 'CA10001' 인 코드들을 분기 코드로 사용합니다.
$qtStmt = $pdo->prepare("SELECT base_code AS code, code_name AS name FROM edu_codes WHERE desc01 = 'CA10001'");
$qtStmt->execute();
$quarters = $qtStmt->fetchAll(PDO::FETCH_ASSOC);
$qtStmt->closeCursor();
// 키워드(KW100) 목록을 프로시저를 통해 DB에서 조회합니다.
$keywordsStmt = $pdo->prepare("CALL proc_get_code_list('KW100')");
$keywordsStmt->execute();
$keywords = $keywordsStmt->fetchAll(PDO::FETCH_ASSOC);
$keywordsStmt->closeCursor();
$goalNosStmt = $pdo->prepare("CALL proc_get_code_list('GN100')");
$goalNosStmt->execute();
$goalNos = $goalNosStmt->fetchAll(PDO::FETCH_ASSOC);
$goalNosStmt->closeCursor();
// 인사이트 이슈구분(IS100) 목록을 사전 준비합니다.
$is100Stmt = $pdo->prepare("CALL proc_get_code_list('IS100')");
$is100Stmt->execute();
$issueTypesIS = $is100Stmt->fetchAll(PDO::FETCH_ASSOC);
$is100Stmt->closeCursor();
// 리더십 이슈구분(LD100) 목록을 사전 준비합니다.
$ld100Stmt = $pdo->prepare("CALL proc_get_code_list('LD100')");
$ld100Stmt->execute();
$issueTypesLD = $ld100Stmt->fetchAll(PDO::FETCH_ASSOC);
$ld100Stmt->closeCursor();
// 기본 선택될 카테고리 코드를 결정합니다. '마이클래스'를 찾으면 우선 적용합니다.
$defaultCatCode = null;
foreach ($categories as $c) {
if ($c['name'] === '마이클래스') {
$defaultCatCode = $c['code'];
break;
}
}
// '마이클래스'가 없으면 첫 번째 카테고리를 기본값으로 사용합니다.
if ($defaultCatCode === null && count($categories) > 0) {
$defaultCatCode = $categories[0]['code'];
}
// GET 파라미터로 넘겨받은 검색 조건(카테고리, 검색어)을 변수에 저장합니다.
$category = isset($_GET['category']) ? $_GET['category'] : 'CA10006';
$q = isset($_GET['q']) ? trim($_GET['q']) : '';
// 쿼리 바인딩을 위한 파라미터 배열과 WHERE 조건 배열을 초기화합니다.
$params = [];
$where = [];
// 카테고리 검색 조건이 '전체'가 아닐 경우 조건절에 추가합니다.
if ($category !== '' && $category !== '전체') {
$where[] = 'category_code = :category';
$params[':category'] = $category;
}
// 텍스트 검색어가 있을 경우 제목(title) 부분 일치 조건(LIKE)을 추가합니다.
if ($q !== '') {
$where[] = 'title LIKE :q';
$params[':q'] = '%' . $q . '%';
}
// 메인 콘텐츠 리스트를 조회하기 위한 기본 SQL 쿼리입니다.
// 서브쿼리를 사용해 카테고리명, 중분류명, 연결된 학습목표명을 함께 가져옵니다.
$sql = 'SELECT c.content_id, c.category_code, c.category_group, c.title, c.is_offer, c.is_active, c.sort_order, c.description,c.description1, c.description2, c.description3, c.content_url, c.goal_code, c.issue_type_code, c.offer_id, c.start_date,
(SELECT COUNT(*) FROM edu_content_keywords ck WHERE ck.content_id=c.content_id AND ck.is_active=\'1\') as keyword_count,
(SELECT code_name FROM edu_codes WHERE group_code=\'CA100\' AND base_code=c.category_code LIMIT 1) as cat_name,
(SELECT code_name FROM edu_codes WHERE group_code=\'CA200\' AND base_code=c.category_group LIMIT 1) as group_name,
(SELECT title FROM edu_learning_goals WHERE goal_code=c.goal_code LIMIT 1) as goal_title,
(SELECT code_name FROM edu_codes WHERE group_code=\'IS100\' AND base_code=c.issue_type_code LIMIT 1) as issue_type_name
FROM edu_contents c';
// WHERE 조건이 하나라도 있다면 조합하여 SQL문에 덧붙입니다.
if ($where) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
// 정렬 순서를 지정합니다.
$sql .= ' ORDER BY c.category_code , c.category_group ,c.goal_code, c.content_id DESC';
// 완성된 쿼리를 실행하여 결과($rows)를 가져옵니다.
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
?>
<main class="max-w-[1600px] mx-auto p-6">
<header class="flex justify-between items-center mb-8">
<h2 class="text-3xl font-bold text-gray-800 tracking-tight">콘텐츠 입력</h2>
<div class="flex items-center gap-2">
<button id="btn-goal" onclick="openGoalModal()"
class="px-4 py-2 bg-blue-700 text-white rounded-lg font-bold flex items-center shadow hover:bg-blue-800 transition">
<i class="fa-solid fa-bullseye mr-2"></i>학습목표 등록
</button>
<button onclick="openNewModal()"
class="px-5 py-2.5 bg-[#114b3d] text-white rounded-lg font-bold flex items-center shadow-lg hover:bg-[#0d3a2f] transition">
<i class="fa-solid fa-plus mr-2"></i>새 콘텐츠 추가
</button>
</div>
</header>
<section class="bg-white p-6 rounded-2xl border border-gray-200 shadow-sm mb-8">
<form class="grid grid-cols-1 md:grid-cols-3 gap-6" method="get">
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">카테고리</label>
<select name="category" onchange="this.form.submit()"
class="w-full border-gray-200 rounded-xl p-3 bg-gray-50 focus:ring-2 focus:ring-teal-500">
<option <?= $category === '전체' ? 'selected' : ''; ?>>전체</option>
<?php foreach ($categories as $c): ?>
<option value="<?= htmlspecialchars($c['code'], ENT_QUOTES, 'UTF-8'); ?>" <?= $category === $c['code'] ? 'selected' : ''; ?>><?= htmlspecialchars($c['name'], ENT_QUOTES, 'UTF-8'); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">콘텐츠명 검색</label>
<div class="relative">
<input name="q" value="<?= htmlspecialchars($q, ENT_QUOTES, 'UTF-8'); ?>" type="text"
placeholder="콘텐츠명을 검색하세요"
class="w-full border-gray-200 rounded-xl p-3 pl-11 bg-gray-50 focus:ring-2 focus:ring-teal-500">
<i class="fa-solid fa-magnifying-glass absolute left-4 top-4 text-gray-400"></i>
</div>
</div>
<div class="flex items-end">
<button class="w-full md:w-auto px-5 py-3 bg-gray-800 text-white rounded-xl font-bold">검색</button>
</div>
</form>
</section>
<section class="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden mb-12">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-gray-50/50 border-b border-gray-100">
<th class="p-4 text-sm font-bold text-gray-500 w-16">No</th>
<th class="p-4 text-sm font-bold text-gray-500 w-32">콘텐츠ID</th>
<th class="p-4 text-sm font-bold text-gray-500 w-44">카테고리</th>
<th class="p-4 text-sm font-bold text-gray-500 w-44 whitespace-nowrap">카테고리구분</th>
<th class="p-4 text-sm font-bold text-gray-500">콘텐츠명</th>
<th class="p-4 text-sm font-bold text-gray-500 ">콘텐츠설명</th>
<th class="p-4 text-sm font-bold text-gray-500 hidden">콘텐츠설명1</th>
<th class="p-4 text-sm font-bold text-gray-500 hidden">콘텐츠설명2</th>
<th class="p-4 text-sm font-bold text-gray-500 hidden">콘텐츠설명3</th>
<th class="p-4 text-sm font-bold text-gray-500">URL</th>
<th class="p-4 text-sm font-bold text-gray-500 w-28 text-center">키워드</th>
<th class="p-4 text-sm font-bold text-gray-500">사용여부</th>
<th class="p-4 text-sm font-bold text-gray-500 text-center w-24">관리</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50 text-sm">
<?php if (!$rows): ?>
<tr>
<td colspan="8" class="p-6 text-center text-gray-400">등록된 콘텐츠가 없습니다</td>
</tr>
<?php else: ?>
<?php $idx = 0;
foreach ($rows as $row):
$idx++; ?>
<tr class="hover:bg-teal-50/30 transition">
<td class="p-4 text-center text-gray-400"><?= $idx; ?></td>
<td class="p-4 font-mono text-xs text-gray-500">
<?= htmlspecialchars($row['content_id'], ENT_QUOTES, 'UTF-8'); ?>
</td>
<td class="p-4">
<span
class="text-gray-600 bg-transparent border-none rounded-none text-sm p-0"><?= htmlspecialchars($row['cat_name'] ?: $row['category_code'], ENT_QUOTES, 'UTF-8'); ?></span>
</td>
<td class="p-4 text-gray-600 text-sm whitespace-nowrap">
<?= htmlspecialchars($row['group_name'] ?: '-', ENT_QUOTES, 'UTF-8'); ?>
</td>
<?php
$displayTitle = $row['title'];
$catDisplay = $row['cat_name'] ?: $row['category_code'];
if ($catDisplay === '마이클래스' && $row['goal_title']) {
$displayTitle = '[' . $row['goal_title'] . '] ' . $displayTitle;
}
?>
<td class="p-4 font-bold text-gray-800 truncate max-w-xs"
title="<?= htmlspecialchars($displayTitle, ENT_QUOTES, 'UTF-8'); ?>">
<?= htmlspecialchars($displayTitle, ENT_QUOTES, 'UTF-8'); ?>
</td>
<td class="p-4 text-gray-500 truncate max-w-xs"
title="<?= htmlspecialchars($row['description'] ?? '', ENT_QUOTES, 'UTF-8'); ?>">
<?= htmlspecialchars($row['description'] ?? '', ENT_QUOTES, 'UTF-8'); ?>
</td>
<td class="p-4 text-gray-500 truncate max-w-xs hidden"
title="<?= htmlspecialchars($row['description1'] ?? '', ENT_QUOTES, 'UTF-8'); ?>">
<?= htmlspecialchars($row['description1'] ?? '', ENT_QUOTES, 'UTF-8'); ?>
</td>
<td class="p-4 text-gray-500 truncate max-w-xs hidden"
title="<?= htmlspecialchars($row['description2'] ?? '', ENT_QUOTES, 'UTF-8'); ?>">
<?= htmlspecialchars($row['description2'] ?? '', ENT_QUOTES, 'UTF-8'); ?>
</td>
<td class="p-4 text-gray-500 truncate max-w-xs hidden"
title="<?= htmlspecialchars($row['description3'] ?? '', ENT_QUOTES, 'UTF-8'); ?>">
<?= htmlspecialchars($row['description3'] ?? '', ENT_QUOTES, 'UTF-8'); ?>
</td>
<td class="p-4">
<?php if ($row['content_url']): ?>
<a href="https://www.youtube.com/watch?v=<?= htmlspecialchars($row['content_url'], ENT_QUOTES, 'UTF-8'); ?>"
target="_blank" class="text-blue-600 hover:underline"><i class="fa-solid fa-link"></i></a>
<?php endif; ?>
</td>
<td class="p-4 text-center text-gray-600 font-bold">
<?= (int) ($row['keyword_count'] ?? 0); ?>
</td>
<td class="p-4 text-center">
<?php if ($row['is_active'] === '1'): ?>
<i class="fa-solid fa-check text-teal-600"></i>
<?php else: ?>
<span class="text-gray-300">-</span>
<?php endif; ?>
</td>
<td class="p-4 text-center">
<button class="px-3 py-1 bg-teal-800 text-white rounded text-xs hover:bg-teal-900 transition btn-edit"
data-row="<?= htmlspecialchars(json_encode($row), ENT_QUOTES, 'UTF-8'); ?>">수정</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</section>
</main>
<div id="upload-modal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-[100] hidden p-4">
<div class="bg-white w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden">
<div class="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50">
<h3 class="text-xl font-bold text-gray-800">새 콘텐츠 등록</h3>
<button onclick="document.getElementById('upload-modal').classList.add('hidden')"
class="text-gray-400 hover:text-gray-600"><i class="fa-solid fa-xmark text-xl"></i></button>
</div>
<form action="../bbs/content_save.php" method="post" enctype="multipart/form-data">
<input type="hidden" name="content_id" id="content_id" value="">
<div class="p-8 space-y-5">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">카테고리 <span
class="text-red-500">*</span></label>
<div class="flex items-center gap-3">
<!-- Hidden input to ensure value is sent when disabled via JS on edit -->
<input type="hidden" name="category_code_hidden" id="category_code_hidden">
<select name="category_code" id="category_code"
class="flex-1 border-gray-200 rounded-lg p-2.5 bg-gray-50 font-bold focus:border-teal-500 focus:ring-teal-500"
required onchange="document.getElementById('category_code_hidden').value=this.value;">
<?php foreach ($categories as $c): ?>
<option value="<?= htmlspecialchars($c['code'], ENT_QUOTES, 'UTF-8'); ?>"
data-name="<?= htmlspecialchars($c['name'], ENT_QUOTES, 'UTF-8'); ?>" <?= $c['code'] === $defaultCatCode ? 'selected' : ''; ?>>
<?= htmlspecialchars($c['name'], ENT_QUOTES, 'UTF-8'); ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div>
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">카테고리구분</label>
<select id="category_group" name="category_group"
class="w-full border-gray-200 rounded-lg p-2.5 bg-gray-50">
<option value="">선택</option>
</select>
</div>
</div>
<div>
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">콘텐츠명 (강좌명)</label>
<input name="title" type="text" class="w-full border-gray-200 rounded-lg p-2.5 bg-gray-50"
placeholder="콘텐츠 제목을 입력하세요" required>
</div>
<!-- 영상 길이 저장용 hidden (edu_contents.content_tm) -->
<input type="hidden" name="content_tm" id="content_tm_input" value="">
<div>
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">유튜브 URL (영상 ID)</label>
<div class="flex gap-2 items-center">
<input name="content_url" id="content_url_input" type="text"
class="flex-1 border-gray-200 rounded-lg p-2.5 bg-gray-50"
placeholder="예: ELd23Hll3is 또는 전체 URL">
<button type="button" id="btn-yt-fetch"
onclick="fetchYouTubeInfo()"
class="hidden px-3 py-2.5 bg-red-600 text-white rounded-lg text-xs font-bold whitespace-nowrap hover:bg-red-700 transition flex items-center gap-1.5">
<i class="fa-brands fa-youtube"></i>영상 정보 가져오기
</button>
</div>
<!-- 유튜브 API 상태 메시지 -->
<div id="yt-fetch-status" class="mt-1.5 text-xs hidden">
<span id="yt-fetch-msg"></span>
</div>
</div>
<div>
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase flex justify-between">
<span>콘텐츠설명</span>
<span id="yt-duration-display" class="text-gray-300 font-normal hidden">⏱ <span id="yt-duration-text"></span></span>
</label>
<textarea name="description" id="description_input" rows="2"
class="w-full border-gray-200 rounded-lg p-2.5 bg-gray-50 resize-none"
placeholder="유튜브 URL 입력 후 [영상 정보 가져오기]를 클릭하면 자동 입력됩니다."></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">콘텐츠설명1</label>
<input name="description1" type="text" class="w-full border-gray-200 rounded-lg p-2.5 bg-gray-50"
placeholder="">
</div>
<div>
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">콘텐츠설명2</label>
<input name="description2" type="text" class="w-full border-gray-200 rounded-lg p-2.5 bg-gray-50"
placeholder="">
</div>
</div>
<!-- 마이클래스 전용 -->
<div class="space-y-4 cat-block" data-cat="마이클래스">
<div class="grid grid-cols-2 gap-4">
<div class="col-span-1">
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">기준년도</label>
<input name="base_year" type="text" maxlength="4"
class="w-full border-gray-200 rounded-lg p-2.5 bg-gray-50" placeholder="YYYY" value="<?= date('Y'); ?>">
</div>
</div>
<div class="grid grid-cols-1">
<div>
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">학습목표코드</label>
<div class="flex gap-2">
<select name="goal_code" id="goal_code" class="flex-1 border-gray-200 rounded-lg p-2.5 bg-gray-50">
<option value="">선택</option>
</select>
<button type="button" onclick="openGoalModal()"
class="px-3 py-2 bg-blue-600 text-white rounded-lg text-xs whitespace-nowrap">등록</button>
</div>
</div>
</div>
</div>
<!-- 온보딩 -->
<div class="space-y-4 cat-block hidden" data-cat="온보딩"></div>
<!-- 법정교육 -->
<div class="space-y-4 cat-block hidden" data-cat="법정교육">
<div>
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">기준년도</label>
<input name="base_year_law" type="text" class="w-full border-gray-200 rounded-lg p-2.5 bg-gray-50"
placeholder="예: 2026">
</div>
</div>
<!-- 리더십 / 인사이트 -->
<div class="space-y-4 cat-block hidden" data-cat="인사이트,리더십">
<div class="grid grid-cols-2 gap-4">
<div id="issue_type_is_container">
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">이슈구분 (인사이트)</label>
<select id="issue_type_code_is" name="issue_type_code"
class="w-full border-gray-200 rounded-lg p-2.5 bg-gray-50">
<option value="">선택</option>
<?php foreach ($issueTypesIS as $it): ?>
<option value="<?= htmlspecialchars($it['code'], ENT_QUOTES, 'UTF-8'); ?>">
<?= htmlspecialchars($it['name'], ENT_QUOTES, 'UTF-8'); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div id="issue_type_ld_container" class="hidden">
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">이슈구분 (리더십)</label>
<select id="issue_type_code_ld" name="issue_type_code"
class="w-full border-gray-200 rounded-lg p-2.5 bg-gray-50" disabled>
<option value="">선택</option>
<?php foreach ($issueTypesLD as $it): ?>
<option value="<?= htmlspecialchars($it['code'], ENT_QUOTES, 'UTF-8'); ?>">
<?= htmlspecialchars($it['name'], ENT_QUOTES, 'UTF-8'); ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="flex items-center gap-6">
<label class="inline-flex items-center space-x-2 cursor-pointer">
<input name="is_offer" type="checkbox" class="w-4 h-4 text-teal-600 rounded"> <span
class="text-sm font-bold">추천콘텐츠 적용</span>
</label>
<div class="flex items-center gap-2" id="offer_id_container">
<span class="text-xs font-bold text-gray-400 uppercase">제안ID</span>
<input name="offer_id" type="text" class="border-gray-200 rounded-lg p-2.5 bg-gray-50">
</div>
</div>
<div>
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">콘텐츠설명3 (메모)</label>
<textarea name="description3" rows="3"
class="w-full border-gray-200 rounded-lg p-2.5 bg-gray-50 resize-none"
placeholder="콘텐츠설명3 내용을 입력하세요"></textarea>
</div>
</div>
<!-- 비즈트렌드 -->
<div class="space-y-4 cat-block hidden" data-cat="비즈트렌드">
<div>
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">기준일자</label>
<input name="start_date_bzt" type="date" class="w-full border-gray-200 rounded-lg p-2.5 bg-gray-50">
</div>
<!-- 사내도서여부: category_group=CA200B01(1부) 일때만 표시 (JS 제어) -->
<div id="book_yn_container" class="hidden">
<label class="inline-flex items-center space-x-2 cursor-pointer">
<input name="book_yn" id="book_yn" type="checkbox" class="w-4 h-4 text-teal-600 rounded" value="1">
<span class="text-sm font-bold">사내도서여부</span>
</label>
</div>
</div>
<!-- Keyword UI logic moved to modal -->
<div class="flex items-end gap-4">
<div class="flex-1" id="sort_order_container" style="display:none;">
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">정렬 순번</label>
<input name="sort_order" type="number" class="w-full border-gray-200 rounded-lg p-2.5 bg-gray-50" value="">
</div>
<div class="flex items-center space-x-4">
<label class="inline-flex items-center space-x-2 cursor-pointer">
<input name="is_active" type="checkbox" class="w-4 h-4 text-teal-600 rounded" checked> <span
class="text-sm font-bold">사용여부</span>
</label>
<button type="button" onclick="openKeywordModal()"
class="text-sm font-bold text-orange-600 hover:text-orange-700 underline underline-offset-2">키워드등록(<span
id="keyword-count-display">0</span>개)</button>
<div id="image_upload_container" class="hidden relative inline-block">
<label for="image_name_input" id="image_name_label"
class="cursor-pointer text-sm font-bold text-purple-700 hover:text-purple-800 underline underline-offset-2 whitespace-nowrap">대표이미지등록(N)</label>
<input type="file" id="image_name_input" name="image_name" class="hidden" accept="image/*"
onchange="previewImageUpload(this)">
</div>
<button type="button" id="btn-memo-open" onclick="openMemoModal()"
class="text-sm font-bold text-teal-700 hover:text-teal-800 underline underline-offset-2 hidden">포스트잇
등록</button>
</div>
</div>
</div>
<div class="p-6 bg-gray-50 border-t border-gray-100 flex justify-between items-center">
<div>
<button type="button" id="btn-delete"
class="px-6 py-2 text-white bg-red-600 rounded-lg font-bold shadow-lg hover:bg-red-700 hidden">삭제</button>
</div>
<div class="flex space-x-3">
<button type="button" onclick="document.getElementById('upload-modal').classList.add('hidden')"
class="px-6 py-2 text-gray-500 font-bold hover:text-gray-700">취소</button>
<button type="submit" class="px-8 py-2 bg-teal-800 text-white rounded-lg font-bold shadow-lg">저장하기</button>
</div>
</div>
</form>
</div>
</div>
<!-- 포스트잇 등록 모달 (마이클래스 전용) -->
<div id="memo-modal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-[115] hidden p-4">
<div class="bg-white w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div class="p-4 border-b border-gray-100 flex justify-between items-center bg-gray-50 shrink-0">
<h3 class="text-xl font-bold text-gray-800">포스트잇 등록</h3>
<button type="button" onclick="closeMemoModal()" class="text-gray-400 hover:text-gray-600"><i
class="fa-solid fa-xmark text-xl"></i></button>
</div>
<div class="flex-1 overflow-auto p-6 bg-white space-y-6">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-500">
콘텐츠ID: <span id="memo-content-id" class="font-mono text-xs text-gray-700"></span>
</div>
<button type="button" onclick="newMemo()"
class="px-4 py-2 bg-green-600 text-white rounded-lg font-bold text-sm hover:bg-green-700">신규 작성</button>
</div>
<div class="bg-gray-50/50 border border-gray-200 rounded-xl overflow-hidden">
<table class="w-full text-left border-collapse bg-white">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="p-3 text-sm font-bold text-gray-500 w-16 text-center">순번</th>
<th class="p-3 text-sm font-bold text-gray-500">내용</th>
<th class="p-3 text-sm font-bold text-gray-500 w-24 text-center">사용여부</th>
<th class="p-3 text-sm font-bold text-gray-500 w-24 text-center">수정</th>
</tr>
</thead>
<tbody id="memo-grid-body" class="divide-y divide-gray-100 text-sm">
<!-- JS injection -->
</tbody>
</table>
</div>
<form id="memo-form" class="space-y-4">
<input type="hidden" name="content_id" id="memo_form_content_id" value="">
<input type="hidden" name="seq" id="memo_form_seq" value="">
<div class="grid grid-cols-1 gap-4">
<div>
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">내용</label>
<textarea name="title" id="memo_form_title" rows="4"
class="w-full border-gray-200 rounded-lg p-3 bg-gray-50 resize-none"
placeholder="포스트잇 내용을 입력하세요"></textarea>
</div>
<label class="inline-flex items-center space-x-2 cursor-pointer">
<input name="is_active" id="memo_form_active" type="checkbox" class="w-4 h-4 text-teal-600 rounded" checked>
<span class="text-sm font-bold">사용여부</span>
</label>
</div>
</form>
</div>
<div class="p-4 bg-gray-50 border-t border-gray-100 flex justify-between items-center">
<button type="button" id="btn-memo-delete" onclick="deleteMemo()"
class="px-5 py-2 text-white bg-red-600 rounded-lg font-bold shadow-lg hover:bg-red-700 hidden">삭제</button>
<div class="flex gap-2">
<button type="button" onclick="closeMemoModal()"
class="px-6 py-2 text-gray-500 font-bold hover:text-gray-700">닫기</button>
<button type="button" onclick="saveMemo()"
class="px-8 py-2 bg-teal-800 text-white rounded-lg font-bold shadow-lg">저장</button>
</div>
</div>
</div>
</div>
<!-- 추천이유 등록 모달 -->
<div id="recommend-modal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-[116] hidden p-4">
<div class="bg-white w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div class="p-4 border-b border-gray-100 flex justify-between items-center bg-gray-50 shrink-0">
<h3 class="text-xl font-bold text-gray-800">추천이유 등록</h3>
<button type="button" onclick="closeRecommendModal()" class="text-gray-400 hover:text-gray-600"><i
class="fa-solid fa-xmark text-xl"></i></button>
</div>
<div class="flex-1 overflow-auto p-6 bg-white space-y-6">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-500">
학습목표코드: <span id="recommend-goal-code" class="font-mono text-xs text-gray-700"></span>
</div>
<button type="button" onclick="newRecommend()"
class="px-4 py-2 bg-green-600 text-white rounded-lg font-bold text-sm hover:bg-green-700">신규 작성</button>
</div>
<div class="bg-gray-50/50 border border-gray-200 rounded-xl overflow-hidden">
<table class="w-full text-left border-collapse bg-white">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="p-3 text-sm font-bold text-gray-500 w-16 text-center">순번</th>
<th class="p-3 text-sm font-bold text-gray-500">내용</th>
<th class="p-3 text-sm font-bold text-gray-500">내용2</th>
<th class="p-3 text-sm font-bold text-gray-500 w-24 text-center">사용여부</th>
<th class="p-3 text-sm font-bold text-gray-500 w-24 text-center">수정</th>
</tr>
</thead>
<tbody id="recommend-grid-body" class="divide-y divide-gray-100 text-sm">
<!-- JS injection -->
</tbody>
</table>
</div>
<form id="recommend-form" class="space-y-4">
<input type="hidden" name="goal_code" id="recommend_form_goal_code" value="">
<input type="hidden" name="seq" id="recommend_form_seq" value="">
<div class="grid grid-cols-1 gap-4">
<div>
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">내용</label>
<textarea name="title" id="recommend_form_title" rows="4"
class="w-full border-gray-200 rounded-lg p-3 bg-gray-50 resize-none"
placeholder="추천이유 내용을 입력하세요"></textarea>
</div>
<div>
<label class="block text-xs font-bold text-gray-400 mb-2 uppercase">내용2</label>
<textarea name="title2" id="recommend_form_title2" rows="4"
class="w-full border-gray-200 rounded-lg p-3 bg-gray-50 resize-none"
placeholder="추천이유 내용2를 입력하세요"></textarea>
</div>
<label class="inline-flex items-center space-x-2 cursor-pointer">
<input name="is_active" id="recommend_form_active" type="checkbox" class="w-4 h-4 text-teal-600 rounded"
checked>
<span class="text-sm font-bold">사용여부</span>
</label>
</div>
</form>
</div>
<div class="p-4 bg-gray-50 border-t border-gray-100 flex justify-between items-center">
<button type="button" id="btn-recommend-delete" onclick="deleteRecommend()"
class="px-5 py-2 text-white bg-red-600 rounded-lg font-bold shadow-lg hover:bg-red-700 hidden">삭제</button>
<div class="flex gap-2">
<button type="button" onclick="closeRecommendModal()"
class="px-6 py-2 text-gray-500 font-bold hover:text-gray-700">닫기</button>
<button type="button" onclick="saveRecommend()"
class="px-8 py-2 bg-teal-800 text-white rounded-lg font-bold shadow-lg">저장</button>
</div>
</div>
</div>
</div>
<!-- 학습목표 등록 모달 -->
<div id="goal-modal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-[110] hidden p-4">
<div class="bg-white w-full max-w-4xl rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div class="p-4 border-b border-gray-100 flex justify-between items-center bg-gray-50 shrink-0">
<h3 class="text-xl font-bold text-gray-800">학습목표 등록</h3>
<button type="button" onclick="closeGoalModal()" class="text-gray-400 hover:text-gray-600"><i
class="fa-solid fa-xmark text-xl"></i></button>
</div>
<div class="p-4 bg-white border-b border-gray-200 shrink-0">
<div class="flex items-center gap-4">
<label class="text-sm font-bold text-gray-700">기준년도</label>
<div class="relative w-32">
<input type="text" id="goal_search_year" class="w-full border-gray-200 rounded-lg p-2 bg-gray-50 text-center"
value="<?= date('Y') ?>" maxlength="4">
</div>
<label class="text-sm font-bold text-gray-700">분기</label>
<div class="relative w-32">
<select id="goal_search_quarter" class="w-full border-gray-200 rounded-lg p-2 bg-gray-50 text-center">
<option value="">전체</option>
<?php foreach ($quarters as $q): ?>
<option value="<?= htmlspecialchars($q['code'], ENT_QUOTES, 'UTF-8'); ?>">
<?= htmlspecialchars($q['name'], ENT_QUOTES, 'UTF-8'); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<button type="button" onclick="loadGoalGrid()"
class="px-4 py-2 bg-gray-800 text-white rounded-lg font-bold text-sm">검색</button>
</div>
</div>
<div class="flex-1 overflow-auto p-4 bg-gray-50/50 min-h-[200px]">
<table
class="w-full text-left border-collapse bg-white border border-gray-200 shadow-sm rounded-lg overflow-hidden">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="p-3 text-sm font-bold text-gray-500 w-16 text-center">No</th>
<th
class="p-3 bg-gray-50 text-gray-500 font-bold whitespace-nowrap border-b border-gray-100 w-24 text-center text-sm">
분기</th>
<th
class="p-3 bg-gray-50 text-gray-500 font-bold whitespace-nowrap border-b border-gray-100 w-24 text-center text-sm">
책장번호</th>
<th class="p-3 bg-gray-50 text-gray-500 font-bold whitespace-nowrap border-b border-gray-100 w-32 text-sm">
학습목표코드
</th>
<th class="p-3 text-sm font-bold text-gray-500 min-w-[16rem]">학습목표제목</th>
<th class="p-3 text-sm font-bold text-gray-500 w-24 text-center">사용여부</th>
<th class="p-3 text-sm font-bold text-gray-500 w-16">비고</th>
<th class="p-3 text-sm font-bold text-gray-500 w-24 text-center">수정</th>
</tr>
</thead>
<tbody id="goal-grid-body" class="divide-y divide-gray-100 text-sm">
<!-- JS injection -->
</tbody>
</table>
</div>
<form id="goal-form" action="../bbs/goal_save.php" method="post" class="shrink-0 bg-white border-t border-gray-200">
<input type="hidden" name="goal_code" id="goal_form_code" value="">
<input type="hidden" name="base_year" id="goal_form_year" value="<?= date('Y') ?>">
<div class="p-4 space-y-4">
<div class="grid grid-cols-12 gap-4 items-start">
<div class="col-span-5 flex gap-2">
<div class="flex-1">
<label class="block text-xs font-bold text-gray-400 mb-1 uppercase">분기</label>
<select id="goal_form_quarter" name="quarter"
class="w-full border-gray-200 rounded-lg p-2 bg-gray-50 text-sm">
<option value="">선택</option>
<?php foreach ($quarters as $q): ?>
<option value="<?= htmlspecialchars($q['code'], ENT_QUOTES, 'UTF-8'); ?>">
<?= htmlspecialchars($q['name'], ENT_QUOTES, 'UTF-8'); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex-1">
<label class="block text-xs font-bold text-gray-400 mb-1 uppercase">책장번호</label>
<select id="goal_form_goal_no" name="goal_no"
class="w-full border-gray-200 rounded-lg p-2 bg-gray-50 text-sm">
<option value="">선택</option>
<?php foreach ($goalNos as $g): ?>
<option value="<?= htmlspecialchars($g['code'], ENT_QUOTES, 'UTF-8'); ?>">
<?= htmlspecialchars($g['name'], ENT_QUOTES, 'UTF-8'); ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="col-span-5">
<label class="block text-xs font-bold text-gray-400 mb-1 uppercase flex justify-between">
<span>학습목표제목</span>
<span class="text-gray-300 font-normal"><span id="goal_title_len">0</span>/30자</span>
</label>
<input name="title" id="goal_form_title" type="text" maxlength="30"
class="w-full border-gray-200 rounded-lg p-2 bg-gray-50 text-sm" required
oninput="document.getElementById('goal_title_len').textContent=this.value.length">
</div>
<div class="col-span-2">
<label class="block text-xs font-bold text-gray-400 mb-1 uppercase">비고</label>
<input name="remarks" id="goal_form_remarks" type="text"
class="w-full border-gray-200 rounded-lg p-2 bg-gray-50 text-sm">
</div>
</div>
<div class="flex items-center justify-between">
<label class="inline-flex items-center space-x-2 cursor-pointer">
<input name="is_active" id="goal_form_active" type="checkbox" class="w-4 h-4 text-teal-600 rounded" checked>
<span class="text-sm font-bold">사용여부</span>
</label>
<div class="flex items-center gap-4">
<button type="button" onclick="openRecommendModal()"
class="text-sm font-bold text-teal-700 hover:text-teal-800 underline underline-offset-2">추천이유 등록</button>
<div class="w-32 hidden">
<label class="block text-xs font-bold text-gray-400 mb-1 uppercase text-right">정렬순번</label>
<input name="sort_order" id="goal_form_sort" type="number"
class="w-full border-gray-200 rounded-lg p-2 bg-gray-50 text-right">
</div>
</div>
</div>
</div>
<div class="p-4 bg-gray-50 border-t border-gray-100 flex justify-between items-center">
<div class="flex gap-2">
<button type="button" onclick="closeGoalModal()"
class="px-6 py-2 text-gray-500 font-bold hover:text-gray-700">취소</button>
<button type="button" id="btn-goal-delete" onclick="deleteGoal()"
class="px-5 py-2 text-white bg-red-600 rounded-lg font-bold shadow-lg hover:bg-red-700 hidden">삭제</button>
<button type="button" id="btn-goal-new" onclick="resetGoalForm()"
class="px-5 py-2 text-white bg-green-600 rounded-lg font-bold shadow-lg hover:bg-green-700 hidden">신규
작성</button>
</div>
<div class="flex space-x-3">
<button type="submit" class="px-8 py-2 bg-blue-700 text-white rounded-lg font-bold shadow-lg">저장</button>
</div>
</div>
</form>
</div>
</div>
<!-- 키워드 등록 모달 -->
<div id="keyword-modal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-[120] hidden p-4">
<div class="bg-white w-[500px] rounded-2xl shadow-2xl overflow-hidden relative">
<div class="p-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
<h3 class="text-xl font-bold text-gray-800">나의 키워드 수정</h3>
<button type="button" onclick="closeKeywordModal()" class="text-gray-400 hover:text-gray-600"><i
class="fa-solid fa-xmark text-xl"></i></button>
</div>
<form id="keyword-form" onsubmit="saveKeywords(event)">
<input type="hidden" name="content_id" id="kw_content_id">
<input type="hidden" name="keywords" id="kw_selected">
<div class="p-8">
<div class="flex flex-wrap justify-center gap-3 relative z-10" id="keyword-container-modal">
<?php foreach ($keywords as $kw): ?>
<button type="button"
class="kw-btn flex items-center justify-between gap-2 px-5 py-2.5 rounded-full border border-gray-200 bg-white text-gray-400 text-sm font-bold hover:border-orange-300 hover:text-orange-500 transition shadow-sm data-[active=true]:bg-orange-600 data-[active=true]:border-orange-700 data-[active=true]:text-white data-[active=true]:shadow-md"
data-code="<?= htmlspecialchars($kw['code'], ENT_QUOTES, 'UTF-8'); ?>">
#<?= htmlspecialchars($kw['name'], ENT_QUOTES, 'UTF-8'); ?>
<span class="kw-icon"><i class="fa-solid fa-plus text-xs"></i></span>
</button>
<?php endforeach; ?>
</div>
</div>
<div class="p-4 bg-gray-50 border-t border-gray-100 flex justify-end gap-2">
<button type="button" onclick="closeKeywordModal()"
class="px-5 py-2 text-gray-500 font-bold hover:text-gray-700">닫기</button>
<button type="submit" class="px-6 py-2 bg-orange-600 text-white rounded-lg font-bold shadow-lg">저장</button>
</div>
</form>
</div>
</div>
</body>
<script src="../js/content_upload.js?v=<?= time() ?>"></script>
+198
View File
@@ -0,0 +1,198 @@
<?php
// 세션 시작 (아직 시작되지 않은 경우에만)
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// 현재 파일명을 가져와서 메뉴 활성화에 사용
$current_page = basename($_SERVER['PHP_SELF']);
// 로그인 정보 시작 세션정보
$auth_level = $_SESSION['auth_level'] ?? ''; // 권한코드
$sys_comp_code = $_SESSION['sys_comp_code'] ?? ''; // 시스템 로그인 법인
$member_id = $_SESSION['member_id'] ?? ''; // 로그인 아이디
// 현재 년도의 법정의무교육 기간 조회
$legal_edu_period = '';
$user_qty = 0;
$user_name = '';
$comp_name = '';
try {
require_once __DIR__ . '/../../bbs/db_conn.php';
$current_year = date('Y');
$pdo = db_conn();
// 법정의무교육 기간 조회
$stmt = $pdo->prepare("
SELECT start_date, end_date
FROM edu_contents
WHERE category_code = 'CA10003'
AND base_year = ?
GROUP BY start_date, end_date
LIMIT 1
");
$stmt->execute([$current_year]);
$period = $stmt->fetch(PDO::FETCH_ASSOC);
if ($period && !empty($period['start_date']) && !empty($period['end_date'])) {
$start = date('Y.m.d', strtotime($period['start_date']));
$end = date('Y.m.d', strtotime($period['end_date']));
$legal_edu_period = "{$start} ~ {$end}";
}
// 전체 학습자 수 (퇴사자 제외) - 법정교육 기간 유무와 무관하게 항상 조회
$stmt_qty = $pdo->prepare("
SELECT COUNT(member_id)
FROM edu_users
WHERE (end_date IS NULL OR end_date > CURDATE())
AND sys_comp_code = working_comp
");
$stmt_qty->execute();
$user_qty = (int) $stmt_qty->fetchColumn();
// 관리자명 (이름 + 아이디)
$stmt_user_name = $pdo->prepare("
SELECT CONCAT(name, ' (', member_id, ')')
FROM edu_users
WHERE sys_comp_code = ?
AND member_id = ?
LIMIT 1
");
$stmt_user_name->execute([$sys_comp_code, $member_id]);
$user_name = $stmt_user_name->fetchColumn() ?: $member_id;
// 법인명
$stmt_comp_name = $pdo->prepare("
SELECT code_name
FROM edu_codes
WHERE group_code = 'CO100'
AND code = ?
LIMIT 1
");
$stmt_comp_name->execute([$sys_comp_code]);
$comp_name = $stmt_comp_name->fetchColumn() ?: $sys_comp_code;
} catch (Exception $e) {
// 오류 무시
}
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap');
body {
font-family: 'Noto Sans KR', sans-serif;
}
.nav-active {
background-color: rgba(255, 255, 255, 0.1);
font-weight: bold;
border-bottom: 4px solid rgba(255, 255, 255, 0.4);
}
</style>
<script>
// [DEBUG] 세션 auth_level 확인용 콘솔 출력
console.log('[auth_level]', '<?php echo addslashes($auth_level); ?>');
</script>
</head>
<body class="bg-[#f8fafc]">
<nav class="w-full shadow-md font-['Noto_Sans_KR'] sticky top-0 z-50">
<div class="bg-[#114b3d] text-white">
<div class="max-w-[1600px] mx-auto px-6 h-16 flex items-center justify-between">
<div class="flex items-center">
<h1 class="text-xl font-bold flex items-center tracking-tight cursor-pointer"
onclick="location.href='<?php echo ($auth_level === 'LE10002') ? 'legal_edu.php' : 'index.php'; ?>'">
<span class="bg-white text-[#114b3d] p-1 rounded mr-2"><i class="fa-solid fa-graduation-cap"></i></span>
배움터 <span class="ml-2 text-sm font-light opacity-70">관리자</span>
</h1>
</div>
<div class="hidden md:flex items-center h-full text-sm">
<?php
// 권한에 따른 탭 표시 제어
// LE10001: 전체권한 - 모든 탭 표시
// LE10002: 법인권한 - 전체학습현황, 학습자관리, 법정의무교육만 표시
// 그 외: 모든 탭 숨김
$is_full = ($auth_level === 'LE10001'); // 전체권한
$is_corp = ($auth_level === 'LE10002'); // 법인권한
$show_all = $is_full; // 콘텐츠입력, 설정 탭 표시 여부
$show_base = ($is_full || $is_corp); // 기본 3개 탭 표시 여부
?>
<?php if ($is_corp): ?>
<a href="legal_edu.php"
class="px-5 h-16 flex items-center hover:bg-white/10 transition space-x-2 <?php echo ($current_page == 'legal_edu.php') ? 'nav-active' : ''; ?>">
<i class="fa-solid fa-book opacity-80"></i>
<span>법정의무교육</span>
</a>
<?php
endif; ?>
<?php if ($show_all): ?>
<a href="index.php"
class="px-5 h-16 flex items-center hover:bg-white/10 transition space-x-2 <?php echo ($current_page == 'index.php') ? 'nav-active' : ''; ?>">
<i class="fa-solid fa-chart-line opacity-80"></i>
<span>전체학습현황</span>
</a>
<a href="member_list.php"
class="px-5 h-16 flex items-center hover:bg-white/10 transition space-x-2 <?php echo ($current_page == 'member_list.php') ? 'nav-active' : ''; ?>">
<i class="fa-solid fa-users opacity-80"></i>
<span>학습자관리</span>
</a>
<a href="legal_edu.php"
class="px-5 h-16 flex items-center hover:bg-white/10 transition space-x-2 <?php echo ($current_page == 'legal_edu.php') ? 'nav-active' : ''; ?>">
<i class="fa-solid fa-book opacity-80"></i>
<span>법정의무교육</span>
</a>
<a href="content_upload.php"
class="px-5 h-16 flex items-center hover:bg-white/10 transition space-x-2 <?php echo ($current_page == 'content_upload.php') ? 'nav-active' : ''; ?>">
<i class="fa-solid fa-pen-to-square opacity-80"></i>
<span>콘텐츠 입력</span>
</a>
<a href="settings.php"
class="ml-2 px-4 h-10 self-center flex items-center bg-white/10 hover:bg-white/20 rounded-lg transition <?php echo ($current_page == 'settings.php') ? 'ring-2 ring-white/50' : ''; ?>">
<i class="fa-solid fa-gear mr-2"></i>설정
</a>
<?php
endif; ?>
</div>
<div class="flex items-center space-x-4">
</div>
</div>
</div>
<div class="bg-[#0d3a2f] text-white/90 border-t border-white/5">
<div class="max-w-[1600px] mx-auto px-6 h-10 flex items-center text-[13px] space-x-8">
<div class="flex items-center border-l border-white/10 pl-8"><span class="opacity-60 mr-2">법인명:</span><span
class="font-bold"><?php echo $comp_name; ?></span></div>
<div class="flex items-center border-l border-white/10 pl-8"><span class="opacity-60 mr-2">관리자 ID:</span><span
class="font-bold"><?php echo $user_name; ?></span></div>
<div class="flex items-center border-l border-white/10 pl-8"><span class="opacity-60 mr-2">전체 학습자 수:</span><span
class="font-bold text-teal-300"> <?php echo $user_qty; ?></span></div>
<div class="flex items-center border-l border-white/10 pl-8"><span class="opacity-60 mr-2">법정의무교육
기간:</span><span
class="font-bold"><?php echo !empty($legal_edu_period) ? htmlspecialchars($legal_edu_period, ENT_QUOTES, 'UTF-8') : '미설정'; ?></span>
</div>
</div>
</div>
</nav>
+373
View File
@@ -0,0 +1,373 @@
<?php
require_once __DIR__ . '/../../bbs/auth.php';
edu_require_login();
include_once '../../bbs/db_conn.php';
include_once 'header.php';
$pdo = db_conn();
// 분기 선택 데이터
$stmt_quarters = $pdo->query("SELECT base_code AS code, code_name FROM edu_codes WHERE group_code = 'CA200' AND DESC01 = 'CA10001' ORDER BY base_code ASC");
$quarters = $stmt_quarters->fetchAll(PDO::FETCH_ASSOC);
// 현재 연도 및 디폴트 분기 설정
$current_year = date('Y');
$current_quarter = ceil(date('n') / 3);
// 법정의무교육 기간 (하드코딩된 예시 텍스트 대체용, 필요시 DB 조회)
$legal_edu_period = '2026.04.13 ~ 2026.12.31';
?>
<main class="max-w-[1600px] mx-auto p-6 bg-gray-50/50 min-h-screen font-sans">
<header class="flex flex-col md:flex-row justify-between items-end md:items-center mb-6 gap-4">
<div>
<h2 class="text-2xl font-bold text-gray-800 flex items-center">
전체 학습현황
</h2>
<p class="text-sm text-gray-400 mt-1">법정의무교육 기간: <?php echo htmlspecialchars($legal_edu_period); ?></p>
</div>
<div class="flex items-center space-x-3">
<div class="text-sm font-bold text-gray-600 mr-2">이번 분기 핵심 지표</div>
<div class="flex bg-white border border-gray-200 p-1 rounded-md shadow-sm">
<button id="btn_year_prev" class="px-3 py-1.5 text-sm rounded transition text-gray-500 hover:bg-gray-50"
onclick="dashboard.changeYear(<?php echo $current_year - 1; ?>)"><?php echo $current_year - 1; ?>년</button>
<button id="btn_year_curr" class="px-3 py-1.5 text-sm rounded transition bg-[#114b3d] text-white shadow-sm font-bold"
onclick="dashboard.changeYear(<?php echo $current_year; ?>)"><?php echo $current_year; ?>년</button>
</div>
<select id="select_quarter" class="bg-white border border-gray-200 text-gray-700 text-sm rounded-md px-4 py-2 font-medium shadow-sm outline-none focus:ring-2 focus:ring-[#114b3d]/50" onchange="dashboard.changeQuarter(this.value)">
<?php foreach ($quarters as $q): ?>
<option value="<?php echo htmlspecialchars($q['code']); ?>" <?php echo substr($q['code'], -1) == $current_quarter ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($q['code_name']); ?>
</option>
<?php endforeach; ?>
<?php if(empty($quarters)): ?>
<option value="1">1분기 (1~3월)</option>
<option value="2" selected>2분기 (4~6월)</option>
<option value="3">3분기 (7~9월)</option>
<option value="4">4분기 (10~12월)</option>
<?php endif; ?>
</select>
</div>
</header>
<!-- KPI 섹션 -->
<section class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<!-- 전체 접속률 -->
<div class="bg-[#114b3d] text-white p-5 rounded-xl shadow-md relative overflow-hidden flex flex-col justify-between">
<h3 class="text-sm font-bold opacity-90 mb-4">전체 접속률</h3>
<div class="flex justify-between mb-6">
<!-- 이번 분기 -->
<div class="flex-1 pr-4">
<div class="text-[11px] opacity-70 mb-1">이번 분기</div>
<div class="text-3xl font-extrabold flex items-baseline gap-1 mb-1" id="kpi_access_rate_current">
0<span class="text-lg font-bold">%</span>
</div>
<div class="text-[10px] opacity-70" id="kpi_access_desc_current">-명 중 -명 접속</div>
</div>
<!-- 전분기 -->
<div class="flex-1 pl-4 border-l border-white/20">
<div class="text-[11px] opacity-70 mb-1">전분기</div>
<div class="text-2xl font-bold flex items-baseline gap-1 mb-1 opacity-80" id="kpi_access_rate_prev">
0<span class="text-sm font-bold">%</span>
</div>
<div class="text-[10px] opacity-70" id="kpi_access_desc_prev">-명 중 -명 접속</div>
</div>
</div>
<!-- 하단 게이지 -->
<div class="space-y-3">
<div class="flex items-center gap-2">
<span class="text-[10px] opacity-70 w-8">전분기</span>
<div class="flex-1 bg-white/10 rounded-full h-1.5">
<div class="bg-gray-400 h-1.5 rounded-full transition-all duration-1000" id="kpi_access_bar_prev" style="width: 0%"></div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-[10px] opacity-70 w-8">현재</span>
<div class="flex-1 bg-white/10 rounded-full h-1.5">
<div class="bg-[#4ade80] h-1.5 rounded-full transition-all duration-1000" id="kpi_access_bar_current" style="width: 0%"></div>
</div>
</div>
</div>
</div>
<!-- 법정의무교육 이수율 -->
<div class="bg-white border border-gray-200 p-5 rounded-xl shadow-sm flex flex-col justify-between">
<h3 class="text-sm font-bold text-gray-700 mb-2">법정의무교육 이수율</h3>
<div class="flex justify-between items-center mb-1">
<div class="text-xs font-bold text-[#eab308]" id="kpi_legal_uncompleted">미이수 -명 잔여</div>
</div>
<div class="text-3xl font-extrabold text-[#16a34a] flex items-baseline gap-1 mb-2" id="kpi_legal_rate">
0<span class="text-lg">%</span>
</div>
<div class="text-xs text-gray-500" id="kpi_legal_desc">이수 -명 / 전체 -명</div>
<div class="w-full bg-gray-100 rounded-full h-1.5 mt-2">
<div class="bg-[#16a34a] h-1.5 rounded-full transition-all duration-1000" id="kpi_legal_bar" style="width: 0%"></div>
</div>
</div>
<!-- 마이클래스 -->
<div class="bg-white border border-gray-200 p-5 rounded-xl shadow-sm flex flex-col justify-between">
<h3 class="text-sm font-bold text-gray-700 mb-4">마이클래스</h3>
<div class="flex justify-between items-end mb-4">
<div>
<div class="text-[10px] text-gray-400 mb-1">목표 설정률</div>
<div class="text-2xl font-bold text-blue-600 flex items-baseline gap-1" id="kpi_myclass_target_rate">
0<span class="text-sm">%</span>
</div>
<div class="text-[11px] text-gray-500 mt-1" id="kpi_myclass_target_desc">설정 -명</div>
</div>
<div>
<div class="text-[10px] text-gray-400 mb-1">목표 달성률</div>
<div class="text-xl font-bold text-[#eab308] flex items-baseline gap-1" id="kpi_myclass_achieve_rate">
0<span class="text-sm">%</span>
</div>
<div class="text-[11px] text-gray-500 mt-1" id="kpi_myclass_achieve_desc">달성 -명</div>
</div>
</div>
<button class="w-full py-2 border border-gray-200 rounded text-xs font-bold text-gray-600 hover:bg-gray-50 transition flex justify-center items-center gap-1 mb-3" onclick="myclassModal.open()">
상세보기 <i class="fa-solid fa-arrow-right text-[10px]"></i>
</button>
<div class="w-full bg-gray-100 rounded-full h-1.5">
<div class="bg-[#eab308] h-1.5 rounded-full transition-all duration-1000" id="kpi_myclass_bar" style="width: 0%"></div>
</div>
</div>
<!-- 접속자 1인당 완주 콘텐츠 -->
<div class="bg-white border border-gray-200 p-5 rounded-xl shadow-sm flex flex-col justify-between relative overflow-hidden">
<h3 class="text-sm font-bold text-gray-700 mb-2">접속자 1인당 완주 콘텐츠</h3>
<div class="text-xs font-bold text-[#16a34a] mb-1 flex items-center gap-1" id="kpi_content_diff">
<i class="fa-solid fa-caret-up"></i> 전분기 대비 +-편
</div>
<div class="text-3xl font-extrabold text-gray-800 flex items-baseline gap-1 mb-2" id="kpi_content_per_user">
0<span class="text-lg">편</span>
</div>
<div class="text-xs text-gray-500" id="kpi_content_desc">접속자 -명 기준<br>총 완수 -회</div>
<div class="w-full bg-gray-100 rounded-full h-1.5 mt-2 relative z-10">
<div class="bg-[#f97316] h-1.5 rounded-full transition-all duration-1000" id="kpi_content_bar" style="width: 0%"></div>
</div>
</div>
</section>
<!-- 접속 현황 상세 섹션 -->
<div class="text-xs text-gray-400 mb-2 font-medium">접속 현황 상세</div>
<section class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<!-- 법인별 접속률 -->
<div class="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden flex flex-col h-[320px]">
<div class="px-5 py-4 flex justify-between items-center border-b border-gray-100">
<h3 class="font-bold text-gray-800 text-sm">법인별 접속률</h3>
<div class="text-[11px] text-gray-500 flex items-center gap-2 bg-gray-50 px-2 py-1 rounded">
기간 <i class="fa-regular fa-calendar"></i> <span id="txt_period_start">-</span> ~ <i class="fa-regular fa-calendar"></i> <span id="txt_period_end">-</span>
</div>
</div>
<div class="overflow-y-auto flex-1 p-0">
<table class="w-full text-xs text-left">
<thead class="bg-gray-50 text-gray-500 sticky top-0 z-10">
<tr>
<th class="py-3 px-5 font-medium">법인</th>
<th class="py-3 px-2 font-medium text-center">전분기 대비</th>
<th class="py-3 px-2 font-medium text-center">전체</th>
<th class="py-3 px-2 font-medium text-center">접속인원</th>
<th class="py-3 px-2 font-medium text-center">접속률</th>
<th class="py-3 px-5 font-medium text-right">미접속</th>
</tr>
</thead>
<tbody id="tbody_corp_access" class="divide-y divide-gray-50">
<!-- JS 렌더링 -->
</tbody>
</table>
</div>
</div>
<!-- 스택 바 차트 그룹 -->
<div class="bg-white border border-gray-200 rounded-xl shadow-sm p-6 flex flex-col justify-between h-[320px]">
<!-- 학습자 접속 빈도 -->
<div>
<h3 class="font-bold text-gray-800 text-sm mb-3">학습자 접속 빈도</h3>
<div class="flex w-full h-8 rounded-md overflow-hidden mb-2" id="bar_access_freq">
<div class="bg-[#114b3d] h-full flex items-center justify-center text-white font-bold text-xs transition-all duration-1000" style="width: 0%"></div>
<div class="bg-[#22c55e] h-full flex items-center justify-center text-white font-bold text-xs transition-all duration-1000" style="width: 0%"></div>
<div class="bg-[#f97316] h-full flex items-center justify-center text-white font-bold text-xs transition-all duration-1000" style="width: 0%"></div>
<div class="bg-gray-200 h-full flex items-center justify-center text-gray-500 font-bold text-xs transition-all duration-1000" style="width: 0%"></div>
</div>
<div class="flex text-[10px] text-gray-500 gap-4">
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-[#114b3d]"></span> 적극적 12회↑</div>
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-[#22c55e]"></span> 보통 5~11회</div>
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-[#f97316]"></span> 저 1~4회</div>
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-gray-200"></span> 미사용</div>
</div>
</div>
<!-- 접속 시간대 -->
<div>
<h3 class="font-bold text-gray-800 text-sm mb-3">접속 시간대</h3>
<div class="flex w-full h-8 rounded-md overflow-hidden mb-2" id="bar_access_time">
<div class="bg-[#114b3d] h-full flex items-center justify-center text-white font-bold text-xs transition-all duration-1000" style="width: 0%"></div>
<div class="bg-[#22c55e] h-full flex items-center justify-center text-white font-bold text-xs transition-all duration-1000" style="width: 0%"></div>
<div class="bg-gray-200 h-full flex items-center justify-center text-gray-500 font-bold text-xs transition-all duration-1000" style="width: 0%"></div>
</div>
<div class="flex text-[10px] text-gray-500 gap-4">
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-[#114b3d]"></span> 업무시간 09:00~17:00</div>
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-[#22c55e]"></span> 점심시간 11:30~13:30</div>
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-gray-200"></span> 업무외시간 9시 이전·17시 이후</div>
</div>
</div>
<!-- 접속 방법 -->
<div>
<h3 class="font-bold text-gray-800 text-sm mb-3">접속 방법</h3>
<div class="flex w-full h-8 rounded-md overflow-hidden mb-2" id="bar_access_device">
<div class="bg-[#114b3d] border-r border-white/20 h-full flex items-center justify-center text-white font-bold text-xs transition-all duration-1000" style="width: 0%"></div>
<div class="bg-[#22c55e] h-full flex items-center justify-center text-white font-bold text-xs transition-all duration-1000" style="width: 0%"></div>
</div>
<div class="flex text-[10px] text-gray-500 gap-4">
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-[#114b3d]"></span> PC</div>
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-[#22c55e]"></span> 모바일</div>
</div>
</div>
</div>
</section>
<!-- 콘텐츠 이용 현황 섹션 -->
<div class="text-xs text-gray-400 mb-2 font-medium">콘텐츠 이용 현황</div>
<section class="grid grid-cols-1 lg:grid-cols-3 gap-4 pb-10">
<!-- 카테고리별 이용 현황 -->
<div class="lg:col-span-1 bg-white border border-gray-200 rounded-xl shadow-sm p-5">
<h3 class="font-bold text-gray-800 text-sm mb-5">카테고리별 이용 현황</h3>
<div class="space-y-6" id="list_content_usage">
<!-- JS 렌더링 -->
</div>
</div>
<!-- 가장 많이 본 콘텐츠 -->
<div class="lg:col-span-2 bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden flex flex-col">
<div class="px-5 py-4 flex justify-between items-center border-b border-gray-100">
<h3 class="font-bold text-gray-800 text-sm">가장 많이 본 콘텐츠</h3>
<div class="flex gap-2" id="tab_popular_categories">
<button class="px-3 py-1 text-[11px] font-bold rounded-full border border-gray-200 bg-[#114b3d] text-white" data-category="ALL">전체</button>
<button class="px-3 py-1 text-[11px] font-bold rounded-full border border-gray-200 text-gray-500 hover:bg-gray-50" data-category="CA10001">마이클래스</button>
<button class="px-3 py-1 text-[11px] font-bold rounded-full border border-gray-200 text-gray-500 hover:bg-gray-50" data-category="CA10005">인사이트</button>
<button class="px-3 py-1 text-[11px] font-bold rounded-full border border-gray-200 text-gray-500 hover:bg-gray-50" data-category="CA10004">리더십</button>
<button class="px-3 py-1 text-[11px] font-bold rounded-full border border-gray-200 text-gray-500 hover:bg-gray-50" data-category="CA10006">비즈트렌드</button>
</div>
</div>
<div class="overflow-y-auto flex-1 p-0 max-h-[295px]">
<table class="w-full text-[11px] text-left">
<thead class="bg-gray-50 text-gray-500 sticky top-0 z-10">
<tr>
<th class="py-3 px-5 font-medium text-center w-20">순위</th>
<th class="py-3 px-2 font-medium">영상명</th>
<th class="py-3 px-2 font-medium text-center w-24">카테고리</th>
<th class="py-3 px-2 font-medium text-center w-24">시청수</th>
<th class="py-3 px-2 font-medium text-center w-24">완주율</th>
<th class="py-3 px-5 font-medium text-center w-24">댓글수</th>
</tr>
</thead>
<tbody id="tbody_popular_contents" class="divide-y divide-gray-50">
<!-- JS 렌더링 -->
</tbody>
</table>
</div>
<div class="px-5 py-2 bg-gray-50 border-t border-gray-100 text-[10px] text-gray-400">
정렬기준 : 시청수
</div>
</div>
</section>
<!-- 마이클래스 모달 -->
<div id="modal_myclass" class="fixed inset-0 z-50 hidden">
<!-- 배경 -->
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" onclick="myclassModal.close()"></div>
<!-- 모달 컨텐츠 -->
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[90%] max-w-4xl bg-white rounded-xl shadow-2xl flex flex-col max-h-[90vh]">
<!-- 헤더 -->
<div class="flex justify-between items-start p-6 border-b border-gray-100">
<div>
<h2 class="text-xl font-extrabold text-gray-800">마이클래스 현황</h2>
<p class="text-sm text-gray-500 mt-1">이번 분기 마이클래스 목표 선택 및 달성 현황을 확인할 수 있습니다.</p>
</div>
<div class="flex items-center gap-2">
<!-- 분기 선택 -->
<select id="modal_myclass_quarter" class="bg-white border border-gray-200 text-gray-700 text-sm rounded px-3 py-1.5 focus:ring-1 focus:ring-blue-500" onchange="myclassModal.fetchData(this.value)">
<?php foreach ($quarters as $q): ?>
<option value="<?php echo htmlspecialchars($q['code']); ?>" <?php echo substr($q['code'], -1) == $current_quarter ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($q['code_name']); ?>
</option>
<?php endforeach; ?>
</select>
<button class="p-1.5 rounded border border-gray-200 text-gray-500 hover:bg-gray-50" onclick="myclassModal.fetchData()">
<i class="fa-solid fa-rotate-right"></i>
</button>
<button class="p-1.5 rounded border border-gray-200 text-gray-500 hover:bg-gray-50" onclick="myclassModal.close()">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<!-- KPI 영역 -->
<div class="grid grid-cols-3 divide-x divide-gray-100 border-b border-gray-100">
<div class="p-6">
<div class="text-xs font-bold text-gray-500 mb-2 flex items-center gap-1.5">
<span class="bg-blue-100 text-blue-600 rounded-full w-5 h-5 flex items-center justify-center text-[10px]"><i class="fa-solid fa-bullseye"></i></span> 목표 선택률
</div>
<div class="text-3xl font-extrabold text-blue-600 mb-1" id="modal_mc_target_rate">0<span class="text-lg">%</span></div>
<div class="text-[11px] text-gray-400" id="modal_mc_target_desc">선택자 0명 / 전체 0명</div>
</div>
<div class="p-6">
<div class="text-xs font-bold text-gray-500 mb-2 flex items-center gap-1.5">
<span class="bg-green-100 text-green-600 rounded-full w-5 h-5 flex items-center justify-center text-[10px]"><i class="fa-solid fa-trophy"></i></span> 목표 달성률
</div>
<div class="text-3xl font-extrabold text-[#4ade80] mb-1" id="modal_mc_achieve_rate">0<span class="text-lg">%</span></div>
<div class="text-[11px] text-gray-400" id="modal_mc_achieve_desc">달성자 0명 / 선택자 0명</div>
</div>
<div class="p-6">
<div class="text-xs font-bold text-gray-500 mb-2 flex items-center gap-1.5">
<span class="bg-gray-100 text-gray-800 rounded-full w-5 h-5 flex items-center justify-center text-[10px]"><i class="fa-solid fa-user-minus"></i></span> 미선택자 수
</div>
<div class="text-3xl font-extrabold text-[#f97316] mb-1" id="modal_mc_unselect_qty">0<span class="text-lg">명</span></div>
<div class="text-[11px] text-gray-400" id="modal_mc_unselect_desc">전체 학습자의 0%</div>
</div>
</div>
<!-- 탭 영역 -->
<div class="px-6 flex gap-6 border-b border-gray-100">
<button class="py-3 text-sm font-bold border-b-2 border-blue-600 text-blue-600 transition" id="tab_btn_goals" onclick="myclassModal.switchTab('goals')">목표별 선택률</button>
<button class="py-3 text-sm font-bold border-b-2 border-transparent text-gray-400 hover:text-gray-600" id="tab_btn_comps" onclick="myclassModal.switchTab('comps')">법인별 비교</button>
</div>
<!-- 리스트 컨텐츠 -->
<div class="overflow-y-auto flex-1 p-6 relative min-h-[300px]">
<div class="absolute right-6 top-2 text-[10px] text-gray-400">(단위: %)</div>
<table class="w-full text-sm mt-4">
<thead class="bg-gray-50 text-gray-500 text-xs hidden" id="modal_table_thead">
<tr>
<th class="py-2 px-4 text-left w-1/3" id="modal_table_th1">목표</th>
<th class="py-2 px-4 text-right text-[10px] font-normal w-2/3" id="modal_table_th2">목표 선택률</th>
</tr>
</thead>
<tbody id="modal_list_tbody" class="divide-y divide-gray-50">
<tr><td class="text-center py-10 text-gray-400">데이터를 불러오는 중입니다...</td></tr>
</tbody>
</table>
</div>
<!-- 하단 닫기 -->
<div class="p-4 border-t border-gray-100 flex justify-end">
<button class="px-4 py-2 bg-white border border-gray-200 text-sm font-bold text-gray-600 rounded hover:bg-gray-50 transition" onclick="myclassModal.close()">닫기</button>
</div>
</div>
</div>
</main>
<script src="../js/dashboard.js?v=<?php echo time(); ?>"></script>
+922
View File
@@ -0,0 +1,922 @@
<?php
require_once __DIR__ . '/../../bbs/auth.php';
edu_require_login();
include_once '../../bbs/db_conn.php';
include_once 'header.php';
// 현재 년도
$current_year = date('Y');
$selected_year = $_GET['year'] ?? $current_year;
$selected_access_comp = $_GET['access_comp'] ?? '';
$selected_ranking_comp = $_GET['ranking_comp'] ?? '';
// 신규 필터 파라미터 (선택된 년도 기준)
$fr_date = $_GET['fr_date'] ?? "{$selected_year}-01-01";
$to_date = $_GET['to_date'] ?? "{$selected_year}-12-31";
$exclude_admin = ($_GET['exclude_admin'] ?? '0') === '1';
// 섹션별 별도 날짜가 필요한 경우를 위해 (추후 확장성 고려)
$rank_fr_date = $_GET['rank_fr_date'] ?? $fr_date;
$rank_to_date = $_GET['rank_to_date'] ?? $to_date;
$video_fr_date = $_GET['video_fr_date'] ?? $fr_date;
$video_to_date = $_GET['video_to_date'] ?? $to_date;
$stat_fr_date = $_GET['stat_fr_date'] ?? $fr_date;
$stat_to_date = $_GET['stat_to_date'] ?? $to_date;
$access_fr_date = $_GET['access_fr_date'] ?? $fr_date;
$access_to_date = $_GET['access_to_date'] ?? $to_date;
// 현재 년도의 법정의무교육 기간 조회
$legal_edu_period = '';
$user_qty = 0;
try {
require_once __DIR__ . '/../../bbs/db_conn.php';
$pdo = db_conn();
$stmt = $pdo->prepare("
SELECT start_date, end_date
FROM edu_contents
WHERE category_code = 'CA10003'
AND base_year = ?
GROUP BY start_date, end_date
LIMIT 1
");
$stmt->execute([$selected_year]);
$period = $stmt->fetch(PDO::FETCH_ASSOC);
if ($period && !empty($period['start_date']) && !empty($period['end_date'])) {
// 날짜 포맷: YYYY.MM.DD
$start = date('Y.m.d', strtotime($period['start_date']));
$end = date('Y.m.d', strtotime($period['end_date']));
$legal_edu_period = "{$start} ~ {$end}";
// 전체 학습자 수 표시 퇴사자 제외
$stmt_qty = $pdo->prepare("
SELECT COUNT(member_id) as qty
FROM edu_users
WHERE (end_date IS NULL OR end_date > CURDATE())
AND sys_comp_code = working_comp
");
$stmt_qty->execute();
$user_qty = $stmt_qty->fetchColumn();
}
} catch (Exception $e) {
// 법정의무교육 기간 조회 실패 시 기본값 유지
}
// 법인 리스트 가져오기 (프로시저 사용)
$pdo = db_conn();
$stmt_corp = $pdo->query("CALL proc_get_code2_list('CO100')");
$companies = $stmt_corp->fetchAll(PDO::FETCH_ASSOC);
while ($stmt_corp->nextRowset()) {
}
unset($stmt_corp);
// 법인별 학습인원 현황 (재직 중인 인원 수)
$learner_count_query = $pdo->prepare("
SELECT c.code_name, COUNT(DISTINCT b.member_id) as learner_count
FROM edu_codes c
LEFT JOIN edu_users b ON c.code = b.belong_comp AND b.end_date IS NULL
WHERE c.group_code = 'CO100' AND c.is_active = '1'
GROUP BY c.code, c.code_name
ORDER BY c.code ASC
");
$learner_count_query->execute();
$learner_counts = $learner_count_query->fetchAll();
// 법인별 통계 (총 학습시간)
$total_time_query = $pdo->prepare("
SELECT
c.code,
c.code_name,
CONCAT(
LPAD(FLOOR(IFNULL(SUM(CASE WHEN a.completed_at IS NULL THEN a.watch_tm ELSE a.content_tm END), 0) / 3600), 2, '0'), '시간 ',
LPAD(FLOOR((IFNULL(SUM(CASE WHEN a.completed_at IS NULL THEN a.watch_tm ELSE a.content_tm END), 0) % 3600) / 60), 2, '0'), '분'
) AS formatted_total_tm
FROM edu_codes c
LEFT JOIN edu_users b ON c.code = b.sys_comp_code
LEFT JOIN edu_learning_histories a ON b.member_id = a.member_id AND b.sys_comp_code = a.sys_comp_code AND a.last_viewed_at BETWEEN ? AND ?
WHERE c.group_code = 'CO100' AND c.is_active = '1'
GROUP BY c.code, c.code_name
ORDER BY c.code ASC
");
$total_time_query->execute(["$stat_fr_date 00:00:00", "$stat_to_date 23:59:59"]);
$total_times = $total_time_query->fetchAll();
// 법인별 통계 (평균 학습횟수)
$avg_count_query = $pdo->prepare("
SELECT
c.code,
c.code_name,
CONCAT(
IFNULL(
ROUND(
COUNT(a.content_id) ,
1
),
0
), '회'
) AS avg_view_count
FROM edu_codes c
LEFT JOIN edu_users b ON c.code = b.sys_comp_code
LEFT JOIN edu_learning_histories a ON b.member_id = a.member_id AND b.sys_comp_code = a.sys_comp_code AND a.last_viewed_at BETWEEN ? AND ?
WHERE c.group_code = 'CO100' AND c.is_active = '1'
GROUP BY c.code, c.code_name
ORDER BY c.code ASC
");
$avg_count_query->execute(["$stat_fr_date 00:00:00", "$stat_to_date 23:59:59"]);
$avg_counts = $avg_count_query->fetchAll();
// 법인별 접속 추이 (월별)
$access_trend_query = $pdo->prepare("
SELECT
MONTH(accessed_at) as month,
COUNT(al.member_id) as access_count
FROM edu_access_logs al
WHERE al.accessed_at BETWEEN ? AND ? AND (? = '' OR EXISTS (
SELECT 1 FROM edu_users u WHERE u.member_id = al.member_id AND u.sys_comp_code = ?
))
GROUP BY MONTH(al.accessed_at)
ORDER BY MONTH(al.accessed_at) ASC
");
$access_trend_query->execute(["$access_fr_date 00:00:00", "$access_to_date 23:59:59", $selected_access_comp, $selected_access_comp]);
$access_trends = $access_trend_query->fetchAll();
// 가장 많이 본 영상 (카테고리별 top5)
$popular_videos_query = $pdo->prepare("
SELECT
b.category_code,
b.title AS content_title,
COUNT(a.content_id) as view_count
FROM edu_learning_histories a
JOIN edu_contents b ON a.content_id = b.content_id
WHERE a.last_viewed_at BETWEEN ? AND ?
GROUP BY b.category_code, b.content_id, b.title
ORDER BY b.category_code, view_count DESC
");
$popular_videos_query->execute(["$video_fr_date 00:00:00", "$video_to_date 23:59:59"]);
$popular_videos = $popular_videos_query->fetchAll();
// 배움터 학습 랭킹
$ranking_sql = "
SELECT a.sys_comp_code,
a.name,
a.dept_name,
c.code_name as company_name,
SUM(CASE WHEN b.completed_at IS NULL THEN b.watch_tm ELSE b.content_tm END) / 3600 as total_hours
FROM edu_users a
JOIN edu_learning_histories b ON a.member_id = b.member_id AND a.sys_comp_code = b.sys_comp_code
JOIN edu_codes c ON a.belong_comp = c.code
WHERE b.last_viewed_at BETWEEN ? AND ? AND c.group_code = 'CO100' AND (? = '' OR a.belong_comp = ?)
";
if ($exclude_admin) {
$ranking_sql .= " AND a.auth_level NOT IN ('LE10001', 'LE10002') ";
}
$ranking_sql .= "
GROUP BY a.sys_comp_code, a.member_id, a.name, a.dept_name, c.code_name
ORDER BY total_hours DESC
LIMIT 20
";
$ranking_query = $pdo->prepare($ranking_sql);
$ranking_query->execute(["$rank_fr_date 00:00:00", "$rank_to_date 23:59:59", $selected_ranking_comp, $selected_ranking_comp]);
$rankings = $ranking_query->fetchAll();
?>
<main class="max-w-[1600px] mx-auto p-6">
<header class="flex flex-col md:flex-row justify-between items-end md:items-center mb-6 gap-4">
<div>
<h2 class="text-2xl font-bold text-gray-800 flex items-center">
전체학습현황
<span class="ml-4 text-xs font-normal px-2 py-1 bg-gray-200 rounded text-gray-600">전체 학습자 수:
<?php echo $user_qty; ?>명</span>
</h2>
<p class="text-sm text-gray-400 mt-1 italic leading-relaxed">법정의무교육 기간: <?php echo $legal_edu_period; ?></p>
</div>
<div class="flex items-center space-x-2">
<div class="flex bg-gray-200 p-1 rounded-md">
<?php
$is_prev_selected = $selected_year == ($current_year - 1);
$is_curr_selected = $selected_year == $current_year;
?>
<button class="px-3 py-1 text-sm rounded transition <?php echo $is_prev_selected ? 'bg-[#114b3d] text-white shadow-sm font-bold' : 'text-gray-500 hover:text-gray-800'; ?>"
onclick="changeYear(<?php echo $current_year - 1; ?>)"><?php echo $current_year - 1; ?>년</button>
<button class="px-3 py-1 text-sm rounded transition <?php echo $is_curr_selected ? 'bg-[#114b3d] text-white shadow-sm font-bold' : 'text-gray-500 hover:text-gray-800'; ?>"
onclick="changeYear(<?php echo $current_year; ?>)"><?php echo $current_year; ?>년</button>
</div>
<button
class="px-4 py-2 bg-[#2563eb] text-white rounded-md text-sm font-bold flex items-center hover:bg-blue-700 transition shadow-lg">
<i class="fa-solid fa-file-invoice mr-2"></i>교육결과보고서
</button>
</div>
</header>
<!-- 통계 카드 3개 -->
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- 법인별 학습인원 현황 (막대 차트) -->
<div class="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
<h3 class="font-bold text-gray-800 mb-4 flex items-center justify-between text-sm">
법인별 학습인원 현황
<i class="fa-solid fa-ellipsis-vertical text-gray-300"></i>
</h3>
<?php
$max_count = 0;
foreach ($learner_counts as $count) {
$val = (int) $count['learner_count'];
if ($val > $max_count)
$max_count = $val;
}
// 스케일 계산: 데이터가 있으면 500 단위로 올림, 없으면 기본 100
$y_max = $max_count > 0 ? ceil($max_count / 500) * 500 : 500;
if ($y_max < 1)
$y_max = 100;
$y_step_count = 4;
$y_unit = $y_max / $y_step_count;
$scale = 165 / $y_max;
$bar_width = 30;
$gap = 12;
$start_x = 45;
?>
<!-- SVG Bar Chart -->
<div class="relative w-full" style="height:240px;">
<svg viewBox="0 0 320 210" class="w-full h-full" xmlns="http://www.w3.org/2000/svg">
<!-- 격자선 -->
<?php for ($i = 0; $i <= $y_step_count; $i++):
$y = 175 - ($i * (165 / $y_step_count));
?>
<line x1="42" y1="<?php echo $y; ?>" x2="315" y2="<?php echo $y; ?>" stroke="#e5e7eb"
stroke-width="<?php echo $i === 0 ? '1' : '0.8'; ?>" <?php echo $i === 0 ? '' : 'stroke-dasharray="4,3"'; ?> />
<?php endfor; ?>
<!-- Y축 레이블 -->
<?php for ($i = 0; $i <= $y_step_count; $i++):
$y = 175 - ($i * (165 / $y_step_count));
$label = $i * $y_unit;
?>
<text x="38" y="<?php echo $y + 3; ?>" text-anchor="end" font-size="10"
fill="#9ca3af"><?php echo number_format($label); ?></text>
<?php endfor; ?>
<!-- Y축 라인 -->
<line x1="42" y1="8" x2="42" y2="175" stroke="#d1d5db" stroke-width="1" />
<?php
foreach ($learner_counts as $index => $data) {
if ($index >= 7)
break; // 차트 공간상 7개까지만 표시
$val = (int) $data['learner_count'];
$height = $val * $scale;
$y = 175 - $height;
$x = $start_x + $index * ($bar_width + $gap);
$color = $index < 4 ? '#114b3d' : '#1d6b56';
echo "<rect x='$x' y='$y' width='$bar_width' height='$height' fill='$color' rx='3'/>\n";
$text_x = $x + $bar_width / 2;
$short_name = mb_substr($data['code_name'], 0, 4); // 이름이 길면 자름
echo "<text x='$text_x' y='194' text-anchor='middle' font-size='9' fill='#6b7280'>{$short_name}</text>\n";
}
?>
</svg>
</div>
</div>
<!-- 법인별 통계 -->
<div class="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
<div class="space-y-2 mb-5">
<div class="flex justify-between items-center">
<h3 class="font-bold text-gray-800 italic underline decoration-blue-200 decoration-4 text-sm">법인별 통계</h3>
<select id="statType" class="text-xs bg-gray-50 border border-gray-100 rounded p-1"
onchange="changeStatType()">
<option value="avg">학습횟수</option>
<option value="total">총 학습시간</option>
</select>
</div>
</div>
<div id="statContent" class="space-y-3">
<!-- 평균학습 내용 -->
<div id="avgStats" class="space-y-3">
<?php foreach ($avg_counts as $data): ?>
<div
class="flex justify-between items-center pb-2 border-b border-gray-50 cursor-pointer hover:bg-gray-50 transition"
onclick="showCorpDetail('<?php echo htmlspecialchars($data['code']); ?>', 'avg', '<?php echo htmlspecialchars($data['code_name']); ?>')">
<span
class="text-sm font-medium text-gray-600"><?php echo htmlspecialchars($data['code_name']); ?></span><span
class="text-sm font-bold text-blue-600"><?php echo htmlspecialchars($data['avg_view_count']); ?></span>
</div>
<?php endforeach; ?>
</div>
<!-- 총 학습시간 내용 -->
<div id="totalStats" class="space-y-3" style="display: none;">
<?php foreach ($total_times as $data): ?>
<div
class="flex justify-between items-center pb-2 border-b border-gray-50 cursor-pointer hover:bg-gray-50 transition"
onclick="showCorpDetail('<?php echo htmlspecialchars($data['code']); ?>', 'total', '<?php echo htmlspecialchars($data['code_name']); ?>')">
<span
class="text-sm font-medium text-gray-600"><?php echo htmlspecialchars($data['code_name']); ?></span><span
class="text-sm font-bold text-blue-600"><?php echo htmlspecialchars($data['formatted_total_tm']); ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- 법인별 접속 추이 (라인 차트) -->
<div class="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-gray-800 text-sm">법인별 접속 추이(로그인 법인)</h3>
<div class="flex flex-col gap-1 items-end">
<select id="accessTrendComp" class="text-xs bg-gray-50 border border-gray-100 rounded p-1 w-24"
onchange="changeAccessTrendComp()">
<option value="">전체</option>
<?php foreach ($companies as $comp): ?>
<option value="<?php echo htmlspecialchars($comp['code']); ?>" <?php echo $selected_access_comp === $comp['code'] ? 'selected' : ''; ?>><?php echo htmlspecialchars($comp['name']); ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<!-- SVG Line Chart -->
<div class="relative w-full" style="height:240px;">
<svg viewBox="0 0 320 210" class="w-full h-full" xmlns="http://www.w3.org/2000/svg">
<?php
$max_access = 0;
foreach ($access_trends as $trend) {
$max_access = max($max_access, $trend['access_count']);
}
$target_max_access = ceil($max_access * 1.2);
if ($target_max_access == 0)
$target_max_access = 20;
$y_trend_step_count = 4;
$y_trend_unit = ceil($target_max_access / $y_trend_step_count);
$y_trend_max = $y_trend_unit * $y_trend_step_count;
$scale_y_trend = $y_trend_max > 0 ? 165 / $y_trend_max : 0;
?>
<!-- 격자선 -->
<?php for ($i = 0; $i <= $y_trend_step_count; $i++):
$y = 175 - ($i * (165 / $y_trend_step_count));
?>
<line x1="42" y1="<?php echo $y; ?>" x2="315" y2="<?php echo $y; ?>" stroke="#e5e7eb"
stroke-width="<?php echo $i === 0 ? '1' : '0.8'; ?>" <?php echo $i === 0 ? '' : 'stroke-dasharray="4,3"'; ?> />
<?php endfor; ?>
<!-- Y축 레이블 -->
<?php for ($i = 0; $i <= $y_trend_step_count; $i++):
$y = 175 - ($i * (165 / $y_trend_step_count));
$label = $i * $y_trend_unit;
?>
<text x="38" y="<?php echo $y + 3; ?>" text-anchor="end" font-size="11"
fill="#9ca3af"><?php echo number_format($label); ?></text>
<?php endfor; ?>
<!-- Y축 라인 -->
<line x1="42" y1="8" x2="42" y2="175" stroke="#d1d5db" stroke-width="1" />
<!-- 라인 경로 -->
<?php
$points = [];
$x_step = 273 / 11; // 12개월
$x_start = 42;
foreach ($access_trends as $trend) {
$month = $trend['month'];
$count = $trend['access_count'];
$x = $x_start + ($month - 1) * $x_step;
$y = 175 - ($count * $scale_y_trend);
$points[] = "$x,$y";
}
$points_str = implode(' ', $points);
?>
<polyline points="<?php echo $points_str; ?>" fill="none" stroke="#0d9488" stroke-width="2.5"
stroke-linejoin="round" />
<!-- 데이터 포인트 -->
<g>
<?php foreach ($access_trends as $trend):
$month = $trend['month'];
$count = $trend['access_count'];
$x = $x_start + ($month - 1) * $x_step;
$y = 175 - ($count * $scale_y_trend);
?>
<circle cx="<?php echo $x; ?>" cy="<?php echo $y; ?>" r="10" fill="transparent" class="cursor-pointer"
onclick="showAccessLogs(<?php echo $month; ?>)" />
<circle cx="<?php echo $x; ?>" cy="<?php echo $y; ?>" r="4.5" fill="white" stroke="#0d9488"
stroke-width="2.5" class="pointer-events-none" />
<?php endforeach; ?>
</g>
<!-- X축 레이블 -->
<?php for ($m = 1; $m <= 12; $m++):
$x = $x_start + ($m - 1) * $x_step;
?>
<text x="<?php echo $x; ?>" y="194" text-anchor="middle" font-size="10"
fill="#9ca3af"><?php echo $m; ?>월</text>
<?php endfor; ?>
</svg>
</div>
</div>
</section>
<!-- 하단: 가장 많이 본 영상 + 배움터 학습 랭킹 -->
<section class="grid grid-cols-1 lg:grid-cols-2 gap-6 pb-12">
<!-- 가장 많이 본 영상 -->
<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div class="p-5 border-b border-gray-50 flex justify-between items-center bg-gray-50/50">
<h3 class="font-bold text-gray-800 flex items-center italic text-sm">
<i class="fa-solid fa-play-circle text-blue-500 mr-2"></i>가장 많이 본 영상
</h3>
<div class="flex flex-col gap-1 items-end">
<div class="flex gap-1 items-center">
<input type="date" id="video_fr_date" value="<?php echo $video_fr_date; ?>"
class="text-xs border border-gray-200 rounded p-1" onchange="updateVideoDate()">
<span class="text-gray-400">~</span>
<input type="date" id="video_to_date" value="<?php echo $video_to_date; ?>"
class="text-xs border border-gray-200 rounded p-1" onchange="updateVideoDate()">
</div>
<select id="videoCategory" class="text-xs bg-white border border-gray-200 rounded p-1 w-24"
onchange="changeVideoCategory()">
<option value="CA10001">마이클래스</option>
<option value="CA10002">온보딩</option>
<option value="CA10003">법정교육</option>
<option value="CA10004">리더십</option>
<option value="CA10005">인사이트</option>
<option value="CA10006">비즈트렌드</option>
</select>
</div>
</div>
<div id="videoContent" class="p-5 space-y-5 overflow-y-auto max-h-[350px]">
<?php
$categories = ['CA10001' => '마이클래스', 'CA10002' => '온보딩', 'CA10003' => '법정교육', 'CA10004' => '리더십', 'CA10005' => '인사이트', 'CA10006' => '비즈트렌드'];
foreach ($categories as $cat_code => $cat_name):
$videos = array_filter($popular_videos, function ($v) use ($cat_code) {
return $v['category_code'] == $cat_code;
});
usort($videos, function ($a, $b) {
return $b['view_count'] - $a['view_count'];
});
$top5 = array_slice($videos, 0, 20);
?>
<div id="videos-<?php echo $cat_code; ?>" class="space-y-5"
style="display: <?php echo $cat_code == 'CA10001' ? 'block' : 'none'; ?>;">
<?php foreach ($top5 as $index => $video): ?>
<div class="flex items-center space-x-3">
<div class="font-bold text-blue-600 text-lg w-10 flex-shrink-0 text-center"><?php echo $index + 1; ?></div>
<div
class="w-24 h-14 bg-slate-200 rounded flex-shrink-0 flex items-center justify-center text-slate-400 text-xs">
<i class="fa-solid fa-play text-lg"></i>
</div>
<div>
<h4 class="font-bold text-sm leading-tight"><?php echo htmlspecialchars($video['content_title']); ?></h4>
<p class="text-[11px] text-gray-400 mt-1">시청수: <span
class="text-gray-700 font-bold"><?php echo htmlspecialchars($video['view_count']); ?>회</span></p>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<!--
<button class="w-full py-3 bg-slate-50 text-xs text-gray-400 font-medium hover:bg-slate-100 border-t border-gray-100 italic transition">
<i class="fa-solid fa-comment-dots mr-2"></i>한줄 소감문 보기
</button>
-->
</div>
<!-- 배움터 학습 랭킹 -->
<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div class="p-5 border-b border-gray-50 flex justify-between items-center bg-gray-50/50">
<h3 class="font-bold text-gray-800 flex items-center italic text-sm">
<i class="fa-solid fa-award text-teal-600 mr-2"></i>배움터 학습 랭킹
</h3>
<div class="flex flex-col gap-1 items-end">
<div class="flex gap-1 items-center">
<input type="date" id="rank_fr_date" value="<?php echo $rank_fr_date; ?>"
class="text-xs border border-gray-200 rounded p-1" onchange="updateRankingDate()">
<span class="text-gray-400">~</span>
<input type="date" id="rank_to_date" value="<?php echo $rank_to_date; ?>"
class="text-xs border border-gray-200 rounded p-1" onchange="updateRankingDate()">
</div>
<div class="flex gap-2 items-center">
<label class="flex items-center text-xs text-gray-500 cursor-pointer">
<input type="checkbox" id="excludeAdmin" class="mr-1" <?php echo $exclude_admin ? 'checked' : ''; ?>
onchange="updateRankingFilter()">
관리자 제외
</label>
<select id="rankingComp" class="text-xs bg-white border border-gray-200 rounded p-1 w-24"
onchange="changeRankingComp()">
<option value="">전체</option>
<?php foreach ($companies as $comp): ?>
<option value="<?php echo htmlspecialchars($comp['code']); ?>" <?php echo $selected_ranking_comp === $comp['code'] ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($comp['name']); ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<div class="p-4 overflow-y-auto max-h-[350px]">
<table class="w-full text-sm">
<tbody>
<?php foreach ($rankings as $index => $rank):
$level = $rank['total_hours'] >= 40 ? 'Master' : ($rank['total_hours'] >= 20 ? 'Elite' : ($rank['total_hours'] >= 8 ? 'Learner' : 'Rookie'));
$level_color = $level == 'Master' ? 'purple' : ($level == 'Elite' ? 'blue' : ($level == 'Learner' ? 'green' : 'gray'));
?>
<tr class="hover:bg-gray-50 transition <?php echo $index > 0 ? 'border-t border-gray-50' : ''; ?>">
<td class="p-3 font-bold text-blue-600 text-lg w-10"><?php echo $index + 1; ?></td>
<td class="p-3">
<p class="font-bold"><?php echo htmlspecialchars($rank['name']); ?></p>
<p class="text-[10px] text-gray-400"><?php echo htmlspecialchars($rank['company_name']); ?>
<?php echo htmlspecialchars($rank['dept_name']); ?>
</p>
</td>
<td class="p-3 text-right">
<span class="font-bold mr-2"><?php echo number_format($rank['total_hours'], 1); ?>시간</span>
<span
class="px-2 py-0.5 bg-<?php echo $level_color; ?>-100 text-<?php echo $level_color; ?>-600 text-[10px] rounded font-bold"><?php echo $level; ?></span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!--
<button class="w-full py-3 bg-slate-50 text-xs text-gray-400 font-medium hover:bg-slate-100 border-t border-gray-100 italic transition">
<i class="fa-solid fa-thumbs-up mr-2"></i>추천 영상 보기
</button>
-->
</div>
</section>
<!-- 접속자 리스트 모달 -->
<div id="accessListModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 hidden">
<div class="bg-white rounded-xl shadow-lg w-full max-w-4xl overflow-hidden flex flex-col max-h-[85vh]">
<div class="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
<h3 class="font-bold text-gray-800" id="accessListTitle">법인별 접속자 리스트</h3>
<button onclick="closeAccessListModal()" class="text-gray-400 hover:text-gray-600 transition">
<i class="fa-solid fa-xmark text-xl"></i>
</button>
</div>
<div class="px-6 py-4 bg-gray-50/50 flex flex-wrap gap-4 items-center">
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-gray-600">조회기간</span>
<input type="date" id="modal_access_fr_date"
class="border border-gray-300 rounded px-2 py-1 text-sm bg-white shadow-sm focus:ring-2 focus:ring-blue-500 outline-none">
<span class="text-gray-400">~</span>
<input type="date" id="modal_access_to_date"
class="border border-gray-300 rounded px-2 py-1 text-sm bg-white shadow-sm focus:ring-2 focus:ring-blue-500 outline-none">
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-gray-600">기준법인</span>
<select id="modal_access_comp"
class="border border-gray-300 rounded px-2 py-1 text-sm bg-white shadow-sm focus:ring-2 focus:ring-blue-500 outline-none">
<option value="">전체</option>
<?php foreach ($companies as $comp): ?>
<option value="<?php echo htmlspecialchars($comp['code']); ?>">
<?php echo htmlspecialchars($comp['name']); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<button onclick="searchAccessLogsInModal()"
class="px-4 py-1.5 bg-blue-600 text-white rounded font-bold text-sm hover:bg-blue-700 transition flex items-center transform active:scale-95 duration-100">
<i class="fa-solid fa-magnifying-glass mr-2 text-xs"></i>검색
</button>
</div>
<div class="px-6 pb-6 pt-2 overflow-y-auto max-h-[50vh]">
<table class="w-full text-sm text-left border-collapse">
<thead class="bg-gray-100 text-gray-600 sticky top-0 z-10 whitespace-nowrap shadow-[0_1px_0_0_#e5e7eb]">
<tr>
<th class="py-2 px-4 font-bold border-b border-gray-200">기준법인</th>
<th class="py-2 px-4 font-bold border-b border-gray-200">이름</th>
<th class="py-2 px-4 font-bold border-b border-gray-200">부서명</th>
<th class="py-2 px-4 font-bold border-b border-gray-200">직위</th>
<th id="thAccessedAt"
class="py-2 px-4 font-bold border-b border-gray-200 cursor-pointer select-none hover:bg-gray-200 transition whitespace-nowrap"
onclick="sortByAccessedAt()">
접속일시 <span id="sortIcon" class="ml-1 text-gray-400">↕</span>
</th>
</tr>
</thead>
<tbody id="accessListBody">
<!-- 데이터 삽입 영역 -->
</tbody>
</table>
<div id="accessListEmpty" class="text-center py-6 text-gray-500 hidden">
접속자 데이터가 없습니다.
</div>
</div>
</div>
</div>
<!-- 법인별 통계 상세 모달 -->
<div id="corpDetailModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 hidden">
<div class="bg-white rounded-xl shadow-lg w-full max-w-5xl overflow-hidden flex flex-col max-h-[90vh]">
<div class="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
<h3 class="font-bold text-gray-800" id="corpDetailTitle">법인별 통계 상세 정보</h3>
<button onclick="closeCorpDetailModal()" class="text-gray-400 hover:text-gray-600 transition">
<i class="fa-solid fa-xmark text-xl"></i>
</button>
</div>
<div class="px-6 py-4 bg-gray-50/50 flex flex-wrap gap-4 items-center">
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-gray-600">조회기간</span>
<input type="date" id="modal_stat_fr_date"
class="border border-gray-300 rounded px-2 py-1 text-sm bg-white shadow-sm focus:ring-2 focus:ring-teal-500 outline-none">
<span class="text-gray-400">~</span>
<input type="date" id="modal_stat_to_date"
class="border border-gray-300 rounded px-2 py-1 text-sm bg-white shadow-sm focus:ring-2 focus:ring-teal-500 outline-none">
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-gray-600">기준법인</span>
<select id="modal_stat_comp_code"
class="border border-gray-300 rounded px-2 py-1 text-sm bg-white shadow-sm focus:ring-2 focus:ring-teal-500 outline-none">
<option value="">전체</option>
<?php foreach ($companies as $comp): ?>
<option value="<?php echo htmlspecialchars($comp['code']); ?>">
<?php echo htmlspecialchars($comp['name']); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<button onclick="searchCorpDetailInModal()"
class="px-4 py-1.5 bg-teal-600 text-white rounded font-bold text-sm hover:bg-teal-700 transition flex items-center shadow-md transform active:scale-95 duration-100">
<i class="fa-solid fa-magnifying-glass mr-2 text-xs"></i>검색
</button>
</div>
<div class="px-6 pb-6 pt-2 overflow-y-auto">
<table class="w-full text-sm text-left border-collapse">
<thead class="bg-gray-100 text-gray-600 sticky top-0 z-10 whitespace-nowrap shadow-[0_1px_0_0_#e5e7eb]">
<tr>
<th class="py-2 px-4 font-bold border-b border-gray-200 w-[5%] text-center">번호</th>
<th class="py-2 px-4 font-bold border-b border-gray-200 w-[10%] text-center">사번</th>
<th class="py-2 px-4 font-bold border-b border-gray-200 w-[12%]">성명</th>
<th class="py-2 px-4 font-bold border-b border-gray-200 w-[18%]">부서</th>
<th class="py-2 px-4 font-bold border-b border-gray-200 w-[30%]">과정명</th>
<th class="py-2 px-4 font-bold border-b border-gray-200 w-[25%] text-center">최종학습일</th>
</tr>
</thead>
<tbody id="corpDetailBody">
<!-- 데이터 삽입 영역 -->
</tbody>
</table>
<div id="corpDetailEmpty" class="text-center py-6 text-gray-500 hidden">
데이터가 없습니다.
</div>
</div>
</div>
</div>
</main>
<script>
function getCommonParams() {
return {
ranking_comp: document.getElementById('rankingComp').value,
rank_fr_date: document.getElementById('rank_fr_date').value,
rank_to_date: document.getElementById('rank_to_date').value,
exclude_admin: document.getElementById('excludeAdmin').checked ? '1' : '0',
access_comp: document.getElementById('accessTrendComp').value,
video_fr_date: document.getElementById('video_fr_date').value,
video_to_date: document.getElementById('video_to_date').value,
video_category: document.getElementById('videoCategory').value,
fr_date: '<?php echo $fr_date; ?>',
to_date: '<?php echo $to_date; ?>'
};
}
function reloadWithParams(params) {
const urlParams = new URLSearchParams(window.location.search);
for (const [key, value] of Object.entries(params)) {
urlParams.set(key, value);
}
window.location.href = '?' + urlParams.toString();
}
function changeYear(year) {
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('year', year);
urlParams.set('fr_date', year + '-01-01');
urlParams.set('to_date', year + '-12-31');
// 섹션별 상세 필터 파라미터가 있다면 제거하여 새 년도 기본값으로 리셋
const paramsToRemove = [
'rank_fr_date', 'rank_to_date',
'video_fr_date', 'video_to_date',
'stat_fr_date', 'stat_to_date',
'access_fr_date', 'access_to_date'
];
paramsToRemove.forEach(p => urlParams.delete(p));
window.location.href = '?' + urlParams.toString();
}
function updateRankingDate() {
const p = getCommonParams();
reloadWithParams(p);
}
function updateRankingFilter() {
const p = getCommonParams();
reloadWithParams(p);
}
function changeRankingComp() {
const p = getCommonParams();
reloadWithParams(p);
}
function updateVideoDate() {
const p = getCommonParams();
reloadWithParams(p);
}
function changeAccessTrendComp() {
const p = getCommonParams();
reloadWithParams(p);
}
function changeStatType() {
const type = document.getElementById('statType').value;
document.getElementById('avgStats').style.display = type === 'avg' ? 'block' : 'none';
document.getElementById('totalStats').style.display = type === 'total' ? 'block' : 'none';
}
function changeVideoCategory() {
const category = document.getElementById('videoCategory').value;
const contents = document.querySelectorAll('#videoContent > div');
contents.forEach(div => {
div.style.display = div.id === 'videos-' + category ? 'block' : 'none';
});
}
// 접속자 리스트 정렬 상태
let _accessLogData = [];
let _accessSortDir = 'desc'; // 기본: 최신순
function renderAccessLogTable(data) {
const tbody = document.getElementById('accessListBody');
tbody.innerHTML = '';
if (data.length === 0) {
document.getElementById('accessListEmpty').classList.remove('hidden');
return;
}
document.getElementById('accessListEmpty').classList.add('hidden');
data.forEach(log => {
const tr = document.createElement('tr');
tr.className = 'border-b border-gray-100 hover:bg-gray-50';
tr.innerHTML = `
<td class="py-2 px-4 text-gray-700">${log.comp_name || log.sys_comp_code || '-'}</td>
<td class="py-2 px-4 text-gray-800 font-medium">${log.name || '-'}</td>
<td class="py-2 px-4 text-gray-600">${log.dept_name || '-'}</td>
<td class="py-2 px-4 text-gray-600">${log.rank_name || '-'}</td>
<td class="py-2 px-4 text-gray-500">${log.accessed_at || '-'}</td>
`;
tbody.appendChild(tr);
});
}
function sortByAccessedAt() {
if (_accessLogData.length === 0) return;
_accessSortDir = _accessSortDir === 'desc' ? 'asc' : 'desc';
const icon = document.getElementById('sortIcon');
if (_accessSortDir === 'asc') {
icon.textContent = '↑';
icon.classList.remove('text-gray-400');
icon.classList.add('text-blue-500');
} else {
icon.textContent = '↓';
icon.classList.remove('text-gray-400');
icon.classList.add('text-blue-500');
}
const sorted = [..._accessLogData].sort((a, b) => {
const da = new Date(a.accessed_at || 0);
const db = new Date(b.accessed_at || 0);
return _accessSortDir === 'asc' ? da - db : db - da;
});
renderAccessLogTable(sorted);
}
function showAccessLogs(month) {
const fr_date = '<?php echo $access_fr_date; ?>';
const to_date = '<?php echo $access_to_date; ?>';
const accessComp = document.getElementById('accessTrendComp').value;
document.getElementById('modal_access_fr_date').value = fr_date;
document.getElementById('modal_access_to_date').value = to_date;
document.getElementById('modal_access_comp').value = accessComp;
// 정렬 상태 초기화
_accessLogData = [];
_accessSortDir = 'desc';
const icon = document.getElementById('sortIcon');
icon.textContent = '↕';
icon.className = 'ml-1 text-gray-400';
document.getElementById('accessListModal').classList.remove('hidden');
searchAccessLogsInModal(month);
}
function searchAccessLogsInModal(month = '') {
const fr_date = document.getElementById('modal_access_fr_date').value;
const to_date = document.getElementById('modal_access_to_date').value;
const accessComp = document.getElementById('modal_access_comp').value;
const year = fr_date ? fr_date.split('-')[0] : '<?php echo $selected_year; ?>';
document.getElementById('accessListTitle').innerText = accessComp ? `${accessComp} 접속자 리스트 (${fr_date} ~ ${to_date})` : `전체 접속자 리스트 (${fr_date} ~ ${to_date})`;
document.getElementById('accessListBody').innerHTML = '<tr><td colspan="5" class="text-center py-4 text-gray-500"><i class="fa-solid fa-spinner fa-spin mr-2"></i>로딩 중...</td></tr>';
document.getElementById('accessListEmpty').classList.add('hidden');
fetch(`../bbs/get_access_logs.php?year=${year}&month=${month}&access_comp=${accessComp}&fr_date=${fr_date}&to_date=${to_date}`)
.then(response => response.json())
.then(res => {
if (res.success) {
_accessLogData = res.data;
document.getElementById('accessListTitle').innerText += ` - ${_accessLogData.length}회`;
renderAccessLogTable(_accessLogData);
} else {
alert(res.message);
}
})
.catch(error => {
console.error('Error fetching logs:', error);
document.getElementById('accessListBody').innerHTML = '<tr><td colspan="5" class="text-center py-4 text-red-500">데이터를 불러오는 중 오류가 발생했습니다.</td></tr>';
});
}
function closeAccessListModal() {
document.getElementById('accessListModal').classList.add('hidden');
}
function showCorpDetail(corpCode, type, corpName) {
const fr_date = '<?php echo $stat_fr_date; ?>';
const to_date = '<?php echo $stat_to_date; ?>';
document.getElementById('modal_stat_fr_date').value = fr_date;
document.getElementById('modal_stat_to_date').value = to_date;
document.getElementById('modal_stat_comp_code').value = corpCode;
document.getElementById('_corp_detail_type') ? null : (window._corp_detail_type = type);
document.getElementById('corpDetailModal').classList.remove('hidden');
searchCorpDetailInModal(type, corpName);
}
function searchCorpDetailInModal(type = window._corp_detail_type, corpName) {
const corpCode = document.getElementById('modal_stat_comp_code').value;
const fr_date = document.getElementById('modal_stat_fr_date').value;
const to_date = document.getElementById('modal_stat_to_date').value;
// 모달 타이틀 업데이트 (선택된 법인명 가져오기)
const select = document.getElementById('modal_stat_comp_code');
const selectedName = select.options[select.selectedIndex].text;
document.getElementById('corpDetailTitle').innerText = corpCode ? `${selectedName} 통계 상세 정보` : `전체 법인 통계 상세 정보`;
document.getElementById('corpDetailBody').innerHTML = '<tr><td colspan="6" class="text-center py-4 text-gray-500"><i class="fa-solid fa-spinner fa-spin mr-2"></i>로딩 중...</td></tr>';
document.getElementById('corpDetailEmpty').classList.add('hidden');
fetch(`../bbs/get_corp_stats_detail.php?corp_code=${corpCode}&fr_date=${fr_date}&to_date=${to_date}&type=${type}`)
.then(response => response.json())
.then(res => {
if (res.success) {
const tbody = document.getElementById('corpDetailBody');
tbody.innerHTML = '';
if (res.data.length === 0) {
document.getElementById('corpDetailEmpty').classList.remove('hidden');
return;
}
res.data.forEach((item, index) => {
const tr = document.createElement('tr');
tr.className = 'border-b border-gray-100 hover:bg-gray-50';
tr.innerHTML = `
<td class="py-2 px-4 text-gray-500 text-center text-xs">${index + 1}</td>
<td class="py-2 px-4 text-gray-700 text-center">${item.member_id || '-'}</td>
<td class="py-2 px-4 text-gray-800 font-medium">${item.name || '-'}</td>
<td class="py-2 px-4 text-gray-600">${item.dept_name || '-'}</td>
<td class="py-2 px-4 text-gray-600 truncate max-w-0" title="${item.content_title || ''}">${item.content_title || '-'}</td>
<td class="py-2 px-4 text-gray-500 text-center">${item.last_viewed_at || '-'}</td>
`;
tbody.appendChild(tr);
});
} else {
alert(res.message);
}
})
.catch(error => {
console.error('Error fetching detail:', error);
document.getElementById('corpDetailBody').innerHTML = '<tr><td colspan="6" class="text-center py-4 text-red-500">데이터를 불러오는 중 오류가 발생했습니다.</td></tr>';
});
}
function closeCorpDetailModal() {
document.getElementById('corpDetailModal').classList.add('hidden');
}
</script>
</body>
</html>
+486
View File
@@ -0,0 +1,486 @@
<?php
/**
* legal_cert_print.php - 수료증 출력 화면 스킨
*/
require_once __DIR__ . '/../../bbs/auth.php';
edu_require_login();
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>수료증 출력 - 배움터</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Gowun+Batang:wght@400;700&display=swap');
body {
margin: 0;
padding: 0;
background-color: #f1f5f9;
font-family: 'Noto Sans KR', sans-serif;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
}
/* 화면용 컨트롤 패널 */
.control-panel {
width: 100%;
max-width: 800px;
margin: 20px auto 10px;
padding: 15px 20px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
display: flex;
justify-content: space-between;
align-items: center;
box-sizing: border-box;
}
.btn {
padding: 10px 20px;
border-radius: 8px;
font-weight: bold;
font-size: 14px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
transition: all 0.2s ease;
}
.btn-primary {
background-color: #114b3d;
color: #ffffff;
}
.btn-primary:hover {
background-color: #0d3a2f;
box-shadow: 0 4px 12px rgba(17, 75, 61, 0.2);
}
.btn-secondary {
background-color: #e2e8f0;
color: #334155;
}
.btn-secondary:hover {
background-color: #cbd5e1;
}
/* 수료증 컨테이너 (화면용 A4 비율 가이드) */
.cert-page {
background: #ffffff;
width: 210mm;
height: 297mm;
padding: 20mm;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
margin: 10px auto 40px;
box-sizing: border-box;
position: relative;
overflow: hidden;
}
/* 인장 외곽 얇은 테두리 */
.cert-outer-border {
border: 1px solid #c0c0c0;
width: 100%;
height: 100%;
padding: 6px;
box-sizing: border-box;
position: relative;
}
/* 인장 내부 두껍고 고급스러운 테두리 */
.cert-inner-border {
border: 2px solid #4a5568;
width: 100%;
height: 100%;
padding: 55px 45px;
box-sizing: border-box;
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
z-index: 1;
}
/* 모서리 고전 장식 문양 */
.corner-decor {
position: absolute;
width: 24px;
height: 24px;
z-index: 10;
}
.decor-tl {
top: -6px;
left: -6px;
}
.decor-tr {
top: -6px;
right: -6px;
transform: scaleX(-1);
}
.decor-bl {
bottom: -6px;
left: -6px;
transform: scaleY(-1);
}
.decor-br {
bottom: -6px;
right: -6px;
transform: scale(-1);
}
/* 발급 번호 영역 */
.cert-no-area {
font-size: 13px;
color: #4a5568;
font-weight: 500;
text-align: left;
height: 20px;
}
/* 수료증 메인 타이틀 */
.cert-title {
font-family: 'Noto Sans KR', sans-serif;
font-size: 52px;
font-weight: 700;
text-align: center;
letter-spacing: 28px;
text-indent: 28px;
margin: 35px 0 45px;
color: #1a202c;
}
/* 가로 구분선 */
.divider {
border-top: 1.2px solid #a0aec0;
width: 100%;
margin: 0 auto;
}
/* 본문 데이터 리스트 */
.info-table {
width: 90%;
margin: 45px auto;
display: flex;
flex-direction: column;
gap: 24px;
}
.info-row {
display: flex;
align-items: center;
font-size: 18px;
line-height: 1.6;
}
.info-label {
width: 150px;
font-weight: 700;
color: #2d3748;
display: flex;
align-items: center;
}
.info-label .bullet {
color: #a0aec0;
font-size: 12px;
margin-right: 12px;
}
.info-label .text {
flex: 1;
display: flex;
justify-content: space-between;
padding-right: 15px;
}
.info-value {
flex: 1;
font-weight: 500;
color: #1a202c;
}
/* 은은하게 흐르는 백그라운드 워터마크 */
.watermark-container {
position: absolute;
left: 50%;
top: 48%;
transform: translate(-50%, -50%);
width: 320px;
height: 320px;
opacity: 0.08;
z-index: 0;
pointer-events: none;
display: flex;
justify-content: center;
align-items: center;
}
.watermark-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* 수여 선언 문구 */
.cert-statement {
font-family: 'Noto Sans KR', sans-serif;
font-size: 21px;
font-weight: 700;
text-align: center;
line-height: 2;
color: #2d3748;
margin: 40px 0;
word-break: keep-all;
}
/* 수료일자 */
.cert-date {
font-family: 'Noto Sans KR', sans-serif;
font-size: 18px;
font-weight: 700;
text-align: center;
letter-spacing: 4px;
color: #2d3748;
margin: 30px 0;
}
/* 발송 기관 및 직인 */
.cert-footer {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 30px;
position: relative;
}
.company-name {
font-family: 'Noto Sans KR', sans-serif;
font-size: 26px;
font-weight: 700;
color: #1a202c;
letter-spacing: 2px;
margin-bottom: 8px;
}
.ceo-name-area {
font-family: 'Noto Sans KR', sans-serif;
font-size: 20px;
font-weight: 700;
color: #2d3748;
display: inline-flex;
align-items: center;
position: relative;
}
/* 대표이사 텍스트 라인 전체 정의 */
.ceo-text {
position: relative; /* 💡 도장(absolute)의 절대 기준점이 됨 */
display: inline-block;
font-size: 20px; /* 프로젝트 환경에 맞게 조절 */
font-weight: bold;
line-height: 1;
}
/* 도장 컨테이너 위치 정밀 제어 */
.stamp-container {
position: absolute;
top: -15px; /* 💡 위아래 정렬 (이름 글자 중간쯤 오도록 마이너스 조절) */
left: 100%; /* 💡 이름 텍스트가 끝나는 바로 우측 끝 지점에 강제 배치 */
margin-left: 5px; /* 이름과 도장 사이의 기본 여백 */
display: inline-block;
}
/* 도장 이미지 사이즈 및 효과 */
.stamp-img {
width: 50px; /* 💡 수료증 직인 표준 사이즈 (50px ~ 60px 추천) */
height: 50px;
object-fit: contain;
opacity: 0.9; /* 글자가 살짝 비치도록 리얼리티 부여 */
}
/* 프린팅 관련 설정 */
@media print {
body {
background-color: #ffffff;
padding: 0;
margin: 0;
}
.no-print {
display: none !important;
}
.cert-page {
margin: 0;
box-shadow: none;
width: 210mm;
height: 297mm;
page-break-after: always;
page-break-before: avoid;
}
.cert-page:last-child {
page-break-after: avoid;
}
/* 웹 브라우저 인쇄 강제 여백 제거 */
@page {
size: A4 portrait;
margin: 0;
}
}
</style>
</head>
<body>
<!-- 화면용 상단 컨트롤 패널 -->
<div class="control-panel no-print">
<div style="font-weight: bold; color: #334155; font-size: 16px;">
<i class="fa-solid fa-graduation-cap text-[#114b3d] mr-1"></i> 수료증 인쇄 미리보기
</div>
<div style="display: flex; gap: 8px;">
<button class="btn btn-primary" onclick="window.print()">
<i class="fa-solid fa-print"></i> 인쇄하기 (PDF 저장)
</button>
<button class="btn btn-secondary" onclick="window.close()">
<i class="fa-solid fa-xmark"></i> 창 닫기
</button>
</div>
</div>
<!-- 수료증 메인 A4 용지 -->
<div class="cert-page">
<div class="cert-outer-border">
<!-- 모서리 코너 장식 문양 (SVG) -->
<!-- 탑 레프트 -->
<svg class="corner-decor decor-tl" viewBox="0 0 30 30" width="30" height="30">
<rect x="0" y="0" width="8" height="8" fill="#4a5568" />
<line x1="4" y1="4" x2="30" y2="4" stroke="#4a5568" stroke-width="2" />
<line x1="4" y1="4" x2="4" y2="30" stroke="#4a5568" stroke-width="2" />
</svg>
<!-- 탑 라이트 -->
<svg class="corner-decor decor-tr" viewBox="0 0 30 30" width="30" height="30">
<rect x="0" y="0" width="8" height="8" fill="#4a5568" />
<line x1="4" y1="4" x2="30" y2="4" stroke="#4a5568" stroke-width="2" />
<line x1="4" y1="4" x2="4" y2="30" stroke="#4a5568" stroke-width="2" />
</svg>
<!-- 바텀 레프트 -->
<svg class="corner-decor decor-bl" viewBox="0 0 30 30" width="30" height="30">
<rect x="0" y="0" width="8" height="8" fill="#4a5568" />
<line x1="4" y1="4" x2="30" y2="4" stroke="#4a5568" stroke-width="2" />
<line x1="4" y1="4" x2="4" y2="30" stroke="#4a5568" stroke-width="2" />
</svg>
<!-- 바텀 라이트 -->
<svg class="corner-decor decor-br" viewBox="0 0 30 30" width="30" height="30">
<rect x="0" y="0" width="8" height="8" fill="#4a5568" />
<line x1="4" y1="4" x2="30" y2="4" stroke="#4a5568" stroke-width="2" />
<line x1="4" y1="4" x2="4" y2="30" stroke="#4a5568" stroke-width="2" />
</svg>
<!-- 수료증 컨텐츠 내부 테두리 안쪽 -->
<div class="cert-inner-border">
<!-- 백그라운드 흐릿한 로고 워터마크 -->
<div class="watermark-container">
<img id="val-watermark" src="" class="watermark-img" alt="" style="display: none;">
</div>
<!-- 발급 번호 -->
<div class="cert-no-area">
발급번호 : <span id="val-cert-no">제 - 호</span>
</div>
<!-- 메인 타이틀 -->
<div class="cert-title">수료증</div>
<!-- 상단 가로 구분선 -->
<div class="divider"></div>
<!-- 본문 리스트 -->
<div class="info-table">
<!-- 성명 -->
<div class="info-row">
<div class="info-label">
<span class="bullet">●</span>
<span class="text"><span>성</span><span>명 : </span></span>
</div>
<div class="info-value" id="val-name">-</div>
</div>
<!-- 교육과정 -->
<div class="info-row">
<div class="info-label">
<span class="bullet">●</span>
<span class="text"><span>교</span><span>육</span><span>과</span><span>정 : </span></span>
</div>
<div class="info-value" id="val-category">-</div>
</div>
<!-- 교육기간 -->
<div class="info-row">
<div class="info-label">
<span class="bullet">●</span>
<span class="text"><span>교</span><span>육</span><span>기</span><span>간 : </span></span>
</div>
<div class="info-value" id="val-period">-</div>
</div>
<!-- 교육시간 -->
<div class="info-row">
<div class="info-label">
<span class="bullet">●</span>
<span class="text"><span>교</span><span>육</span><span>시</span><span>간</span></span>
</div>
<div class="info-value" id="val-hours">-</div>
</div>
</div>
<!-- 하단 가로 구분선 -->
<div class="divider"></div>
<!-- 수여 성명 및 선언문 -->
<div class="cert-statement">
상기인은 위의 교육과정을 수료하였으므로<br>이 증서를 수여합니다.
</div>
<!-- 수료 년월일 -->
<div class="cert-date" id="val-prt-date">
- 년 - 월 - 일
</div>
<!-- 발송 회사명 및 대표이사 서명/도장 -->
<div class="cert-footer">
<div class="company-name" id="val-company">-</div>
<div class="ceo-name-area">
<span class="ceo-text">
대표이사 &nbsp;<span id="val-ceo">-</span>
<span class="stamp-container">
<img id="val-stamp" src="" class="stamp-img" alt="직인" style="display: none;">
</span>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 자바스크립트 로직 로드 -->
<script src="../js/legal_cert_print.js?v=<?= time() ?>"></script>
</body>
</html>
+697
View File
@@ -0,0 +1,697 @@
<?php
require_once __DIR__ . '/../../bbs/auth.php';
edu_require_login();
include_once 'header.php';
require_once __DIR__ . '/../../bbs/db_conn.php';
$search_comp = $_GET['comp'] ?? '';
// 만약 사용자가 처음 페이지에 들어왔거나(GET값이 없음), '전체'를 누른 게 아니라면 초기값 설정
// 소속회사(comp)도 동일한 메커니즘 적용
$search_comp = $_GET['comp'] ?? '';
if (empty($search_comp) && !isset($_GET['comp'])) {
$search_comp = $sys_comp_code;
}
$search_year = $_GET['year'] ?? date('Y');
$search_dept = $_GET['dept'] ?? '';
$search_name = $_GET['name'] ?? '';
$search_comp_status = $_GET['comp_status'] ?? '';
$message_name = '';
$all_user_qty = 0;
$completed_qty = 0;
$incomplete_qty = 0;
$corp_list = [];
$rows = [];
try {
$pdo = db_conn();
// 1. 프로시저 호입 후 nextRowset으로 부분적 result set을 완전 소진
try {
$stmt_corp = $pdo->query("CALL proc_get_code2_list('CO100')");
$corp_list = $stmt_corp->fetchAll(PDO::FETCH_ASSOC);
while ($stmt_corp->nextRowset()) {
}
unset($stmt_corp);
} catch (Exception $eProc) {
$corp_list = [];
}
// 1-2. 교육과정별 수료증 출력을 위한 과정 목록 조회
$course_list = [];
try {
$stmt_courses = $pdo->query("SELECT base_code AS code, code_name FROM edu_codes WHERE group_code='CA200' AND desc01 = 'CA10003' ORDER BY base_code;");
$course_list = $stmt_courses->fetchAll(PDO::FETCH_ASSOC);
while ($stmt_courses->nextRowset()) {
}
unset($stmt_courses);
} catch (Exception $eCourses) {
$course_list = [];
}
// 권한에 따른 법인 목록 제어
// LE10001: 전체권한 → 전체 법인 표시 (corp_list 그대로)
// LE10002: 법인권한 → 본인 법인($sys_comp_code)만 표시, 검색값도 고정
// 그 외: 법인 목록 없음
// 법인 초기값은 로그인한 사용자의 법인으로 설정
$message_name = $pdo->prepare("SELECT DESC01 FROM edu_codes WHERE base_code = 'AL100100'");
$message_name->execute();
$message_name = $message_name->fetchColumn();
if ($auth_level === 'LE10002') {
// 본인 법인만 필터링
$corp_list = array_filter($corp_list, fn($c) => $c['code'] === $sys_comp_code);
$corp_list = array_values($corp_list);
// 검색 법인도 강제 고정
$search_comp = $sys_comp_code;
} elseif ($auth_level !== 'LE10001') {
// 그 외 권한: 법인 목록 비움
$corp_list = [];
}
// 2. 전체 대상자 수 로그인한 법인과 소속회사가 같은 기준으로 계산, 퇴사자 제외
$stmt_all = $pdo->prepare("SELECT COUNT(DISTINCT member_id)
FROM edu_users
WHERE (end_date IS NULL OR (end_date > '1000-01-01' AND YEAR(end_date) >= ?))
AND (? = '' OR belong_comp = ?)
and sys_comp_code = belong_comp");
$stmt_all->execute([$search_year, $search_comp, $search_comp]);
$all_user_qty = (int) $stmt_all->fetchColumn();
// 3. 미수료 인원
$stmt_incomp = $pdo->prepare("SELECT COUNT(DISTINCT u.member_id)
FROM edu_users u
WHERE (u.end_date IS NULL OR (u.end_date > '1000-01-01' AND YEAR(u.end_date) >= ?))
AND (? = '' OR u.belong_comp = ?)
AND sys_comp_code = belong_comp
AND fn_get_progress_rate(u.sys_comp_code,?,u.member_id,'CA10003','') != 100");
$stmt_incomp->execute([$search_year, $search_comp, $search_comp, $search_year]);
$incomplete_qty = (int) $stmt_incomp->fetchColumn();
$completed_qty = max(0, $all_user_qty - $incomplete_qty);
// 4. G1 메인 쿼리 (? 위치 파라미터 사용으로 재사용 문제 없음)
$sql_inner = "SELECT a.sys_comp_code, a.belong_comp
, (SELECT code_name FROM edu_codes c WHERE c.group_code = 'CO100' AND c.code = a.belong_comp LIMIT 1) AS comp_name
, a.name, a.member_id
, a.dept_name
, IFNULL(b.formatted_tm, '00시간 00분') AS all_tm
, CASE WHEN a.member_id IN (
SELECT u2.member_id FROM edu_users u2 WHERE NOT EXISTS (
SELECT 1 FROM edu_contents c2 WHERE c2.category_code = 'CA10003' AND c2.base_year = ? AND c2.is_active = '1'
AND NOT EXISTS (SELECT 1 FROM edu_learning_histories h2 WHERE h2.content_id = c2.content_id AND h2.member_id = u2.member_id AND h2.sys_comp_code = u2.sys_comp_code AND h2.completed_at IS NOT NULL AND h2.completed_at != '')
)
) THEN '수료' ELSE '미수료' END AS completion_status
, fn_get_progress_rate(a.sys_comp_code, ?, a.member_id, 'CA10003', '') AS progress_rate -- 진행율
, fn_get_completion_date(a.sys_comp_code, ?, a.member_id, 'CA10003', '') AS completion_date -- 학습완료일
FROM edu_users a
LEFT JOIN (
SELECT
t.sys_comp_code,
t.member_id,
CONCAT(
LPAD(FLOOR(SUM(t.calc_tm)/3600), 2, '0'), '시간 ',
LPAD(FLOOR((SUM(t.calc_tm)%3600)/60), 2, '0'), '분'
) AS formatted_tm
FROM (
SELECT
z.sys_comp_code,
x.member_id,
CASE
WHEN x.completed_at IS NOT NULL AND x.completed_at <> '' THEN x.content_tm
ELSE x.watch_tm
END AS calc_tm
FROM edu_learning_histories x
JOIN edu_contents y ON x.content_id = y.content_id
JOIN edu_users z ON x.sys_comp_code = z.working_comp AND x.member_id = z.member_id
WHERE y.category_code = 'CA10003'
AND YEAR(x.first_viewed_at) = ?
) t
GROUP BY t.sys_comp_code, t.member_id
) b ON a.member_id = b.member_id AND a.sys_comp_code = b.sys_comp_code
WHERE (a.end_date IS NULL OR (a.end_date > '1000-01-01' AND YEAR(a.end_date) >= ?))
and a.sys_comp_code = a.belong_comp
AND (? = '' OR a.belong_comp = ?)
AND a.dept_name LIKE CONCAT('%', ?, '%')
AND a.name LIKE CONCAT('%', ?, '%')";
if ($search_comp_status === 'Y') {
$sql = "SELECT * FROM ($sql_inner) t WHERE completion_status = '수료'";
} elseif ($search_comp_status === 'N') {
$sql = "SELECT * FROM ($sql_inner) t WHERE completion_status = '미수료'";
} else {
$sql = "SELECT * FROM ($sql_inner) t";
}
$stmt_g1 = $pdo->prepare($sql);
// ? 순서: 1=completion_status(base_year), 2=progress_rate(year), 3=completion_date(year), 4=LEFT JOIN(first_viewed_at), 5=WHERE(end_date), 6=belong_comp 체크, 7=belong_comp 필터, 8=dept_name, 9=name
$stmt_g1->execute([$search_year, $search_year, $search_year, $search_year, $search_year, $search_comp, $search_comp, $search_dept, $search_name]);
$rows = $stmt_g1->fetchAll(PDO::FETCH_ASSOC);
} catch (Exception $e) {
$db_error = $e->getMessage();
}
?>
<main class="max-w-[1600px] mx-auto p-6">
<header class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<h2 class="text-2xl font-bold text-gray-800 italic">법정의무교육</h2>
<div class="flex flex-wrap gap-2">
<button onclick="downloadReportExcel()"
class="px-4 py-2 bg-white border border-gray-200 rounded-md text-sm font-medium hover:bg-gray-50 flex items-center shadow-sm">
<i class="fa-solid fa-file-excel mr-2"></i>교육결과보고서
</button>
<button onclick="openCourseSelectModal()"
class="px-4 py-2 bg-[#114b3d] text-white rounded-md text-sm font-bold flex items-center hover:bg-[#0d3a2f] shadow-sm transition">
<i class="fa-solid fa-print mr-2"></i>수료증출력
</button>
<button onclick="sendIncompleteNotification()"
class="px-4 py-2 bg-red-50 text-red-600 border border-red-100 rounded-md text-sm font-bold flex items-center hover:bg-red-100 shadow-sm transition">
<i class="fa-solid fa-bell mr-2"></i>미수료자 알림 (<?php echo $incomplete_qty; ?>명)
</button>
<button onclick="downloadExcel()"
class="px-4 py-2 bg-gray-100 text-gray-600 rounded-md text-sm font-medium hover:bg-gray-200 flex items-center transition">
<i class="fa-solid fa-download mr-2"></i>다운로드
</button>
</div>
</header>
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
<p class="text-xs font-bold text-gray-400 mb-1">전체 대상자</p>
<p class="text-3xl font-bold text-gray-800"><?php echo $all_user_qty; ?></p>
</div>
<div class="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
<p class="text-xs font-bold text-gray-400 mb-1">수료 완료</p>
<p class="text-3xl font-bold text-teal-600"><?php echo $completed_qty; ?></p>
</div>
<div class="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
<p class="text-xs font-bold text-gray-400 mb-1">미수료</p>
<p class="text-3xl font-bold text-red-500"><?php echo $incomplete_qty; ?>명</p>
</div>
</section>
<!-- 검색 조건 폼 -->
<form id="searchForm" method="GET" action="legal_edu.php"
class="bg-white p-5 rounded-xl border border-gray-200 shadow-sm mb-6 flex flex-wrap md:flex-row gap-4 items-end">
<div class="flex-1 min-w-[120px]">
<label for="comp" class="block text-xs font-bold text-gray-500 mb-2">법인 선택</label>
<?php if ($auth_level === 'LE10002'): ?>
<?php
// LE10002: 본인 법인명 표시 (변경 불가)
$fixed_corp_name = !empty($corp_list) ? htmlspecialchars($corp_list[0]['name']) : htmlspecialchars($sys_comp_code);
?>
<!-- 실제 전송값은 hidden으로, UI는 고정 텍스트로 표시 -->
<input type="hidden" name="comp" value="<?= htmlspecialchars($sys_comp_code) ?>">
<input type="text" value="<?= $fixed_corp_name ?>" readonly
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-100 text-gray-600 cursor-not-allowed">
<?php else: ?>
<select id="comp" name="comp" onchange="this.form.submit()"
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-50">
<option value="">전체</option>
<?php foreach ($corp_list as $corp): ?>
<option value="<?= htmlspecialchars($corp['code']) ?>" <?= $search_comp === $corp['code'] ? 'selected' : '' ?>>
<?= htmlspecialchars($corp['name']) ?>
</option>
<?php endforeach; ?>
</select>
<?php endif; ?>
</div>
<div class="flex-1 min-w-[100px]">
<label for="year" class="block text-xs font-bold text-gray-500 mb-2">기준년도</label>
<input type="text" id="year" name="year" value="<?= htmlspecialchars($search_year) ?>" placeholder="YYYY"
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-50"
onkeydown="if(event.key==='Enter') this.form.submit();">
</div>
<div class="flex-1 min-w-[120px]">
<label for="dept" class="block text-xs font-bold text-gray-500 mb-2">부서</label>
<input type="text" id="dept" name="dept" value="<?= htmlspecialchars($search_dept) ?>"
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-50"
onkeydown="if(event.key==='Enter') this.form.submit();">
</div>
<div class="flex-1 min-w-[100px]">
<label for="name" class="block text-xs font-bold text-gray-500 mb-2">성명</label>
<input type="text" id="name" name="name" value="<?= htmlspecialchars($search_name) ?>"
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-50"
onkeydown="if(event.key==='Enter') this.form.submit();">
</div>
<div class="flex-1 min-w-[120px]">
<label for="comp_status" class="block text-xs font-bold text-gray-500 mb-2">이수여부</label>
<select id="comp_status" name="comp_status" onchange="this.form.submit()"
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-50">
<option value="">전체</option>
<option value="Y" <?= $search_comp_status === 'Y' ? 'selected' : '' ?>>이수</option>
<option value="N" <?= $search_comp_status === 'N' ? 'selected' : '' ?>>미이수</option>
</select>
</div>
</form>
<section class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mb-12">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="bg-gray-50 border-b border-gray-200 text-gray-500 font-bold italic">
<tr>
<th class="p-4 w-16 text-center">NO</th>
<th class="p-4">소속법인</th>
<th class="p-4">성명</th>
<th class="p-4">사번</th>
<th class="p-4">부서</th>
<th class="p-4">학습시간</th>
<th class="p-4 w-48">진도율</th>
<th class="p-4">교육이수일</th>
<th class="p-4 text-center">수료구분</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<?php if (count($rows) === 0): ?>
<tr>
<td colspan="9" class="p-8 text-center text-gray-400">데이터가 없습니다.</td>
</tr>
<?php else: ?>
<?php $idx = 1;
foreach ($rows as $row): ?>
<tr class="hover:bg-gray-50 transition cursor-pointer"
onclick="openDetailModal('<?= htmlspecialchars($row['sys_comp_code']) ?>', '<?= htmlspecialchars($row['member_id']) ?>', '<?= htmlspecialchars($row['name']) ?>', '<?= htmlspecialchars($row['dept_name']) ?>')">
<td class="p-4 text-center text-gray-500"><?= $idx++ ?></td>
<td class="p-4 text-gray-500"><?= htmlspecialchars($row['comp_name'] ?? '') ?></td>
<td class="p-4 font-bold text-gray-800"><?= htmlspecialchars($row['name'] ?? '') ?></td>
<td class="p-4 text-gray-500"><?= htmlspecialchars($row['member_id'] ?? '') ?></td>
<td class="p-4 text-gray-500"><?= htmlspecialchars($row['dept_name'] ?? '') ?></td>
<td class="p-4 text-gray-500"><?= htmlspecialchars($row['all_tm'] ?? '') ?></td>
<td class="p-4">
<div class="flex items-center space-x-2">
<?php
$progress = $row['progress_rate'] ?? '0%';
$progress_value = is_numeric($progress) ? (int) $progress : (int) preg_replace('/[^0-9]/', '', $progress);
$progress_value = min(100, max(0, $progress_value));
?>
<div class="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div class="bg-teal-500 h-full" style="width: <?= $progress_value ?>%;"></div>
</div>
<span class="text-[10px] font-bold text-teal-600"><?= $progress_value ?>%</span>
</div>
</td>
<td class="p-4 text-gray-600"><?= htmlspecialchars($row['completion_date'] ?? '-') ?></td>
<td class="p-4 text-center">
<?php if ($row['completion_status'] === '수료'): ?>
<span
class="px-3 py-1 bg-green-50 text-green-600 border border-green-100 rounded-full text-[11px] font-bold"><i
class="fa-solid fa-check-circle mr-1"></i>수료</span>
<?php else: ?>
<span class="px-3 py-1 bg-red-50 text-red-600 border border-red-100 rounded-full text-[11px] font-bold"><i
class="fa-solid fa-circle-xmark mr-1"></i>미수료</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
</main>
<!-- 상세 데이터 (G2) 모달 팝업 -->
<div id="detail-modal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-[100] hidden p-4">
<div class="bg-white w-full max-w-4xl rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div class="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50 shrink-0">
<h3 class="text-xl font-bold text-gray-800">법정의무교육 이수 상세</h3>
<button onclick="closeDetailModal()" class="text-gray-400 hover:text-gray-600"><i
class="fa-solid fa-xmark text-xl"></i></button>
</div>
<div class="p-4 bg-white border-b border-gray-100 flex gap-6 text-sm font-bold text-gray-600 shrink-0">
<div>성명: <span id="modal-name" class="text-gray-900"></span></div>
<div>사번: <span id="modal-member-id" class="text-gray-900"></span></div>
<div>부서명: <span id="modal-dept" class="text-gray-900"></span></div>
</div>
<div class="flex-1 overflow-auto p-4 bg-gray-50/50">
<table class="w-full text-left border-collapse bg-white border border-gray-200">
<thead class="bg-gray-50 border-b border-gray-200 text-gray-500 font-bold">
<tr>
<th class="p-3 text-center w-12"><input type="checkbox" id="chk-all-certs" onchange="toggleAllDetailCerts(this)" checked class="w-4 h-4 text-teal-600 border-gray-300 rounded focus:ring-teal-500"></th>
<th class="p-3 text-center w-12">NO</th>
<th class="p-3">교육과정명</th>
<th class="p-3 w-32">학습시간</th>
<th class="p-3 w-32">진도율</th>
<th class="p-3 w-32">학습완료일</th>
<th class="p-3 w-28 text-center">수료구분</th>
</tr>
</thead>
<tbody id="detail-grid-body" class="divide-y divide-gray-100 text-sm">
<!-- AJAX JS INJECTION -->
</tbody>
</table>
</div>
<div class="p-4 bg-gray-50 border-t border-gray-100 flex justify-between items-center shrink-0">
<button onclick="printSelectedCertificates()"
class="px-5 py-2 bg-[#114b3d] text-white rounded-lg font-bold shadow-md hover:bg-[#0d3a2f] transition flex items-center gap-2">
<i class="fa-solid fa-print"></i> 선택 수료증 일괄출력 (멀티출력)
</button>
<button onclick="closeDetailModal()"
class="px-6 py-2 bg-gray-500 text-white rounded-lg font-bold shadow-md">닫기</button>
</div>
</div>
</div>
<!-- 교육과정선택 (수료증 일괄출력) 모달 팝업 -->
<div id="course-select-modal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-[100] hidden p-4">
<div class="bg-white w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden flex flex-col">
<div class="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50 shrink-0">
<h3 class="text-xl font-bold text-gray-800"><i class="fa-solid fa-print text-[#114b3d] mr-2"></i>교육과정별 수료증 일괄출력</h3>
<button onclick="closeCourseSelectModal()" class="text-gray-400 hover:text-gray-600"><i
class="fa-solid fa-xmark text-xl"></i></button>
</div>
<div class="p-6 bg-white flex flex-col gap-5 text-sm shrink-0">
<!-- 고정 메타데이터 정보 -->
<div class="grid grid-cols-2 gap-4 bg-gray-50 p-4 rounded-xl border border-gray-100 font-medium text-gray-600">
<div>출력 기준년도: <span class="text-gray-900 font-bold"><?= htmlspecialchars($search_year) ?>년</span></div>
<div>출력 대상법인: <span class="text-gray-900 font-bold">
<?php
if ($search_comp === '') {
echo '전체 법인';
} else {
$comp_name_found = $search_comp;
foreach ($corp_list as $corp) {
if ($corp['code'] === $search_comp) {
$comp_name_found = $corp['name'];
break;
}
}
echo htmlspecialchars($comp_name_found);
}
?>
</span></div>
</div>
<!-- 과정 선택 영역 -->
<div class="flex flex-col gap-2">
<label class="block text-xs font-bold text-gray-500 uppercase">출력할 교육과정 선택</label>
<div class="flex flex-col gap-2.5 max-h-[300px] overflow-y-auto pr-1">
<?php if (empty($course_list)): ?>
<p class="text-gray-400 text-center py-4">조회 가능한 교육과정이 없습니다.</p>
<?php else: ?>
<?php foreach ($course_list as $idx => $course): ?>
<label class="flex items-center gap-3 p-3 border border-gray-100 rounded-xl hover:bg-gray-50 cursor-pointer transition">
<input type="radio" name="selected_course_group" value="<?= htmlspecialchars($course['code']) ?>" <?= $idx === 0 ? 'checked' : '' ?>
class="w-4 h-4 text-[#114b3d] border-gray-300 focus:ring-[#114b3d]">
<div class="flex flex-col">
<span class="font-bold text-gray-800"><?= htmlspecialchars($course['code_name']) ?></span>
<span class="text-xs text-gray-400">코드: <?= htmlspecialchars($course['code']) ?></span>
</div>
</label>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
<div class="p-4 bg-gray-50 border-t border-gray-100 flex justify-end gap-2 shrink-0">
<button onclick="closeCourseSelectModal()"
class="px-5 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg font-bold transition">닫기</button>
<button onclick="printCourseCertificates()"
class="px-5 py-2 bg-[#114b3d] text-white rounded-lg font-bold shadow-md hover:bg-[#0d3a2f] transition flex items-center gap-2">
<i class="fa-solid fa-print"></i> 수료증 일괄출력
</button>
</div>
</div>
</div>
<script>
function downloadExcel() {
const form = document.getElementById('searchForm');
const urlParams = new URLSearchParams(new FormData(form)).toString();
window.location.href = '../bbs/legal_edu_excel.php?' + urlParams;
}
function toggleAllDetailCerts(master) {
const checkboxes = document.querySelectorAll('.cert-item-chk:not(:disabled)');
checkboxes.forEach(chk => {
chk.checked = master.checked;
});
}
function openDetailModal(sys_comp_code, member_id, name, dept) {
document.getElementById('modal-name').textContent = name;
document.getElementById('modal-member-id').textContent = member_id;
document.getElementById('modal-dept').textContent = dept;
// 선택 일괄출력을 위한 데이터 바인딩
const modal = document.getElementById('detail-modal');
modal.dataset.sysCompCode = sys_comp_code;
modal.dataset.memberId = member_id;
modal.classList.remove('hidden');
const gridBody = document.getElementById('detail-grid-body');
gridBody.innerHTML = '<tr><td colspan="7" class="p-6 text-center text-gray-500">로딩 중...</td></tr>';
const year = document.getElementById('year').value;
modal.dataset.year = year;
const requestUrl = `../bbs/get_legal_edu_detail.php?sys_comp_code=${encodeURIComponent(sys_comp_code)}&member_id=${encodeURIComponent(member_id)}&year=${encodeURIComponent(year)}`;
console.log('[G2] requestUrl', requestUrl, { sys_comp_code, member_id, year });
fetch(requestUrl)
.then(res => res.text())
.then(text => {
console.log('[G2] raw response', text);
let data;
try {
data = JSON.parse(text);
} catch (e) {
console.error('[G2] JSON parse error', e, text);
gridBody.innerHTML = '<tr><td colspan="7" class="p-6 text-center text-red-500">JSON 파싱 오류 발생했습니다.</td></tr>';
return;
}
gridBody.innerHTML = '';
if (!data.success || !data.items || data.items.length === 0) {
gridBody.innerHTML = '<tr><td colspan="7" class="p-6 text-center text-gray-400">학습 내역이 없습니다.</td></tr>';
return;
}
// 전체 체크박스 선택기 초기화
const chkAll = document.getElementById('chk-all-certs');
if (chkAll) chkAll.checked = true;
data.items.forEach((it, idx) => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50 transition';
const checkboxHtml = it.comp_status === '수료'
? `<input type="checkbox" class="cert-item-chk w-4 h-4 text-teal-600 border-gray-300 rounded focus:ring-teal-500" data-title="${escapeHtml(it.title)}" checked>`
: `<input type="checkbox" disabled class="w-4 h-4 border-gray-200 rounded cursor-not-allowed bg-gray-50 opacity-50">`;
const stHtml = it.comp_status === '수료'
? `<div class="flex flex-col items-center gap-1.5">
<span class="px-3 py-1 bg-green-50 text-green-600 border border-green-100 rounded-full text-[11px] font-bold"><i class="fa-solid fa-check-circle mr-1"></i>수료</span>
<button onclick="event.stopPropagation(); printCertificate('${year}', '${sys_comp_code}', '${member_id}', '${it.title}')" class="px-2 py-0.5 bg-[#114b3d] text-white rounded text-[10px] font-bold hover:bg-[#0d3a2f] transition flex items-center gap-1 shadow-sm"><i class="fa-solid fa-print"></i>수료증발급</button>
</div>`
: `<span class="px-3 py-1 bg-red-50 text-red-600 border border-red-100 rounded-full text-[11px] font-bold"><i class="fa-solid fa-circle-xmark mr-1"></i>미수료</span>`;
// progress_rate에서 숫자만 추출
let progressValue = 0;
if (it.progress_rate) {
const match = it.progress_rate.toString().match(/\d+/);
progressValue = match ? parseInt(match[0]) : 0;
}
progressValue = Math.min(100, Math.max(0, progressValue));
tr.innerHTML = `
<td class="p-3 text-center">${checkboxHtml}</td>
<td class="p-3 text-center text-gray-400">${idx + 1}</td>
<td class="p-3 font-bold text-gray-800">${escapeHtml(it.title)}</td>
<td class="p-3 text-gray-600">${escapeHtml(it.learn_time)}</td>
<td class="p-3">
<div class="flex items-center space-x-2">
<div class="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div class="bg-teal-500 h-full" style="width: ${progressValue}%;"></div>
</div>
<span class="text-[10px] font-bold text-teal-600">${progressValue}%</span>
</div>
</td>
<td class="p-3 text-gray-600">${escapeHtml(it.completion_date) || '-'}</td>
<td class="p-3 text-center">${stHtml}</td>
`;
gridBody.appendChild(tr);
});
})
.catch(err => {
console.error('데이터 로드 오류:', err);
gridBody.innerHTML = '<tr><td colspan="7" class="p-6 text-center text-red-500">데이터를 불러오는 중 오류가 발생했습니다.</td></tr>';
});
}
function printSelectedCertificates() {
const modal = document.getElementById('detail-modal');
const sysCompCode = modal.dataset.sysCompCode;
const memberId = modal.dataset.memberId;
const year = modal.dataset.year;
// 선택된 체크박스 가져오기
const checkedBoxes = document.querySelectorAll('.cert-item-chk:checked');
if (checkedBoxes.length === 0) {
alert('출력할 수료증 과정을 1개 이상 선택해 주세요.');
return;
}
const titles = Array.from(checkedBoxes).map(chk => chk.dataset.title);
const categoryMap = {
'개인정보보호': 'CA200C01',
'직장내 괴롭힘 예방': 'CA200C02',
'장애인 인식 개선': 'CA200C03',
'성희롱 예방 교육': 'CA200C04',
'퇴직금 교육': 'CA200C05',
'산업안전보건': 'CA200C06'
};
const categories = [];
titles.forEach(title => {
const code = categoryMap[title];
if (code) {
categories.push(code);
}
});
if (categories.length === 0) {
alert('선택한 과정 중 수료증 출력이 가능한 과정이 없습니다.');
return;
}
// 쉼표로 연결하여 멀티 파라미터 전달
const categoryGroup = categories.join(',');
const url = `legal_cert_print.php?year=${encodeURIComponent(year)}&comp=${encodeURIComponent(sysCompCode)}&member_id=${encodeURIComponent(memberId)}&category_group=${encodeURIComponent(categoryGroup)}`;
window.open(url, '_blank', 'width=950,height=1000,scrollbars=yes');
}
function printCertificate(year, comp, memberId, title) {
const categoryMap = {
'개인정보보호': 'CA200C01',
'직장내 괴롭힘 예방': 'CA200C02',
'장애인 인식 개선': 'CA200C03',
'성희롱 예방 교육': 'CA200C04',
'퇴직금 교육': 'CA200C05',
'산업안전보건': 'CA200C06'
};
const categoryGroup = categoryMap[title] || '';
if (!categoryGroup) {
alert('이 교육과정은 수료증 출력을 지원하지 않습니다.');
return;
}
const url = `legal_cert_print.php?year=${encodeURIComponent(year)}&comp=${encodeURIComponent(comp)}&member_id=${encodeURIComponent(memberId)}&category_group=${encodeURIComponent(categoryGroup)}`;
window.open(url, '_blank', 'width=950,height=1000,scrollbars=yes');
}
function closeDetailModal() {
document.getElementById('detail-modal').classList.add('hidden');
}
function openCourseSelectModal() {
document.getElementById('course-select-modal').classList.remove('hidden');
}
function closeCourseSelectModal() {
document.getElementById('course-select-modal').classList.add('hidden');
}
function printCourseCertificates() {
const checkedRadio = document.querySelector('input[name="selected_course_group"]:checked');
if (!checkedRadio) {
alert('출력할 교육과정을 선택해 주세요.');
return;
}
const categoryGroup = checkedRadio.value;
const year = document.getElementById('year').value || '<?= htmlspecialchars($search_year) ?>';
const comp = '<?= htmlspecialchars($search_comp) ?>';
const url = `legal_cert_print.php?year=${encodeURIComponent(year)}&comp=${encodeURIComponent(comp)}&member_id=ALL&category_group=${encodeURIComponent(categoryGroup)}`;
window.open(url, '_blank', 'width=950,height=1000,scrollbars=yes');
closeCourseSelectModal();
}
function sendIncompleteNotification() {
const incompleteCount = <?php echo $incomplete_qty; ?>;
if (incompleteCount === 0) {
alert('미수료자가 없습니다.');
return;
}
if (!confirm(`<?php echo $message_name; ?> \n"위의 메시지로발송됩니다."\n미수료자 ${incompleteCount}명에게 알림을 발송하시겠습니까?`)) {
return;
}
// 현재 날짜 + 14일 계산
const today = new Date();
const endDate = new Date(today);
endDate.setDate(today.getDate() + 14);
const endDateStr = endDate.toISOString().split('T')[0];
// 알림 발송 요청
fetch('../bbs/notification_send.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
code: '100',
end_date: endDateStr,
action: 'send'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`미수료자 알림이 성공적으로 발송되었습니다.\n발송 대상: ${data.sent_count || incompleteCount}명`);
} else {
alert(`알림 발송에 실패했습니다: ${data.message || '알 수 없는 오류'}`);
}
})
.catch(err => {
console.error('알림 발송 오류:', err);
alert('알림 발송 중 오류가 발생했습니다.');
});
}
function downloadExcel() {
const params = new URLSearchParams({
comp: document.getElementById('comp').value || '',
year: document.getElementById('year').value || '',
dept: document.getElementById('dept').value || '',
name: document.getElementById('name').value || '',
comp_status: document.getElementById('comp_status').value || ''
});
window.location.href = `../bbs/legal_edu_excel.php?${params.toString()}`;
}
function downloadReportExcel() {
const params = new URLSearchParams({
comp: document.getElementById('comp').value || '',
year: document.getElementById('year').value || ''
});
window.location.href = `../bbs/legal_edu_report_excel.php?${params.toString()}`;
}
function escapeHtml(unsafe) {
return (unsafe || '').toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
</script>
</body>
</html>
+492
View File
@@ -0,0 +1,492 @@
<?php
require_once __DIR__ . '/../../bbs/auth.php';
edu_require_login();
include_once 'header.php';
require_once __DIR__ . '/../../bbs/db_conn.php';
$search_comp = $_GET['comp'] ?? '';
// 만약 사용자가 처음 페이지에 들어왔거나(GET값이 없음), '전체'를 누른 게 아니라면 초기값 설정
// 소속회사(comp)도 동일한 메커니즘 적용
$search_comp = $_GET['comp'] ?? '';
if (empty($search_comp) && !isset($_GET['comp'])) {
$search_comp = $sys_comp_code;
}
$search_year = $_GET['year'] ?? date('Y');
$search_dept = $_GET['dept'] ?? '';
$search_name = $_GET['name'] ?? '';
$search_comp_status = $_GET['comp_status'] ?? '';
$message_name = '';
$all_user_qty = 0;
$completed_qty = 0;
$incomplete_qty = 0;
$corp_list = [];
$rows = [];
try {
$pdo = db_conn();
// 1. 프로시저 호입 후 nextRowset으로 부분적 result set을 완전 소진
try {
$stmt_corp = $pdo->query("CALL proc_get_code2_list('CO100')");
$corp_list = $stmt_corp->fetchAll(PDO::FETCH_ASSOC);
while ($stmt_corp->nextRowset()) {
}
unset($stmt_corp);
} catch (Exception $eProc) {
$corp_list = [];
}
// 권한에 따른 법인 목록 제어
// LE10001: 전체권한 → 전체 법인 표시 (corp_list 그대로)
// LE10002: 법인권한 → 본인 법인($sys_comp_code)만 표시, 검색값도 고정
// 그 외: 법인 목록 없음
// 법인 초기값은 로그인한 사용자의 법인으로 설정
$message_name = $pdo->prepare("SELECT DESC01 FROM edu_codes WHERE base_code = 'AL100100'");
$message_name->execute();
$message_name = $message_name->fetchColumn();
if ($auth_level === 'LE10002') {
// 본인 법인만 필터링
$corp_list = array_filter($corp_list, fn($c) => $c['code'] === $sys_comp_code);
$corp_list = array_values($corp_list);
// 검색 법인도 강제 고정
$search_comp = $sys_comp_code;
} elseif ($auth_level !== 'LE10001') {
// 그 외 권한: 법인 목록 비움
$corp_list = [];
}
// 2. 전체 대상자 수 로그인한 법인과 소속회사가 같은 기준으로 계산, 퇴사자 제외
$stmt_all = $pdo->prepare("SELECT COUNT(DISTINCT member_id)
FROM edu_users
WHERE (end_date IS NULL OR (end_date > '1000-01-01' AND YEAR(end_date) >= ?))
AND (? = '' OR belong_comp = ?)
and sys_comp_code = belong_comp");
$stmt_all->execute([$search_year, $search_comp, $search_comp]);
$all_user_qty = (int) $stmt_all->fetchColumn();
// 3. 미수료 인원
$stmt_incomp = $pdo->prepare("SELECT COUNT(DISTINCT u.member_id)
FROM edu_users u
WHERE (u.end_date IS NULL OR (u.end_date > '1000-01-01' AND YEAR(u.end_date) >= ?))
AND (? = '' OR u.belong_comp = ?)
AND sys_comp_code = belong_comp
AND fn_get_progress_rate(u.sys_comp_code,?,u.member_id,'CA10003','') != 100");
$stmt_incomp->execute([$search_year, $search_comp, $search_comp, $search_year]);
$incomplete_qty = (int) $stmt_incomp->fetchColumn();
$completed_qty = max(0, $all_user_qty - $incomplete_qty);
// 4. G1 메인 쿼리 (? 위치 파라미터 사용으로 재사용 문제 없음)
$sql_inner = "SELECT a.sys_comp_code, a.belong_comp
, (SELECT code_name FROM edu_codes c WHERE c.group_code = 'CO100' AND c.code = a.belong_comp LIMIT 1) AS comp_name
, a.name, a.member_id
, a.dept_name
, IFNULL(b.formatted_tm, '00시간 00분') AS all_tm
, CASE WHEN a.member_id IN (
SELECT u2.member_id FROM edu_users u2 WHERE NOT EXISTS (
SELECT 1 FROM edu_contents c2 WHERE c2.category_code = 'CA10003' AND c2.base_year = ? AND c2.is_active = '1'
AND NOT EXISTS (SELECT 1 FROM edu_learning_histories h2 WHERE h2.content_id = c2.content_id AND h2.member_id = u2.member_id AND h2.sys_comp_code = u2.sys_comp_code AND h2.completed_at IS NOT NULL AND h2.completed_at != '')
)
) THEN '수료' ELSE '미수료' END AS completion_status
, fn_get_progress_rate(a.sys_comp_code, ?, a.member_id, 'CA10003', '') AS progress_rate -- 진행율
, fn_get_completion_date(a.sys_comp_code, ?, a.member_id, 'CA10003', '') AS completion_date -- 학습완료일
FROM edu_users a
LEFT JOIN (
SELECT
t.sys_comp_code,
t.member_id,
CONCAT(
LPAD(FLOOR(SUM(t.calc_tm)/3600), 2, '0'), '시간 ',
LPAD(FLOOR((SUM(t.calc_tm)%3600)/60), 2, '0'), '분'
) AS formatted_tm
FROM (
SELECT
z.sys_comp_code,
x.member_id,
CASE
WHEN x.completed_at IS NOT NULL AND x.completed_at <> '' THEN x.content_tm
ELSE x.watch_tm
END AS calc_tm
FROM edu_learning_histories x
JOIN edu_contents y ON x.content_id = y.content_id
JOIN edu_users z ON x.sys_comp_code = z.working_comp AND x.member_id = z.member_id
WHERE y.category_code = 'CA10003'
AND YEAR(x.first_viewed_at) = ?
) t
GROUP BY t.sys_comp_code, t.member_id
) b ON a.member_id = b.member_id AND a.sys_comp_code = b.sys_comp_code
WHERE (a.end_date IS NULL OR a.end_date = '' OR (a.end_date > '1000-01-01' AND YEAR(a.end_date) >= ?))
and a.sys_comp_code = a.belong_comp
AND (? = '' OR a.belong_comp = ?)
AND a.dept_name LIKE CONCAT('%', ?, '%')
AND a.name LIKE CONCAT('%', ?, '%')";
if ($search_comp_status === 'Y') {
$sql = "SELECT * FROM ($sql_inner) t WHERE completion_status = '수료'";
} elseif ($search_comp_status === 'N') {
$sql = "SELECT * FROM ($sql_inner) t WHERE completion_status = '미수료'";
} else {
$sql = "SELECT * FROM ($sql_inner) t";
}
$stmt_g1 = $pdo->prepare($sql);
// ? 순서: 1=completion_status(base_year), 2=progress_rate(year), 3=completion_date(year), 4=LEFT JOIN(first_viewed_at), 5=WHERE(end_date), 6=belong_comp 체크, 7=belong_comp 필터, 8=dept_name, 9=name
$stmt_g1->execute([$search_year, $search_year, $search_year, $search_year, $search_year, $search_comp, $search_comp, $search_dept, $search_name]);
$rows = $stmt_g1->fetchAll(PDO::FETCH_ASSOC);
} catch (Exception $e) {
$db_error = $e->getMessage();
}
?>
<main class="max-w-[1600px] mx-auto p-6">
<header class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<h2 class="text-2xl font-bold text-gray-800 italic">법정의무교육</h2>
<div class="flex flex-wrap gap-2">
<button onclick="alert('준비중입니다.')"
class="px-4 py-2 bg-white border border-gray-200 rounded-md text-sm font-medium hover:bg-gray-50 flex items-center shadow-sm">
<i class="fa-solid fa-file-excel mr-2"></i>교육결과보고서
</button>
<button onclick="sendIncompleteNotification()"
class="px-4 py-2 bg-red-50 text-red-600 border border-red-100 rounded-md text-sm font-bold flex items-center hover:bg-red-100 shadow-sm transition">
<i class="fa-solid fa-bell mr-2"></i>미수료자 알림 (<?php echo $incomplete_qty; ?>명)
</button>
<button onclick="downloadExcel()"
class="px-4 py-2 bg-gray-100 text-gray-600 rounded-md text-sm font-medium hover:bg-gray-200 flex items-center transition">
<i class="fa-solid fa-download mr-2"></i>다운로드
</button>
</div>
</header>
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
<p class="text-xs font-bold text-gray-400 mb-1">전체 대상자</p>
<p class="text-3xl font-bold text-gray-800"><?php echo $all_user_qty; ?></p>
</div>
<div class="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
<p class="text-xs font-bold text-gray-400 mb-1">수료 완료</p>
<p class="text-3xl font-bold text-teal-600"><?php echo $completed_qty; ?></p>
</div>
<div class="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
<p class="text-xs font-bold text-gray-400 mb-1">미수료</p>
<p class="text-3xl font-bold text-red-500"><?php echo $incomplete_qty; ?>명</p>
</div>
</section>
<!-- 검색 조건 폼 -->
<form id="searchForm" method="GET" action="legal_edu.php"
class="bg-white p-5 rounded-xl border border-gray-200 shadow-sm mb-6 flex flex-wrap md:flex-row gap-4 items-end">
<div class="flex-1 min-w-[120px]">
<label for="comp" class="block text-xs font-bold text-gray-500 mb-2">법인 선택</label>
<?php if ($auth_level === 'LE10002'): ?>
<?php
// LE10002: 본인 법인명 표시 (변경 불가)
$fixed_corp_name = !empty($corp_list) ? htmlspecialchars($corp_list[0]['name']) : htmlspecialchars($sys_comp_code);
?>
<!-- 실제 전송값은 hidden으로, UI는 고정 텍스트로 표시 -->
<input type="hidden" name="comp" value="<?= htmlspecialchars($sys_comp_code) ?>">
<input type="text" value="<?= $fixed_corp_name ?>" readonly
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-100 text-gray-600 cursor-not-allowed">
<?php else: ?>
<select id="comp" name="comp" onchange="this.form.submit()"
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-50">
<option value="">전체</option>
<?php foreach ($corp_list as $corp): ?>
<option value="<?= htmlspecialchars($corp['code']) ?>" <?= $search_comp === $corp['code'] ? 'selected' : '' ?>>
<?= htmlspecialchars($corp['name']) ?>
</option>
<?php endforeach; ?>
</select>
<?php endif; ?>
</div>
<div class="flex-1 min-w-[100px]">
<label for="year" class="block text-xs font-bold text-gray-500 mb-2">기준년도</label>
<input type="text" id="year" name="year" value="<?= htmlspecialchars($search_year) ?>" placeholder="YYYY"
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-50"
onkeydown="if(event.key==='Enter') this.form.submit();">
</div>
<div class="flex-1 min-w-[120px]">
<label for="dept" class="block text-xs font-bold text-gray-500 mb-2">부서</label>
<input type="text" id="dept" name="dept" value="<?= htmlspecialchars($search_dept) ?>"
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-50"
onkeydown="if(event.key==='Enter') this.form.submit();">
</div>
<div class="flex-1 min-w-[100px]">
<label for="name" class="block text-xs font-bold text-gray-500 mb-2">성명</label>
<input type="text" id="name" name="name" value="<?= htmlspecialchars($search_name) ?>"
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-50"
onkeydown="if(event.key==='Enter') this.form.submit();">
</div>
<div class="flex-1 min-w-[120px]">
<label for="comp_status" class="block text-xs font-bold text-gray-500 mb-2">이수여부</label>
<select id="comp_status" name="comp_status" onchange="this.form.submit()"
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-50">
<option value="">전체</option>
<option value="Y" <?= $search_comp_status === 'Y' ? 'selected' : '' ?>>이수</option>
<option value="N" <?= $search_comp_status === 'N' ? 'selected' : '' ?>>미이수</option>
</select>
</div>
</form>
<section class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mb-12">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="bg-gray-50 border-b border-gray-200 text-gray-500 font-bold italic">
<tr>
<th class="p-4 w-16 text-center">NO</th>
<th class="p-4">소속법인</th>
<th class="p-4">성명</th>
<th class="p-4">사번</th>
<th class="p-4">부서</th>
<th class="p-4">학습시간</th>
<th class="p-4 w-48">진도율</th>
<th class="p-4">교육이수일</th>
<th class="p-4 text-center">수료구분</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<?php if (count($rows) === 0): ?>
<tr>
<td colspan="9" class="p-8 text-center text-gray-400">데이터가 없습니다.</td>
</tr>
<?php else: ?>
<?php $idx = 1;
foreach ($rows as $row): ?>
<tr class="hover:bg-gray-50 transition cursor-pointer"
onclick="openDetailModal('<?= htmlspecialchars($row['sys_comp_code']) ?>', '<?= htmlspecialchars($row['member_id']) ?>', '<?= htmlspecialchars($row['name']) ?>', '<?= htmlspecialchars($row['dept_name']) ?>')">
<td class="p-4 text-center text-gray-500"><?= $idx++ ?></td>
<td class="p-4 text-gray-500"><?= htmlspecialchars($row['comp_name'] ?? '') ?></td>
<td class="p-4 font-bold text-gray-800"><?= htmlspecialchars($row['name'] ?? '') ?></td>
<td class="p-4 text-gray-500"><?= htmlspecialchars($row['member_id'] ?? '') ?></td>
<td class="p-4 text-gray-500"><?= htmlspecialchars($row['dept_name'] ?? '') ?></td>
<td class="p-4 text-gray-500"><?= htmlspecialchars($row['all_tm'] ?? '') ?></td>
<td class="p-4">
<div class="flex items-center space-x-2">
<?php
$progress = $row['progress_rate'] ?? '0%';
$progress_value = is_numeric($progress) ? (int) $progress : (int) preg_replace('/[^0-9]/', '', $progress);
$progress_value = min(100, max(0, $progress_value));
?>
<div class="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div class="bg-teal-500 h-full" style="width: <?= $progress_value ?>%;"></div>
</div>
<span class="text-[10px] font-bold text-teal-600"><?= $progress_value ?>%</span>
</div>
</td>
<td class="p-4 text-gray-600"><?= htmlspecialchars($row['completion_date'] ?? '-') ?></td>
<td class="p-4 text-center">
<?php if ($row['completion_status'] === '수료'): ?>
<span
class="px-3 py-1 bg-green-50 text-green-600 border border-green-100 rounded-full text-[11px] font-bold"><i
class="fa-solid fa-check-circle mr-1"></i>수료</span>
<?php else: ?>
<span class="px-3 py-1 bg-red-50 text-red-600 border border-red-100 rounded-full text-[11px] font-bold"><i
class="fa-solid fa-circle-xmark mr-1"></i>미수료</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
</main>
<!-- 상세 데이터 (G2) 모달 팝업 -->
<div id="detail-modal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-[100] hidden p-4">
<div class="bg-white w-full max-w-4xl rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div class="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50 shrink-0">
<h3 class="text-xl font-bold text-gray-800">법정의무교육 이수 상세</h3>
<button onclick="closeDetailModal()" class="text-gray-400 hover:text-gray-600"><i
class="fa-solid fa-xmark text-xl"></i></button>
</div>
<div class="p-4 bg-white border-b border-gray-100 flex gap-6 text-sm font-bold text-gray-600 shrink-0">
<div>성명: <span id="modal-name" class="text-gray-900"></span></div>
<div>사번: <span id="modal-member-id" class="text-gray-900"></span></div>
<div>부서명: <span id="modal-dept" class="text-gray-900"></span></div>
</div>
<div class="flex-1 overflow-auto p-4 bg-gray-50/50">
<table class="w-full text-left border-collapse bg-white border border-gray-200">
<thead class="bg-gray-50 border-b border-gray-200 text-gray-500 font-bold">
<tr>
<th class="p-3 text-center w-12">NO</th>
<th class="p-3">교육과정명</th>
<th class="p-3 w-32">학습시간</th>
<th class="p-3 w-32">진도율</th>
<th class="p-3 w-32">학습완료일</th>
<th class="p-3 w-28 text-center">수료구분</th>
</tr>
</thead>
<tbody id="detail-grid-body" class="divide-y divide-gray-100 text-sm">
<!-- AJAX JS INJECTION -->
</tbody>
</table>
</div>
<div class="p-4 bg-gray-50 border-t border-gray-100 flex justify-end shrink-0">
<button onclick="closeDetailModal()"
class="px-6 py-2 bg-gray-500 text-white rounded-lg font-bold shadow-lg">닫기</button>
</div>
</div>
</div>
<script>
function downloadExcel() {
const form = document.getElementById('searchForm');
const urlParams = new URLSearchParams(new FormData(form)).toString();
window.location.href = '../bbs/legal_edu_excel.php?' + urlParams;
}
function openDetailModal(sys_comp_code, member_id, name, dept) {
document.getElementById('modal-name').textContent = name;
document.getElementById('modal-member-id').textContent = member_id;
document.getElementById('modal-dept').textContent = dept;
document.getElementById('detail-modal').classList.remove('hidden');
const gridBody = document.getElementById('detail-grid-body');
gridBody.innerHTML = '<tr><td colspan="6" class="p-6 text-center text-gray-500">로딩 중...</td></tr>';
const year = document.getElementById('year').value;
const requestUrl = `../bbs/get_legal_edu_detail.php?sys_comp_code=${encodeURIComponent(sys_comp_code)}&member_id=${encodeURIComponent(member_id)}&year=${encodeURIComponent(year)}`;
console.log('[G2] requestUrl', requestUrl, { sys_comp_code, member_id, year });
fetch(requestUrl)
.then(res => res.text())
.then(text => {
console.log('[G2] raw response', text);
let data;
try {
data = JSON.parse(text);
} catch (e) {
console.error('[G2] JSON parse error', e, text);
gridBody.innerHTML = '<tr><td colspan="6" class="p-6 text-center text-red-500">JSON 파싱 오류 발생했습니다.</td></tr>';
return;
}
gridBody.innerHTML = '';
if (!data.success || !data.items || data.items.length === 0) {
gridBody.innerHTML = '<tr><td colspan="6" class="p-6 text-center text-gray-400">학습 내역이 없습니다.</td></tr>';
return;
}
data.items.forEach((it, idx) => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50 transition';
const stHtml = it.comp_status === '수료'
? `<span class="px-3 py-1 bg-green-50 text-green-600 border border-green-100 rounded-full text-[11px] font-bold"><i class="fa-solid fa-check-circle mr-1"></i>수료</span>`
: `<span class="px-3 py-1 bg-red-50 text-red-600 border border-red-100 rounded-full text-[11px] font-bold"><i class="fa-solid fa-circle-xmark mr-1"></i>미수료</span>`;
// progress_rate에서 숫자만 추출
let progressValue = 0;
if (it.progress_rate) {
const match = it.progress_rate.toString().match(/\d+/);
progressValue = match ? parseInt(match[0]) : 0;
}
progressValue = Math.min(100, Math.max(0, progressValue));
tr.innerHTML = `
<td class="p-3 text-center text-gray-400">${idx + 1}</td>
<td class="p-3 font-bold text-gray-800">${escapeHtml(it.title)}</td>
<td class="p-3 text-gray-600">${escapeHtml(it.learn_time)}</td>
<td class="p-3">
<div class="flex items-center space-x-2">
<div class="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div class="bg-teal-500 h-full" style="width: ${progressValue}%;"></div>
</div>
<span class="text-[10px] font-bold text-teal-600">${progressValue}%</span>
</div>
</td>
<td class="p-3 text-gray-600">${escapeHtml(it.completion_date) || '-'}</td>
<td class="p-3 text-center">${stHtml}</td>
`;
gridBody.appendChild(tr);
});
})
.catch(err => {
console.error('데이터 로드 오류:', err);
gridBody.innerHTML = '<tr><td colspan="6" class="p-6 text-center text-red-500">데이터를 불러오는 중 오류가 발생했습니다.</td></tr>';
});
}
function closeDetailModal() {
document.getElementById('detail-modal').classList.add('hidden');
}
function sendIncompleteNotification() {
const incompleteCount = <?php echo $incomplete_qty; ?>;
if (incompleteCount === 0) {
alert('미수료자가 없습니다.');
return;
}
if (!confirm(`<?php echo $message_name; ?> \n"위의 메시지로발송됩니다."\n미수료자 ${incompleteCount}명에게 알림을 발송하시겠습니까?`)) {
return;
}
// 현재 날짜 + 14일 계산
const today = new Date();
const endDate = new Date(today);
endDate.setDate(today.getDate() + 14);
const endDateStr = endDate.toISOString().split('T')[0];
// 알림 발송 요청
fetch('../bbs/notification_send.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
code: '100',
end_date: endDateStr,
action: 'send'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`미수료자 알림이 성공적으로 발송되었습니다.\n발송 대상: ${data.sent_count || incompleteCount}명`);
} else {
alert(`알림 발송에 실패했습니다: ${data.message || '알 수 없는 오류'}`);
}
})
.catch(err => {
console.error('알림 발송 오류:', err);
alert('알림 발송 중 오류가 발생했습니다.');
});
}
function downloadExcel() {
const params = new URLSearchParams({
comp: document.getElementById('comp').value || '',
year: document.getElementById('year').value || '',
dept: document.getElementById('dept').value || '',
name: document.getElementById('name').value || '',
comp_status: document.getElementById('comp_status').value || ''
});
window.location.href = `../bbs/legal_edu_excel.php?${params.toString()}`;
}
function escapeHtml(unsafe) {
return (unsafe || '').toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
</script>
</body>
</html>
+249
View File
@@ -0,0 +1,249 @@
<?php
require_once __DIR__ . '/../../bbs/auth.php';
edu_require_login();
include_once 'header.php';
require_once __DIR__ . '/../../bbs/db_conn.php';
$search_year = $_GET['year'] ?? date('Y');
$search_sys_comp = $_GET['sys_comp'] ?? '';
// 만약 사용자가 처음 페이지에 들어왔거나(GET값이 없음), '전체'를 누른 게 아니라면 초기값 설정
if (empty($search_sys_comp) && !isset($_GET['sys_comp'])) {
$search_sys_comp = $sys_comp_code;
}
$search_comp = $_GET['comp'] ?? '';
$search_dept = $_GET['dept'] ?? '';
$search_text = $_GET['search_text'] ?? '';
$search_quarter = $_GET['quarter'] ?? '';
$corp_list = [];
$rows = [];
try {
$pdo = db_conn();
// 법인 리스트 가져오기
try {
$stmt_corp = $pdo->query("CALL proc_get_code2_list('CO100')");
$corp_list = $stmt_corp->fetchAll(PDO::FETCH_ASSOC);
while ($stmt_corp->nextRowset()) {}
unset($stmt_corp);
} catch (Exception $eProc) {
$corp_list = [];
}
// 권한에 따른 법인 목록 제어
// LE10001: 전체권한 → 전체 법인 표시
// LE10002: 법인권한 → 본인 법인($sys_comp_code)만 표시, 검색값도 강제 고정
// 그 외: 법인 목록 비움
// 기준법인 초기값은 로그인한 사용자의 법인으로 설정
if ($auth_level === 'LE10002') {
$corp_list = array_values(array_filter($corp_list, fn($c) => $c['code'] === $sys_comp_code));
$search_sys_comp = $sys_comp_code;
$search_comp = $sys_comp_code;
} elseif ($auth_level !== 'LE10001') {
$corp_list = [];
}
// 프로시저 호출로 데이터 가져오기
$stmt = $pdo->prepare("CALL proc_get_learner_status(?, ?, ?, ?, ?, ?)");
$stmt->execute([$search_year, $search_sys_comp, $search_comp, $search_dept, $search_text, $search_quarter]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
while ($stmt->nextRowset()) {}
unset($stmt);
} catch (Exception $e) {
$db_error = $e->getMessage();
}
?>
<main class="max-w-[1600px] mx-auto p-6">
<header class="mb-6">
<h2 class="text-2xl font-bold text-gray-800 italic">학습자관리</h2>
</header>
<section class="bg-white p-6 rounded-xl border border-gray-200 shadow-sm mb-6">
<form id="searchForm" method="GET" action="member_list.php"
class="grid grid-cols-1 md:grid-cols-6 gap-4">
<div>
<label class="block text-xs font-bold text-gray-500 mb-2">기준년도</label>
<input type="text" id="year" name="year" value="<?= htmlspecialchars($search_year) ?>" placeholder="YYYY"
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-50"
onkeydown="if(event.key==='Enter') this.form.submit();">
</div>
<div>
<label class="block text-xs font-bold text-gray-500 mb-2">기준법인</label>
<?php if ($auth_level === 'LE10002'): ?>
<?php $fixed_corp_name = !empty($corp_list) ? htmlspecialchars($corp_list[0]['name']) : htmlspecialchars($sys_comp_code); ?>
<input type="hidden" name="sys_comp" value="<?= htmlspecialchars($sys_comp_code) ?>">
<input type="text" value="<?= $fixed_corp_name ?>" readonly
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-100 text-gray-600 cursor-not-allowed">
<?php else: ?>
<select id="sys_comp" name="sys_comp" onchange="this.form.submit()"
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-50">
<option value="">전체</option>
<?php foreach ($corp_list as $corp): ?>
<option value="<?= htmlspecialchars($corp['code']) ?>" <?= $search_sys_comp === $corp['code'] ? 'selected' : '' ?>>
<?= htmlspecialchars($corp['name']) ?>
</option>
<?php endforeach; ?>
</select>
<?php endif; ?>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 mb-2">소속회사</label>
<?php if ($auth_level === 'LE10002'): ?>
<input type="hidden" name="comp" value="<?= htmlspecialchars($sys_comp_code) ?>">
<input type="text" value="<?= $fixed_corp_name ?>" readonly
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-100 text-gray-600 cursor-not-allowed">
<?php else: ?>
<select id="comp" name="comp" onchange="this.form.submit()"
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-50">
<option value="">전체</option>
<?php foreach ($corp_list as $corp): ?>
<option value="<?= htmlspecialchars($corp['code']) ?>" <?= $search_comp === $corp['code'] ? 'selected' : '' ?>>
<?= htmlspecialchars($corp['name']) ?>
</option>
<?php endforeach; ?>
</select>
<?php endif; ?>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 mb-2">부서명</label>
<input type="text" id="dept" name="dept" value="<?= htmlspecialchars($search_dept) ?>"
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-50"
onkeydown="if(event.key==='Enter') this.form.submit();">
</div>
<div>
<label class="block text-xs font-bold text-gray-500 mb-2">분기</label>
<select id="quarter" name="quarter" onchange="this.form.submit()"
class="w-full border-gray-200 rounded-lg text-sm p-2.5 bg-gray-50">
<option value="">전체</option>
<option value="CA200Q01" <?= $search_quarter === 'CA200Q01' ? 'selected' : '' ?>>1분기</option>
<option value="CA200Q02" <?= $search_quarter === 'CA200Q02' ? 'selected' : '' ?>>2분기</option>
<option value="CA200Q03" <?= $search_quarter === 'CA200Q03' ? 'selected' : '' ?>>3분기</option>
<option value="CA200Q04" <?= $search_quarter === 'CA200Q04' ? 'selected' : '' ?>>4분기</option>
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 mb-2">성명/사번</label>
<div class="relative">
<input type="text" id="search_text" name="search_text" value="<?= htmlspecialchars($search_text) ?>" placeholder="성명 또는 사번"
class="w-full border-gray-200 rounded-lg text-sm p-2.5 pl-8 bg-gray-50"
onkeydown="if(event.key==='Enter') this.form.submit();">
<i class="fa-solid fa-magnifying-glass absolute left-3 top-3 text-gray-400 text-xs"></i>
</div>
</div>
</form>
</section>
<section class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mb-12">
<div class="overflow-x-auto">
<?php if (isset($db_error)): ?>
<div class="p-4 bg-red-50 border border-red-200 rounded-md">
<p class="text-red-600 text-sm">데이터베이스 오류: <?= htmlspecialchars($db_error) ?></p>
</div>
<?php endif; ?>
<table class="w-full text-sm text-left">
<thead class="bg-gray-50 border-b border-gray-200 text-gray-500 font-bold">
<tr>
<th class="p-4">NO</th>
<th class="p-4">사번</th>
<th class="p-4">성명</th>
<th class="p-4">소속법인</th>
<th class="p-4">근무법인</th>
<th class="p-4">부서</th>
<th class="p-4 w-40">마이클래스</th>
<th class="p-4 w-40">법정의무교육</th>
<th class="p-4">학습시간</th>
<th class="p-4">최근 접속일</th>
<th class="p-4 text-center">학습 레벨</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<?php if (count($rows) === 0): ?>
<tr>
<td colspan="11" class="p-8 text-center text-gray-400">데이터가 없습니다.</td>
</tr>
<?php else: ?>
<?php foreach ($rows as $row): ?>
<tr class="hover:bg-blue-50/30 transition">
<td class="p-4 text-gray-500 font-medium"><?= htmlspecialchars($row['no'] ?? '') ?></td>
<td class="p-4 text-gray-500 font-medium"><?= htmlspecialchars($row['member_id'] ?? '') ?></td>
<td class="p-4 font-bold text-gray-800"><?= htmlspecialchars($row['name'] ?? '') ?></td>
<td class="p-4"><?= htmlspecialchars($row['belong_name'] ?? '') ?></td>
<td class="p-4"><?= htmlspecialchars($row['working_name'] ?? '') ?></td>
<td class="p-4"><?= htmlspecialchars($row['dept_name'] ?? '') ?></td>
<td class="p-4">
<div class="flex items-center space-x-2">
<div class="progress-bar flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div class="progress-fill bg-blue-500 h-full" style="width: <?= (int)($row['progress_rate1'] ?? 0) ?>%;"></div>
</div>
<span class="text-[10px] font-bold text-blue-600"><?= (int)($row['progress_rate1'] ?? 0) ?>%</span>
</div>
</td>
<td class="p-4">
<div class="flex items-center space-x-2">
<div class="progress-bar flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div class="progress-fill bg-teal-500 h-full" style="width: <?= (int)($row['progress_rate2'] ?? 0) ?>%;"></div>
</div>
<span class="text-[10px] font-bold text-teal-600"><?= (int)($row['progress_rate2'] ?? 0) ?>%</span>
</div>
</td>
<td class="p-4 font-bold"><?= htmlspecialchars($row['all_tm'] ?? '') ?></td>
<td class="p-4 text-gray-400"><?= htmlspecialchars($row['last_login_time'] ?? '-') ?></td>
<td class="p-4 text-center">
<?php
$all_tm_text = $row['all_tm'] ?? '';
preg_match('/(\d+)시간/', $all_tm_text, $matches);
$total_hours = isset($matches[1]) ? (int)$matches[1] : 0;
$level = $total_hours >= 40 ? 'Master' : ($total_hours >= 20 ? 'Elite' : ($total_hours >= 8 ? 'Learner' : 'Rookie'));
$level_color = $level == 'Master' ? 'purple' : ($level == 'Elite' ? 'blue' : ($level == 'Learner' ? 'green' : 'gray'));
$levelClass = "bg-{$level_color}-100 text-{$level_color}-600";
$levelIcon = '';
switch ($level) {
case 'Master': $levelIcon = 'fa-crown'; break;
case 'Elite': $levelIcon = 'fa-star'; break;
case 'Learner': $levelIcon = 'fa-graduation-cap'; break;
case 'Rookie':
default: $levelIcon = 'fa-seedling'; break;
}
?>
<span class="px-2 py-1 <?= $levelClass ?> rounded-md text-[10px] font-bold uppercase">
<i class="fa-solid <?= $levelIcon ?> mr-1"></i><?= $level ?>
</span>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="mt-6 flex flex-wrap gap-4 justify-center py-4 bg-white rounded-xl border border-dashed border-gray-300">
<div class="flex items-center text-xs font-medium text-gray-500">
<span class="w-3 h-3 rounded-full bg-gray-200 mr-2"></span> Rookie: 0-8시간
</div>
<div class="flex items-center text-xs font-medium text-gray-500">
<span class="w-3 h-3 rounded-full bg-green-200 mr-2"></span> Learner: 8-20시간
</div>
<div class="flex items-center text-xs font-medium text-gray-500">
<span class="w-3 h-3 rounded-full bg-blue-200 mr-2"></span> Elite: 20-40시간
</div>
<div class="flex items-center text-xs font-medium text-gray-500">
<span class="w-3 h-3 rounded-full bg-purple-200 mr-2"></span> Master: 40시간 이상
</div>
</section>
</main>
</body>
</html>
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB