20260205 업데이트(컨텐츠 페이지 연결)

This commit is contained in:
2026-02-05 10:06:09 +09:00
parent 5d52f6d37a
commit 6dcc2eb796
208 changed files with 8143 additions and 1524 deletions

View File

@@ -2,6 +2,7 @@ import { w2grid, w2ui, w2popup, w2alert, w2confirm } from 'https://cdn.jsdelivr.
let AUTH_ITEMS = []
let CURRENT_MEMBER_ID = null;
const USE_ITEMS = [
{ id: 'Y', text: '사용' },
{ id: 'N', text: '미사용' }
@@ -259,10 +260,10 @@ export function loadUsersByMember(memberId) {
w2alert('사용자 조회 실패')
return
}
const records = normalizeAuth(d.records || [])
let records = normalizeAuth(d.records || [])
records = normalizeUseYn(records)
g.clear()
g.add(d.records)
g.add(records)
})
.catch(err => console.error(err))
}

View File

@@ -36,6 +36,7 @@ export function openfaqPopup() {
modal: true,
body: `
<div class="faq-popup">
<div style="display: flex; justify-content: flex-end; padding: 10px; gap: 5px;">

View File

@@ -1,6 +1,9 @@
import { w2grid, w2ui, w2popup, w2alert } from 'https://cdn.jsdelivr.net/gh/vitmalina/w2ui@master/dist/w2ui.es6.min.js'
const USE_YN_ITEMS = [
{ id: 'Y', text: '사용' },
{ id: 'N', text: '미사용' }
]
/* -------------------------------------------------
공통 유틸
------------------------------------------------- */
@@ -36,6 +39,8 @@ export function openProductPopup() {
modal: true,
body: `
<div class="product-popup">
<div style="display: flex; justify-content: flex-end; padding: 1S0px; gap: 5px;">
@@ -162,7 +167,7 @@ export function openProductPopup() {
itm_nm : merged.itm_nm,
area : merged.area,
itm_amt: merged.itm_amt,
use_yn : merged.use_yn,
use_yn : merged.use_yn?.id || merged.use_yn,
rmks : merged.rmks
})
}
@@ -224,31 +229,28 @@ export async function createProductGrid(boxId) {
lineNumbers: true // 행 번호를 표시하면 디버깅이 편합니다.
},
columns: [
/*
{ field: 'row_status', text: ' ', size: '30px', attr: 'align=center',
render: function (record) {
// 1. 신규 추가된 행 (recid가 임시값이거나 DB에 없는 경우)
if (record.is_new) return '<span style="color: green;">I</span>';
// 2. 수정된 데이터 (w2ui.changes 객체가 존재하는 경우)
if (record.w2ui && record.w2ui.changes) return '<span style="color: blue;">U</span>';
// 3. 일반 상태
return '<span style="color: gray;"> </span>';
}
},
*/
{ field: 'itm_cd', text: '상품코드', size: '80px',attr: 'align=center',style: 'text-align: center', attr: 'align=center', editable: { type: 'text' } ,sortable: true}, // name 아님!
{ field: 'itm_nm', text: '상품명', size: '120px', attr: 'align=center',style: 'text-align: center', editable: { type: 'text' } ,sortable: true}, // name 아님!
{ field: 'itm_cd', text: '상품코드', size: '80px',style: 'text-align: center', attr: 'align=center', editable: { type: 'text' } ,sortable: true}, // name 아님!
{ field: 'itm_nm', text: '상품명', size: '120px', style: 'text-align: center', editable: { type: 'text' } ,sortable: true}, // name 아님!
{ field: 'area', text: '제공량', size: '100px', attr: 'align=center',render: 'number:0' , editable: { type: 'float' } ,sortable: true}, // volume 아님!
{ field: 'itm_amt', text: '단가', size: '120px', attr: 'align=center',render: 'number:0', editable: { type: 'int' } ,sortable: true}, //
{ field: 'use_yn', text: '사용여부', size: '80px',attr: 'align=center',style: 'text-align: center', attr: 'align=center',
editable: { type: 'list', items: authItems, filter: false ,showAll: true } ,
render(record) {
const item = authItems.find(i => i.id === record.use_yn)
return item ? item.text : record.use_yn
},sortable: true
{
field: 'use_yn',
text: '사용여부',
size: '80px',
attr: 'align=center',
editable: {
type: 'list',
items: USE_YN_ITEMS,
showAll: true,
openOnFocus: true
},
{ field: 'rmks', text: '비고', size: '200px', attr: 'align=center', editable: { type: 'text' } ,sortable: true} // memo 아님!
render(record, extra) {
return extra?.value?.text || ''
},
sortable: true
},
{ field: 'rmks', text: '비고', size: '200px', editable: { type: 'text' } ,sortable: true} // memo 아님!
],
records: [] // 처음엔 비워둠
});
@@ -266,7 +268,9 @@ async function loadProductData() {
w2ui.productGrid.lock('조회 중...', true);
const response = await fetch('/kngil/bbs/adm_product_popup.php'); // PHP 파일 호출
const data = await response.json();
let data = await response.json();
data = normalizeProductUseYn(data)
w2ui.productGrid.clear();
w2ui.productGrid.add(data);
@@ -277,4 +281,14 @@ async function loadProductData() {
w2ui.productGrid.unlock();
w2alert('데이터 로드 실패');
}
}
function normalizeProductUseYn(records) {
return records.map(r => {
const item = USE_YN_ITEMS.find(u => u.id === r.use_yn)
return {
...r,
use_yn: item || null
}
})
}

View File

@@ -16,13 +16,8 @@ export function openPurchaseHistoryPopup(memberId = '',isSuperAdmin='') {
height: 600,
modal: true,
body:
/*
`
<div style="padding:8px">
<div id="purchaseHistoryGrid" style="height:520px;"></div>
</div>
*/
`
`
<div id="popupMainContainer" style="display: flex; flex-direction: column; height: 100%; padding: 10px; gap: 10px; box-sizing: border-box;">
<div id="searchFormArea" style="display: flex; align-items: center; gap: 10px; padding: 15px; background: #f9f9f9; border: 1px solid #ddd; border-radius: 4px;">
@@ -31,7 +26,19 @@ export function openPurchaseHistoryPopup(memberId = '',isSuperAdmin='') {
${!isSuperAdmin ? 'disabled' : ''}
style="width: 150px; padding: 6px; border: 1px solid #ccc; border-radius: 4px;"
placeholder="아이디 입력">
<div style="display: flex; align-items: center; gap: 5px;"> <span style="font-weight: bold; color: #333; white-space: nowrap;">구입일</span>
<input type="date" id="searchFbuydt"
style="width: 130px; padding: 4px; border: 1px solid #ccc; border-radius: 4px;">
<span style="color: #666;">~</span>
<input type="date" id="searchTbuydt"
style="width: 130px; padding: 4px; border: 1px solid #ccc; border-radius: 4px;">
</div>
<button id="btnSearchHistory"
style="padding: 6px 20px; background: #1565c0; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
검색
@@ -45,21 +52,25 @@ export function openPurchaseHistoryPopup(memberId = '',isSuperAdmin='') {
onOpen(event) {
event.onComplete = () => {
const searchBtn = document.getElementById('btnSearchHistory');
const searchInput = document.getElementById('searchMemberId');
const inputs = {
mid: document.getElementById('searchMemberId'),
fdt: document.getElementById('searchFbuydt'),
tdt: document.getElementById('searchTbuydt')
};
// 조회 버튼 클릭 시 동작
// 1. 날짜 필드 캘린더 연결 (W2UI 스타일)
if (inputs.fdt) inputs.fdt.type = 'date';
if (inputs.tdt) inputs.tdt.type = 'date';
// 2. 조회 버튼 클릭
if (searchBtn) {
searchBtn.onclick = () => {
loadPurchaseHistoryData(searchInput.value);
};
searchBtn.onclick = () => loadPurchaseHistoryData();
}
// 엔터키 지원
if (searchInput) {
searchInput.onkeydown = (e) => {
if (e.key === 'Enter') searchBtn.click();
};
}
// 3. 모든 입력창 엔터키 지원
Object.values(inputs).forEach(el => {
if (el) el.onkeydown = (e) => { if (e.key === 'Enter') searchBtn.click(); };
});
createPurchaseHistoryGrid(memberId);
loadPurchaseHistoryData(memberId);
@@ -78,44 +89,49 @@ function createPurchaseHistoryGrid(memberId) {
lineNumbers: true // 행 번호를 표시하면 디버깅이 편합니다.
},
columns: [
// field 이름이 PHP에서 내려주는 JSON 키값과 정확히 일치해야 함!
{ field: 'member_id', text: '회원ID', size: '80px',style: 'text-align: center', attr: 'align=center',sortable: true }, // name 아님!
{ field: 'sq_no', text: '순번', size: '50px',style: 'text-align: center', attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'user_nm', text: '구매자', size: '80px',style: 'text-align: center', attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'co_nm', text: '회사명', size: '100px',style: 'text-align: center', attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'bs_no', text: '사업자번호', size: '100px',style: 'text-align: center', attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'buy_dt', text: '구입일자', size: '80px',style: 'text-align: center', attr: 'align=center',sortable: true }, // name 아님!
{ field: 'itm_nm', text: '상품명', size: '80px',style: 'text-align: center', attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'area', text: '상품면적', size: '80px',render: 'number:0' , attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'itm_qty', text: '수량', size: '80px',render: 'number:0' , attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'itm_area', text: '면적', size: '100px',render: 'number:0' , attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'add_area', text: '추가면적', size: '100px',render: 'number:0' , attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'sum_area', text: '합계면적', size: '100px',render: 'number:0' , attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'itm_amt', text: '단가', size: '100px',render: 'number:0' , attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'dis_rt', text: '할인율', size: '80px', attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'buy_amt', text: '공급금액', size: '100px',render: 'number:0' , attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'vat_amt', text: '부가세', size: '100px',render: 'number:0' , attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'sum_amt', text: '합계', size: '100px',render: 'number:0' , attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'end_dt', text: '종료일', size: '80px',style: 'text-align: center', attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'ok_yn', text: '승인여부', size: '80px',style: 'text-align: center', attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'rmks', text: '비고', size: '150px',style: 'text-align: center', attr: 'align=center' ,sortable: true} // name 아님!
{ field: 'member_id', text: '회원ID', size: '80px', style: 'text-align: center', attr: 'align=center', sortable: true },
{ field: 'sq_no', text: '순번', size: '50px', style: 'text-align: center', attr: 'align=center', sortable: true },
{ field: 'user_nm', text: '구매자', size: '80px', style: 'text-align: center', attr: 'align=center', sortable: true },
{ field: 'co_nm', text: '회사명', size: '100px', style: 'text-align: center', attr: 'align=center', sortable: true },
{ field: 'bs_no', text: '사업자번호', size: '100px', style: 'text-align: center', attr: 'align=center', sortable: true },
{ field: 'buy_dt', text: '구입일자', size: '80px', style: 'text-align: center', attr: 'align=center', sortable: true },
{ field: 'itm_nm', text: '상품명', size: '80px', style: 'text-align: center', attr: 'align=center', sortable: true },
// --- 숫자/면적 데이터: 제목(center), 데이터(right) ---
{ field: 'area', text: '상품면적', size: '80px', render: 'number:0', style: 'text-align: center', attr: 'align=right', sortable: true },
{ field: 'itm_qty', text: '수량', size: '80px', render: 'number:0', style: 'text-align: center', attr: 'align=right', sortable: true },
{ field: 'itm_area', text: '면적', size: '100px', render: 'number:0', style: 'text-align: center', attr: 'align=right', sortable: true },
{ field: 'add_area', text: '추가면적', size: '100px', render: 'number:0', style: 'text-align: center', attr: 'align=right', sortable: true },
{ field: 'sum_area', text: '합계면적', size: '100px', render: 'number:0', style: 'text-align: center', attr: 'align=right', sortable: true },
{ field: 'itm_amt', text: '단가', size: '100px', render: 'number:0', style: 'text-align: center', attr: 'align=right', sortable: true },
{ field: 'dis_rt', text: '할인율', size: '80px', style: 'text-align: center', attr: 'align=center', sortable: true },
{ field: 'buy_amt', text: '공급금액', size: '100px', render: 'number:0', style: 'text-align: center', attr: 'align=right', sortable: true },
{ field: 'vat_amt', text: '부가세', size: '100px', render: 'number:0', style: 'text-align: center', attr: 'align=right', sortable: true },
{ field: 'sum_amt', text: '합계', size: '100px', render: 'number:0', style: 'text-align: center', attr: 'align=right', sortable: true },
// --------------------------------------------------
{ field: 'end_dt', text: '종료일', size: '80px', style: 'text-align: center', attr: 'align=center', sortable: true },
{ field: 'ok_yn', text: '승인여부', size: '80px', style: 'text-align: center', attr: 'align=center', sortable: true },
{ field: 'rmks', text: '비고', size: '150px', style: 'text-align: center', attr: 'align=left', sortable: true }
]
});
}
async function loadPurchaseHistoryData(memberId) {
async function loadPurchaseHistoryData() {
try {
const grid = w2ui.purchaseHistoryGrid;
if (!grid) return;
const sMid = document.getElementById('searchMemberId')?.value || '';
const sfdt = document.getElementById('searchFbuydt')?.value || '';
const stdt = document.getElementById('searchTbuydt')?.value || '';
grid.lock('조회 중...', true);
const searchParams = new URLSearchParams();
searchParams.append('member_id', memberId);
searchParams.append('member_nm', '');
searchParams.append('fbuy_dt', '');
searchParams.append('tbuy_dt', '');
searchParams.append('member_id', sMid);
searchParams.append('fbuy_dt', sfdt);
searchParams.append('tbuy_dt', stdt);
const response = await fetch('/kngil/bbs/adm_purch_popup.php', {
method: 'POST',
@@ -123,22 +139,45 @@ async function loadPurchaseHistoryData(memberId) {
body: searchParams
});
if (!response.ok) throw new Error(`서버 응답 오류: ${response.status}`);
const data = await response.json();
grid.clear();
if (data && data.length > 0) {
// 1. 일반 데이터 추가
grid.add(data);
if (!Array.isArray(data)) {
throw new Error('응답 데이터 형식 오류');
// 2. 합계(Summary) 계산
let totals = {
recid: 'summary',
w2ui: { summary: true, style: 'background-color: #efefef; font-weight: bold;' },
user_nm: '합계', // 합계 문구를 표시할 컬럼
area: 0, itm_area: 0, add_area: 0, sum_area: 0,
itm_amt: 0, buy_amt: 0, vat_amt: 0, sum_amt: 0
};
data.forEach(row => {
totals.area += Number(row.area || 0);
totals.itm_area += Number(row.itm_area || 0);
totals.add_area += Number(row.add_area || 0);
totals.sum_area += Number(row.sum_area || 0);
totals.itm_amt += Number(row.itm_amt || 0);
totals.buy_amt += Number(row.buy_amt || 0);
totals.vat_amt += Number(row.vat_amt || 0);
totals.sum_amt += Number(row.sum_amt || 0);
});
// 3. 그리드 하단에 합계 행 주입
grid.summary = [totals];
}
grid.clear();
grid.add(data);
grid.unlock();
grid.refresh(); // 변경사항 반영
} catch (e) {
console.error(e);
if (w2ui.purchaseHistoryGrid) w2ui.purchaseHistoryGrid.unlock();
w2alert('구매이력 조회 실패: ' + e.message);
w2alert('조회 실패: ' + e.message);
}
}
////////////////////////////////////////////////////////////////////////

View File

@@ -1,5 +1,11 @@
import { w2grid, w2ui, w2popup, w2alert, w2confirm }
from 'https://cdn.jsdelivr.net/gh/vitmalina/w2ui@master/dist/w2ui.es6.min.js'
from 'https://cdn.jsdelivr.net/gh/vitmalina/w2ui@master/dist/w2ui.es6.min.js'
const OK_YN_ITEMS = [
{ id: 'Y', text: '승인' },
{ id: 'N', text: '미승인' }
]
/* -------------------------------------------------
공통 유틸
@@ -19,6 +25,14 @@ function fmtPrice(val) {
: v.toLocaleString(undefined, { minimumFractionDigits: 2 })
}
function forceEndEdit(grid) {
if (!grid) return
if (grid.last && grid.last.inEdit) {
// ✅ W2UI 표준 방식으로 편집을 종료해야 데이터가 정상적으로 커밋됩니다.
grid.editField(null)
}
}
/* -------------------------------------------------
서비스 등록 팝업
------------------------------------------------- */
@@ -196,7 +210,7 @@ function createServiceGrid() {
text: '합계면적(m²)',
size: '110px',
attr: 'align=right',
editable: { type: 'int' },
editable: false,
render: r => fmtNum(r.sum_area)
},
{
@@ -207,20 +221,22 @@ function createServiceGrid() {
},
{
field: 'ok_yn',
text: '승인여부',
size: '80px',
attr: 'align=center',
text: '사용승인',
size: '110px',
editable: {
type: 'combo',
items: [
{ id: 'N', text: '미승인' },
{ id: 'Y', text: '승인' }
],
type: 'list',
items: OK_YN_ITEMS,
showAll: true,
openOnFocus: true
},
render(record) {
if (record.ok_yn === 'Y') return '승인'
return '미승인'
const val = record.ok_yn
// 1. 객체인 경우 (W2UI list editor 또는 정규화 결과)
if (val && typeof val === 'object') return val.text || ''
// 2. 문자열/기타 값인 경우 (ID 매칭 시도)
const rawId = String(val || '').trim().toUpperCase()
const item = OK_YN_ITEMS.find(i => String(i.id).toUpperCase() === rawId)
return item ? item.text : (val || '')
}
},
{ field: 'rmks', text: '비고', size: '150px', editable: { type: 'text' } }
@@ -232,17 +248,45 @@ function createServiceGrid() {
일반 필드 처리 (date 제외)
----------------------------- */
onChange(event) {
const g = this
// 🔥 현재 편집 세션 강제 종료
if (g.last?.inEdit) {
g.editField(null)
}
event.onComplete = (ev) => {
const g = w2ui.serviceGrid
const field = g.columns[ev.column].field
if (!g || ev.column == null) return
const field = g.columns[ev.column]?.field
if (!field) return
let val = ev.value_new
if (typeof val === 'object' && val !== null) val = val.id
// ✅ 사용승인 전용 처리
if (field === 'ok_yn') {
// 화면은 객체
g.set(ev.recid, { ok_yn: val })
// 🔥 변경사항은 id 기준으로 확정
g.mergeChanges(ev.recid, {
ok_yn: val?.id || null
})
return
}
// 나머지 컬럼
if (typeof val === 'object' && val !== null) {
val = val.id
}
g.set(ev.recid, { [field]: val })
g.mergeChanges(ev.recid, { [field]: val })
if (['itm_qty','dis_rt','add_area'].includes(field)) {
recalcRow(ev.recid)
// ✅ 재계산 (50ms 지연하여 포커스 이슈 및 그리드 리프레시 충돌 방지)
if (['itm_qty', 'dis_rt', 'add_area', 'area'].includes(field)) {
setTimeout(() => recalcRow(ev.recid), 50)
}
}
},
@@ -251,35 +295,29 @@ function createServiceGrid() {
날짜 전용 처리 (핵심)
----------------------------- */
onEditField(event) {
if (event.field !== 'end_dt') return;
if (event.field !== 'end_dt') return
event.onComplete = () => {
const g = w2ui.serviceGrid;
const recid = event.recid;
const g = w2ui.serviceGrid
const recid = event.recid
const input = document.querySelector(
`#grid_${g.name}_edit input`
);
if (!input) return;
)
if (!input) return
input.addEventListener('change', () => {
// 값만 반영
g.set(recid, { end_dt: input.value })
// 🔥 날짜는 문자열 그대로 즉시 확정
g.set(recid, {
end_dt: input.value // YYYY-MM-DD
});
// 🔥 w2ui 방식으로 edit 종료
input.blur();
g.refreshRow(recid);
});
};
// 🔥 아무 것도 하지 마라
// w2ui가 blur 시 자동 종료
})
}
}
});
}
/* -------------------------------------------------
상품 목록
------------------------------------------------- */
@@ -288,9 +326,9 @@ function createProductList() {
new w2grid({
name: 'productList',
box: '#productList',
url: '/kngil/bbs/adm_product_popup.php',
url: '/kngil/bbs/adm_product_popup.php',
columns: [
{ field: 'itm_nm', text: '상품명', size: '120px' },
{ field: 'itm_nm', text: '상품명', size: '120px' },
{
field: 'area',
text: '제공량',
@@ -363,7 +401,7 @@ function addServiceFromProduct(p) {
sum_amt: Math.floor(unit * qty * 1.1),
end_dt: endDt,
ok_yn: 'N',
ok_yn: OK_YN_ITEMS.find(o => o.id === 'N'),
rmks: '',
_new: true
@@ -388,14 +426,14 @@ function deleteSelectedRows() {
}
const recid = sel[0]
const row = g.get(recid)
const row = g.get(recid)
if (!row || !row.sq_no) {
w2alert('삭제할 수 없는 항목입니다.')
return
}
if (row.ok_yn === 'Y') {
if (row.ok_yn?.id === 'Y') {
w2alert('승인된 항목은 삭제할 수 없습니다.')
return
}
@@ -407,7 +445,7 @@ function deleteSelectedRows() {
}
function deleteServiceImmediately(row) {
console.log({
console.log({
action: 'delete',
member_id: row.member_id,
sq_no: row.sq_no
@@ -422,23 +460,23 @@ function deleteServiceImmediately(row) {
sq_no: row.sq_no
})
})
.then(res => res.json())
.then(res => {
if (res.status === 'success') {
w2alert('삭제되었습니다.')
w2ui.serviceGrid.remove(row.recid)
calcSummary()
} else {
w2alert(res.message || '삭제 실패')
}
})
.catch(() => w2alert('서버 오류'))
.then(res => res.json())
.then(res => {
if (res.status === 'success') {
w2alert('삭제되었습니다.')
w2ui.serviceGrid.remove(row.recid)
calcSummary()
} else {
w2alert(res.message || '삭제 실패')
}
})
.catch(() => w2alert('서버 오류'))
}
function isServiceItem(r) {
return r.itm_cd && r.itm_cd.startsWith('ZET01')
|| r.itm_nm === '서비스'
|| r.itm_nm === '서비스'
}
/* -------------------------------------------------
@@ -455,62 +493,61 @@ function loadExistingPurchase(memberId, buyDate) {
buy_date: buyDate
})
})
.then(res => res.json())
.then(json => {
if (json.status !== 'success') {
w2alert(json.message || '조회 실패')
return
}
const g = w2ui.serviceGrid
g.clear()
json.records.forEach((r, i) => {
g.add({
recid: i + 1,
member_id: memberId,
sq_no: r.sq_no,
itm_cd: r.itm_cd,
itm_nm: r.itm_nm,
itm_area: Number(r.itm_area || 0),
add_area: Number(r.add_area || 0),
itm_amt: Number(r.itm_amt),
itm_qty: Number(r.itm_qty),
// area
area: isServiceItem(r)
? 1
: (r.itm_qty ? Number(r.itm_area) / Number(r.itm_qty) : 0),
dis_rt: Number(r.dis_rt || 0), // 문자열 → 숫자
supply: Number(r.buy_amt),
vat_amt: Number(r.vat_amt),
sum_amt: Number(r.sum_amt),
end_dt: r.end_dt || '',
ok_yn: r.ok_yn || 'N',
rmks: r.rmks ?? '', // undefined 방지
_existing: true
})
g.set(i + 1, {
w2ui: { style: 'background:#f3f3f3;color:#555' }
})
})
g.refresh()
// 🔥 모든 행 재계산
g.records.forEach(r => {
if (!r._deleted) {
recalcRow(r.recid)
.then(res => res.json())
.then(json => {
if (json.status !== 'success') {
w2alert(json.message || '조회 실패')
return
}
const g = w2ui.serviceGrid
g.clear()
const records = normalizeServiceOkYn(json.records)
records.forEach((r, i) => {
g.add({
recid: i + 1,
member_id: memberId,
sq_no: r.sq_no,
itm_cd: r.itm_cd,
itm_nm: r.itm_nm,
itm_area: Number(r.itm_area || 0),
add_area: Number(r.add_area || 0),
itm_amt: Number(r.itm_amt),
itm_qty: Number(r.itm_qty),
// area
area: isServiceItem(r)
? 1
: (r.itm_qty ? Number(r.itm_area) / Number(r.itm_qty) : 0),
dis_rt: Number(r.dis_rt || 0), // 문자열 → 숫자
supply: Number(r.buy_amt),
vat_amt: Number(r.vat_amt),
sum_amt: Number(r.sum_amt),
end_dt: r.end_dt || '',
ok_yn: r.ok_yn, // 이미 normalizeServiceOkYn에서 객체로 변환됨
rmks: r.rmks ?? '', // undefined 방지
_existing: true
})
g.set(i + 1, {
w2ui: { style: 'background:#f3f3f3;color:#555' }
})
})
g.refresh()
// 🔥 모든 행 재계산
g.records.forEach(r => {
if (!r._deleted) {
recalcRow(r.recid)
}
})
calcSummary()
})
calcSummary()
})
.catch(() => w2alert('서버 오류'))
.catch(() => w2alert('서버 오류'))
}
/* -------------------------------------------------
@@ -522,11 +559,11 @@ function recalcRow(recid) {
const r = g.get(recid)
if (!r) return
const qty = Number(r.itm_qty || 0)
const qty = Number(r.itm_qty || 0)
const unit = Number(r.itm_amt || 0)
const rate = Number(r.dis_rt || 0)
const area = Number(r.area || 0)
const add = Number(r.add_area || 0)
const add = Number(r.add_area || 0)
// 🔥 적용면적 = 제공량 * 수량
r.itm_area = area * qty
@@ -563,13 +600,13 @@ function calcSummary() {
w2ui.serviceGrid.records.forEach(r => {
if (r._deleted) return
supply += Number(r.supply || 0)
vat += Number(r.vat_amt || 0)
total += Number(r.sum_amt || 0)
vat += Number(r.vat_amt || 0)
total += Number(r.sum_amt || 0)
})
document.getElementById('sumSupply').innerText = supply.toLocaleString()
document.getElementById('sumVat').innerText = vat.toLocaleString()
document.getElementById('sumTotal').innerText = total.toLocaleString()
document.getElementById('sumVat').innerText = vat.toLocaleString()
document.getElementById('sumTotal').innerText = total.toLocaleString()
}
function saveService(ctx) {
@@ -582,29 +619,33 @@ function saveService(ctx) {
const g = w2ui.serviceGrid
// 🔥🔥🔥 핵심: 현재 편집중인 셀 강제 반영
g.save()
// 🔥🔥🔥 핵심 1: 편집중인 셀 강제 종료
g.finishEditing?.()
g.save?.()
const items = g.records.map(r => ({
sq_no: r.sq_no || null,
itm_cd: r.itm_cd,
itm_area: r.itm_area,
add_area: r.add_area || 0,
itm_amt: r.itm_amt,
itm_qty: r.itm_qty,
const items = g.records
.filter(r => !r._deleted)
.map(r => ({
sq_no: r.sq_no || null,
itm_cd: r.itm_cd,
itm_area: r.itm_area,
add_area: r.add_area || 0,
itm_amt: r.itm_amt,
itm_qty: r.itm_qty,
dis_rt: r.dis_rt,
buy_amt: r.supply,
vat_amt: r.vat_amt,
sum_amt: r.sum_amt,
end_dt: dateToYMD(r.end_dt),
ok_yn: r.ok_yn || 'N',
rmks: r.rmks,
dis_rt: r.dis_rt,
buy_amt: r.supply,
vat_amt: r.vat_amt,
sum_amt: r.sum_amt,
end_dt: dateToYMD(r.end_dt),
ok_yn: r.ok_yn?.id || 'N',
rmks: r.rmks,
_new: r._new || false,
_existing: r._existing || false,
_deleted: r._deleted || false
}))
// 🔥 상태 플래그 유지
_new: !!r._new,
_existing: !!r._existing,
_deleted: !!r._deleted
}))
fetch('/kngil/bbs/adm_service.php', {
method: 'POST',
@@ -616,17 +657,20 @@ function saveService(ctx) {
items
})
})
.then(res => res.json())
.then(res => {
if (res.status === 'success') {
w2alert('저장되었습니다.')
loadExistingPurchase(ctx.memberId, buyDate)
} else {
w2alert(res.message || '저장 실패')
}
})
.then(res => res.json())
.then(res => {
if (res.status === 'success') {
w2alert('저장되었습니다.')
console.log('changes =', g.getChanges()) // 이제 정상적으로 다 나올 것
loadExistingPurchase(ctx.memberId, buyDate)
} else {
w2alert(res.message || '저장 실패')
}
})
}
function normalizeDate(val) {
if (!val) return null
@@ -675,4 +719,17 @@ function dateToYMD(val) {
}
return null
}
function normalizeServiceOkYn(records) {
return records.map(r => {
const yn = typeof r.ok_yn === 'string'
? r.ok_yn.trim()
: r.ok_yn
return {
...r,
ok_yn: OK_YN_ITEMS.find(o => String(o.id).toUpperCase() === String(yn || '').trim().toUpperCase()) || null
}
})
}

View File

@@ -1,4 +1,4 @@
import { w2grid, w2ui, w2popup, w2alert } from 'https://cdn.jsdelivr.net/gh/vitmalina/w2ui@master/dist/w2ui.es6.min.js'
import { w2grid, w2ui, w2popup, w2alert} from 'https://cdn.jsdelivr.net/gh/vitmalina/w2ui@master/dist/w2ui.es6.min.js'
// 파일이 실제 존재하는지 확인 필수! 존재하지 않으면 아래 코드가 모두 멈춥니다.
import { bindProductPopupEvents } from '/kngil/js/adm_common.js'
@@ -49,6 +49,18 @@ export function openuseHistoryPopup(memberId = '',isSuperAdmin='') {
placeholder="부서명 입력">
</div>
<div style="display: flex; align-items: center; gap: 5px;"> <span style="font-weight: bold; color: #333; white-space: nowrap;">구입일</span>
<input type="date" id="searchFbuydt"
style="width: 130px; padding: 4px; border: 1px solid #ccc; border-radius: 4px;">
<span style="color: #666;">~</span>
<input type="date" id="searchTbuydt"
style="width: 130px; padding: 4px; border: 1px solid #ccc; border-radius: 4px;">
</div>
<button id="btnSearchHistory"
style="padding: 6px 20px; background: #1565c0; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; margin-left: auto;">
검색
@@ -61,27 +73,33 @@ export function openuseHistoryPopup(memberId = '',isSuperAdmin='') {
`,
onOpen(event) {
event.onComplete = () => {
const searchBtn = document.getElementById('btnSearchHistory');
const searchInput = document.getElementById('searchMemberId');
const inputUnm = document.getElementById('searchUserNm');
const inputDnm = document.getElementById('searchDeptNm');
// 조회 버튼 클릭 시 동작
if (searchBtn) {
searchBtn.onclick = () => {
loadUseHistoryData(searchInput.value,inputUnm,inputDnm);
};
}
const searchBtn = document.getElementById('btnSearchHistory');
const inputs = {
mid: document.getElementById('searchMemberId'),
unm: document.getElementById('searchUserNm'),
dnm: document.getElementById('searchDeptNm'),
fdt: document.getElementById('searchFbuydt'),
tdt: document.getElementById('searchTbuydt')
};
// 엔터키 지원
if (searchInput,inputUnm,inputDnm) {
searchInput.onkeydown = (e) => {
if (e.key === 'Enter') searchBtn.click();
};
}
// 1. 날짜 필드 캘린더 연결 (W2UI 스타일)
if (inputs.fdt) inputs.fdt.type = 'date';
if (inputs.tdt) inputs.tdt.type = 'date';
createUseHistoryGrid(memberId);
loadUseHistoryData(memberId);
// 2. 조회 버튼 클릭
if (searchBtn) {
searchBtn.onclick = () => loadUseHistoryData();
}
// 3. 모든 입력창 엔터키 지원
Object.values(inputs).forEach(el => {
if (el) el.onkeydown = (e) => { if (e.key === 'Enter') searchBtn.click(); };
});
// 4. 초기 그리드 생성 및 데이터 로드
createUseHistoryGrid(memberId);
loadUseHistoryData();
}
}
});
@@ -96,21 +114,25 @@ function createUseHistoryGrid(memberId) {
footer: true,
lineNumbers: true // 행 번호를 표시하면 디버깅이 편합니다.
},
columns: [
// field 이름이 PHP에서 내려주는 JSON 키값과 정확히 일치해야 함!
{ field: 'member_id', text: '회원ID', size: '100px',style: 'text-align: center', attr: 'align=center',hidden: true }, // name 아님!
{ field: 'use_dt', text: '사용자', size: '100px',style: 'text-align: center', attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'user_id', text: '사용자ID', size: '100px',style: 'text-align: center', attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'sq_no', text: '순번', size: '100px',style: 'text-align: center', attr: 'align=center' ,hidden: true}, // name 아님!
{ field: 'user_nm', text: '성명', size: '100px',style: 'text-align: center', attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'dept_nm', text: '부서', size: '100px',style: 'text-align: center', attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'posit_nm', text: '직위', size: '100px',style: 'text-align: center', attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'use_yn', text: '사용여부', size: '80px', attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'use_area', text: '사용면적', size: '100px',render: 'number:0' , attr: 'align=center' ,sortable: true}, // name 아님!
{ field: 'ser_bc', text: '서비스구분', size: '150px', attr: 'align=center' ,sortable: true}, // name 아님!
]
columns: [
{ field: 'use_dt', text: '사용일자', size: '100px',
style: 'text-align: center', attr: 'align=center', sortable: true },
{ field: 'user_id', text: '사용자ID', size: '100px',
style: 'text-align: center', attr: 'align=center', sortable: true },
{ field: 'user_nm', text: '성명', size: '100px',
style: 'text-align: center', attr: 'align=center', sortable: true },
{ field: 'dept_nm', text: '부서', size: '120px',
tyle: 'text-align: center', attr: 'align=center', sortable: true },
{ field: 'posit_nm', text: '직위', size: '100px',
style: 'text-align: center', attr: 'align=center', sortable: true },
{ field: 'use_yn', text: '사용여부', size: '80px',
style: 'text-align: center', attr: 'align=center', sortable: true },
// 사용면적: 제목은 가운데(style), 숫자는 오른쪽(attr)이 데이터 가독성에 좋습니다.
{ field: 'use_area', text: '사용면적', size: '100px', render: 'number:0',
style: 'text-align: center', attr: 'align=right', sortable: true },
{ field: 'ser_bc', text: '서비스구분', size: '200px',
style: 'text-align: center', attr: 'align=center', sortable: true },
]
});
}
@@ -119,10 +141,11 @@ async function loadUseHistoryData(memberId = ''){
const grid = w2ui.UseHistoryGrid;
if (!grid) return;
// DOM에서 현재 입력된 값을 실시간으로 읽어옴
const sMid = document.getElementById('searchMemberId')?.value || '';
const sUnm = document.getElementById('searchUserNm')?.value || '';
const sDnm = document.getElementById('searchDeptNm')?.value || '';
const sfdt = document.getElementById('searchFbuydt')?.value || '';
const stdt = document.getElementById('searchTbuydt')?.value || '';
grid.lock('조회 중...', true);
@@ -130,6 +153,8 @@ async function loadUseHistoryData(memberId = ''){
searchParams.append('member_id', sMid);
searchParams.append('user_nm', sUnm);
searchParams.append('dept_nm', sDnm);
searchParams.append('fuse_dt', sfdt);
searchParams.append('tuse_dt', stdt);
const response = await fetch('/kngil/bbs/adm_use_history.php', {
method: 'POST',
@@ -146,8 +171,33 @@ async function loadUseHistoryData(memberId = ''){
}
grid.clear();
grid.add(data);
// --- 합계 계산 로직 시작 ---
if (data.length > 0) {
// 일반 데이터 추가
grid.add(data);
// 합계 변수 초기화
let totalArea = 0;
data.forEach(item => {
// 숫자가 아닌 값이 올 경우를 대비해 Number() 처리
totalArea += Number(item.use_area || 0);
});
// Summary 행 생성
grid.summary = [{
recid: 'summary',
w2ui: { summary: true, style: 'background-color: #f0f0f0; font-weight: bold;' },
use_dt: '합 계', // 합계 문구를 노출할 위치 (보통 첫 번째나 두 번째 컬럼)
use_area: totalArea // 계산된 합계값
}];
} else {
grid.summary = []; // 데이터가 없으면 합계행 비우기
}
// --- 합계 계산 로직 끝 ---
grid.unlock();
grid.refresh(); // 변경된 summary 정보를 화면에 반영
} catch (e) {
console.error(e);
@@ -155,4 +205,3 @@ async function loadUseHistoryData(memberId = ''){
w2alert('사용이력 조회 실패: ' + e.message);
}
}
////////////////////////////////////////////////////////////////////////

108
kngil/js/analysis.js Normal file
View File

@@ -0,0 +1,108 @@
/**
* Analysis Page Controller
* 스크롤 기반 타이틀 전환 및 섹션 네비게이션 (3개 key 섹션)
* LayoutFixController 공통 모듈 사용
*/
(function() {
'use strict';
// ============================================
// Configuration
// ============================================
const CONFIG = {
SELECTORS: {
key1: '.key.spatial',
key2: '.key.statistics',
key3: '.key.attribute'
},
SVG_LOAD: {
threshold: 0.3,
rootMargin: '0px'
}
};
// ============================================
// Utility Functions
// ============================================
const Utils = {
$(selector) {
return document.querySelector(selector);
},
$$(selector) {
return document.querySelectorAll(selector);
}
};
// ============================================
// Layout Fix Controller Instance
// ============================================
let layoutFixController = null;
// ============================================
// SVG Lazy Loader
// ============================================
const initSVGLoader = () => {
const subFigsElements = Utils.$$('.sub-figs');
if (subFigsElements.length === 0) return;
const loadedElements = new Set();
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !loadedElements.has(entry.target)) {
const svgImg = entry.target.querySelector('.apx img[data-src]');
if (svgImg && svgImg.dataset.src) {
// data-src를 src로 변경하여 SVG 로드 (한 번만)
svgImg.src = svgImg.dataset.src;
svgImg.removeAttribute('data-src');
loadedElements.add(entry.target);
observer.unobserve(entry.target);
}
}
});
},
{
threshold: CONFIG.SVG_LOAD.threshold,
rootMargin: CONFIG.SVG_LOAD.rootMargin
}
);
subFigsElements.forEach((el) => observer.observe(el));
};
// ============================================
// Initialization
// ============================================
const init = () => {
// LayoutFixController가 로드되었는지 확인
if (typeof LayoutFixController === 'undefined') {
console.warn('[Analysis] LayoutFixController not loaded');
return;
}
const key1 = Utils.$(CONFIG.SELECTORS.key1);
const key2 = Utils.$(CONFIG.SELECTORS.key2);
const key3 = Utils.$(CONFIG.SELECTORS.key3);
if (!key1 || !key2 || !key3) return;
// 공통 모듈 초기화 (3개 key 섹션)
layoutFixController = new LayoutFixController();
layoutFixController.init([
CONFIG.SELECTORS.key1,
CONFIG.SELECTORS.key2,
CONFIG.SELECTORS.key3
]);
// SVG 로더 초기화 (analysis 전용)
initSVGLoader();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,5 @@
/**
* Main Page Video Player & Navigation Controller
* 모듈화된 구조로 리팩토링
*/
(function() {
'use strict';
@@ -12,19 +11,19 @@
TOTAL_PAGES: 5,
INTRO_DELAY: 2800,
VISITED_STORAGE_KEY: 'visited',
VIDEO_BASE_PATH: '/kngil/img/video',
VIDEO_BASE_PATH: '../img/video',
PAGE_LINKS: {
1: './value.html',
2: './provided.html',
3: './primary.html',
4: './analysis.html',
5: './results.html'
1: '/kngil/skin/value.php',
2: '/kngil/skin/provided.php',
3: '/kngil/skin/primary.php',
4: '/kngil/skin/analysis.php',
5: '/kngil/skin/results.php'
},
SELECTORS: {
video: '#video_play',
videoLink: '#main_video_link',
pagination: '.main-pagination',
paginationItem: '.main-pagination div',
paginationItem: '.main-pagination button[data-page]',
intro: '.intro-wrap',
mainMask: '.main-mask',
footer: 'footer',
@@ -43,50 +42,26 @@
// Utility Functions
// ============================================
const Utils = {
/**
* DOM 요소 선택 (jQuery 대체)
*/
$(selector) {
return document.querySelector(selector);
},
/**
* DOM 요소들 선택 (jQuery 대체)
*/
$$(selector) {
return document.querySelectorAll(selector);
},
/**
* 숫자 유효성 검사
*/
isValidPageNum(pageNum) {
return Number.isInteger(pageNum) &&
pageNum >= 1 &&
pageNum <= CONFIG.TOTAL_PAGES;
},
/**
* 안전한 이벤트 리스너 추가
*/
safeAddEventListener(element, event, handler) {
if (element && typeof handler === 'function') {
element.addEventListener(event, handler);
return true;
}
return false;
},
/**
* 클래스 토글 헬퍼
*/
toggleClass(element, className, force) {
if (!element) return;
if (force === undefined) {
element.classList.toggle(className);
} else {
element.classList.toggle(className, force);
}
}
};
@@ -100,37 +75,41 @@
init() {
this.videoElement = Utils.$(CONFIG.SELECTORS.video);
if (!this.videoElement) {
console.warn('[VideoPlayer] Video element not found');
return false;
}
if (!this.videoElement) return false;
this.sourceElement = this.videoElement.querySelector('source');
if (!this.sourceElement) {
console.warn('[VideoPlayer] Source element not found');
return false;
}
if (!this.sourceElement) return false;
this.setupEventListeners();
return true;
},
setupEventListeners() {
// 영상 종료 후 다음 영상 실행
Utils.safeAddEventListener(this.videoElement, 'ended', () => {
this.playNext();
});
Utils.safeAddEventListener(this.videoElement, 'loadeddata', () => {
if (this.videoElement.paused) {
this.play().catch(() => {});
}
});
Utils.safeAddEventListener(this.videoElement, 'error', () => {
console.error('[VideoPlayer] Video load error');
});
},
loadVideo(pageNum) {
if (!Utils.isValidPageNum(pageNum)) {
console.warn(`[VideoPlayer] Invalid page number: ${pageNum}`);
return false;
}
if (!Utils.isValidPageNum(pageNum)) return false;
const videoSource = `${CONFIG.VIDEO_BASE_PATH}/main_${pageNum}.mp4`;
// 현재 소스와 동일하면 다시 로드하지 않음
if (this.sourceElement.src && this.sourceElement.src.includes(`main_${pageNum}.mp4`)) {
return true;
}
try {
this.sourceElement.src = videoSource;
this.videoElement.load();
@@ -143,13 +122,9 @@
play() {
if (!this.videoElement) return false;
return this.videoElement.play()
.then(() => true)
.catch(err => {
console.warn('[VideoPlayer] Play failed:', err);
return false;
});
.catch(() => false);
},
pause() {
@@ -165,6 +140,7 @@
if (this.loadVideo(this.currentPage)) {
Pagination.updateState(this.currentPage);
Pagination.updateLink(this.currentPage);
this.play();
}
}
@@ -179,18 +155,14 @@
init() {
this.paginationElement = Utils.$(CONFIG.SELECTORS.pagination);
if (!this.paginationElement) {
console.warn('[Pagination] Pagination element not found');
return;
}
if (!this.paginationElement) return;
this.items = Utils.$$(CONFIG.SELECTORS.paginationItem);
this.setupClickHandlers();
this.show();
},
setupClickHandlers() {
// 이벤트 위임 사용
Utils.safeAddEventListener(
this.paginationElement,
'click',
@@ -199,14 +171,12 @@
},
handleClick(e) {
// 클릭된 요소 또는 그 부모 요소에서 data-page 속성을 찾음
let target = e.target.closest('[data-page]');
let target = e.target.closest('button[data-page]');
// 만약 li 요소를 직접 클릭한 경우, 그 안의 div를 찾음
if (!target) {
const liElement = e.target.closest('li');
if (liElement) {
target = liElement.querySelector('[data-page]');
target = liElement.querySelector('button[data-page]');
}
}
@@ -234,18 +204,18 @@
updateState(pageNum) {
if (!this.items || !Utils.isValidPageNum(pageNum)) return;
// 모든 아이템에서 활성 클래스 제거
this.items.forEach(item => {
item.classList.remove(CONFIG.CLASSES.pageOn);
item.removeAttribute('aria-current');
});
// 해당 페이지 아이템에 활성 클래스 추가
const targetItem = Array.from(this.items).find(item =>
item.classList.contains(`page-0${pageNum}`)
);
if (targetItem) {
targetItem.classList.add(CONFIG.CLASSES.pageOn);
targetItem.setAttribute('aria-current', 'page');
}
},
@@ -255,14 +225,17 @@
const linkUrl = CONFIG.PAGE_LINKS[pageNum];
const linkElement = Utils.$(CONFIG.SELECTORS.videoLink);
if (linkElement && linkUrl) {
linkElement.href = linkUrl;
}
if (!linkElement || !linkUrl) return;
linkElement.href = linkUrl;
linkElement.setAttribute('href', linkUrl);
},
show() {
if (this.paginationElement) {
this.paginationElement.style.display = '';
this.paginationElement.style.display = 'block';
this.paginationElement.style.visibility = 'visible';
this.paginationElement.style.opacity = '1';
}
},
@@ -285,7 +258,6 @@
if (visited) {
this.hideIntro(intro, mainMask);
} else {
// 첫 방문 시 세션 스토리지에 저장
setTimeout(() => {
try {
sessionStorage.setItem(CONFIG.VISITED_STORAGE_KEY, 'true');
@@ -297,12 +269,8 @@
},
hideIntro(intro, mainMask) {
if (intro) {
intro.style.display = 'none';
}
if (mainMask) {
mainMask.classList.add(CONFIG.CLASSES.skip);
}
if (intro) intro.style.display = 'none';
if (mainMask) mainMask.classList.add(CONFIG.CLASSES.skip);
}
};
@@ -313,13 +281,9 @@
cursorTextElement: null,
init() {
// 메인 페이지인지 확인 (.wrap.main 클래스 존재 여부)
const mainElement = Utils.$(CONFIG.SELECTORS.main);
if (!mainElement) {
return;
}
if (!mainElement) return;
// 커서 따라다니는 텍스트 요소 생성
this.cursorTextElement = document.createElement('div');
this.cursorTextElement.textContent = 'Click!';
this.cursorTextElement.style.cssText = `
@@ -335,17 +299,14 @@
opacity: 0;
`;
document.body.appendChild(this.cursorTextElement);
this.setupEventListeners();
},
setupEventListeners() {
// 마우스 움직임 추적
document.addEventListener('mousemove', (e) => {
this.handleMouseMove(e);
});
// 마우스가 페이지를 벗어나면 숨김
document.addEventListener('mouseleave', () => {
if (this.cursorTextElement) {
this.cursorTextElement.style.opacity = '0';
@@ -362,23 +323,21 @@
handleMouseMove(e) {
if (!this.cursorTextElement) return;
// 마우스 위치의 요소 확인
const elementUnderMouse = document.elementFromPoint(e.clientX, e.clientY);
// 특정 영역인지 확인
const isInFooter = elementUnderMouse?.closest('footer');
const isInPopup = elementUnderMouse?.closest('.popup_wrap') || elementUnderMouse?.closest('.popup-wrap');
const isInFloating = elementUnderMouse?.closest('.floating_menu');
const isInPagination = elementUnderMouse?.closest('.main-pagination');
const isInHeader = elementUnderMouse?.closest('.header');
const isInSitemap = elementUnderMouse?.closest('.popup_sitemap') || elementUnderMouse?.closest('.sitemap');
const hideAreas = [
elementUnderMouse?.closest('footer'),
elementUnderMouse?.closest('.popup_wrap'),
elementUnderMouse?.closest('.popup-wrap'),
elementUnderMouse?.closest('.floating-menu'),
elementUnderMouse?.closest('.main-pagination'),
elementUnderMouse?.closest('.header'),
elementUnderMouse?.closest('.sitemap')
];
// 특정 영역이면 숨김
if (isInFooter || isInPopup || isInFloating || isInPagination || isInHeader || isInSitemap) {
if (hideAreas.some(area => area)) {
this.cursorTextElement.style.opacity = '0';
} else {
this.cursorTextElement.style.opacity = '1';
// 커서 오른쪽 아래에 위치
this.cursorTextElement.style.left = (e.clientX + 15) + 'px';
this.cursorTextElement.style.top = (e.clientY + 15) + 'px';
}
@@ -398,17 +357,13 @@
this.mainElement = Utils.$(CONFIG.SELECTORS.main);
this.footerCloseElement = Utils.$(CONFIG.SELECTORS.footerClose);
if (!this.footerElement || !this.mainElement) {
console.warn('[FooterController] Required elements not found');
return;
}
if (!this.footerElement || !this.mainElement) return;
this.setupMousewheelHandler();
this.setupCloseHandler();
},
setupMousewheelHandler() {
// jQuery mousewheel 이벤트 대신 wheel 이벤트 사용
Utils.safeAddEventListener(
this.mainElement,
'wheel',
@@ -418,8 +373,6 @@
},
handleWheel(e) {
// deltaY가 양수면 아래로 스크롤 (footer 표시 - on 클래스 추가)
// deltaY가 음수면 위로 스크롤 (footer 숨김 - on 클래스 제거)
if (e.deltaY > 0) {
this.show();
} else if (e.deltaY < 0) {
@@ -442,7 +395,6 @@
this.footerElement.classList.add(CONFIG.CLASSES.footerOn);
// footerOff가 유효한 클래스 이름인 경우에만 제거
const footerOff = CONFIG.CLASSES.footerOff.trim();
if (footerOff) {
this.footerElement.classList.remove(footerOff);
@@ -452,7 +404,6 @@
hide() {
if (!this.footerElement) return;
// footerOff가 유효한 클래스 이름인 경우에만 추가
const footerOff = CONFIG.CLASSES.footerOff.trim();
if (footerOff) {
this.footerElement.classList.add(footerOff);
@@ -467,7 +418,6 @@
// ============================================
const MainController = {
init() {
// DOM이 준비되면 초기화
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
this.start();
@@ -478,40 +428,64 @@
},
start() {
// Video Player 초기화
if (!VideoPlayer.init()) {
console.warn('[MainController] Video player initialization failed');
return;
}
// 초기 비디오 일시정지 및 페이지네이션 숨김
VideoPlayer.pause();
Pagination.hide();
// 방문 여부 확인 및 비디오 재생
const visited = sessionStorage.getItem(CONFIG.VISITED_STORAGE_KEY);
if (visited) {
this.startVideoPlayback();
} else {
setTimeout(() => {
this.startVideoPlayback();
}, CONFIG.INTRO_DELAY);
}
if (!VideoPlayer.init()) return;
// 모듈 초기화
Pagination.init();
Pagination.updateLink(VideoPlayer.currentPage);
Pagination.updateState(VideoPlayer.currentPage);
IntroController.init();
FooterController.init();
CursorTextController.init();
// 초기 링크 설정
Pagination.updateLink(VideoPlayer.currentPage);
// 비디오 재생 시작
this.startVideoPlayback();
},
startVideoPlayback() {
VideoPlayer.play();
Pagination.show();
const introElement = Utils.$(CONFIG.SELECTORS.intro);
const hasIntro = introElement && introElement.offsetParent !== null;
const visited = sessionStorage.getItem(CONFIG.VISITED_STORAGE_KEY);
const playVideo = () => {
if (!VideoPlayer.videoElement.paused) return;
const currentSrc = VideoPlayer.videoElement.currentSrc || VideoPlayer.videoElement.src;
if (!currentSrc || currentSrc === '') {
VideoPlayer.loadVideo(1);
}
VideoPlayer.play().catch(() => {
// 재생 실패 시 재시도
let retryCount = 0;
const maxRetries = 10;
const retry = () => {
if (retryCount >= maxRetries || !VideoPlayer.videoElement.paused) return;
retryCount++;
VideoPlayer.play().catch(() => {
setTimeout(retry, 500);
});
};
setTimeout(retry, 500);
});
};
const tryAutoPlay = () => {
if (VideoPlayer.videoElement.readyState >= 1) {
playVideo();
} else {
VideoPlayer.videoElement.addEventListener('loadedmetadata', playVideo, { once: true });
}
};
// Intro가 없으면 즉시 재생, 있으면 방문 여부에 따라 처리
if (!hasIntro) {
tryAutoPlay();
} else if (visited) {
tryAutoPlay();
} else {
setTimeout(tryAutoPlay, CONFIG.INTRO_DELAY);
}
}
};

316
kngil/js/layout-fix.js Normal file
View File

@@ -0,0 +1,316 @@
/**
* Layout Fix Left Controller
* 스크롤 기반 타이틀 전환 및 섹션 네비게이션 공통 모듈
* analysis, primary 페이지에서 공통 사용
*/
(function() {
'use strict';
// ============================================
// Configuration
// ============================================
const DEFAULT_CONFIG = {
SELECTORS: {
titles: '.js-fixLeft-tit > li',
sections: '.js-fixLeft-secs > article, .js-fixLeft-secs > div'
},
SCROLL: {
triggerLine: 'center center',
offsetY: 100,
bottomThreshold: 30
},
ANIMATION: {
duration: 0.6
}
};
// ============================================
// Utility Functions
// ============================================
const Utils = {
$(selector) {
return document.querySelector(selector);
},
$$(selector) {
return document.querySelectorAll(selector);
}
};
// ============================================
// Layout Fix Controller Class
// ============================================
class LayoutFixController {
constructor(config = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.keySections = [];
this.keyData = [];
this.lastIndex = 0;
}
/**
* 초기화
* @param {string|Array} keySelector - key 섹션 선택자 (문자열 또는 배열)
*/
init(keySelector) {
// GSAP 및 ScrollTrigger 확인
if (typeof gsap === 'undefined' || typeof ScrollTrigger === 'undefined') {
console.warn('[LayoutFixController] GSAP or ScrollTrigger not loaded');
return;
}
gsap.registerPlugin(ScrollTrigger);
if (typeof ScrollToPlugin !== 'undefined') {
gsap.registerPlugin(ScrollToPlugin);
}
// key 섹션 찾기
if (Array.isArray(keySelector)) {
this.keySections = keySelector.map(sel => Utils.$(sel)).filter(Boolean);
} else {
const section = Utils.$(keySelector);
if (section) {
this.keySections = [section];
}
}
if (this.keySections.length === 0) return;
this.setupKeyData();
this.initScrollTriggers();
this.initClickHandlers();
this.setInitialState();
}
/**
* key 섹션 데이터 설정
*/
setupKeyData() {
this.keyData = this.keySections.map((keyEl) => {
const titles = keyEl.querySelectorAll(this.config.SELECTORS.titles);
const sections = keyEl.querySelectorAll(this.config.SELECTORS.sections);
return { keyEl, titles, sections };
});
// 유효성 검사
const isValid = this.keyData.every(
(data) => data.titles.length > 0 && data.sections.length > 0
);
if (!isValid) {
console.warn('[LayoutFixController] Invalid key data structure');
return;
}
// 마지막 인덱스 계산
if (this.keyData.length > 0) {
const lastKeyData = this.keyData[this.keyData.length - 1];
this.lastIndex = lastKeyData.sections.length - 1;
}
}
/**
* 스크롤 트리거 초기화
*/
initScrollTriggers() {
this.keyData.forEach(({ titles, sections }, keyIndex) => {
if (!titles || !sections) return;
// 각 섹션: 화면 중앙에 올 때(center center) 해당 li.on
sections.forEach((section, sectionIndex) => {
if (!section) return;
ScrollTrigger.create({
trigger: section,
start: this.config.SCROLL.triggerLine, // 'center center' = 섹션 중앙이 뷰포트 중앙에 올 때
onEnter: () => this.updateTitle(titles, sectionIndex),
onEnterBack: () => this.updateTitle(titles, sectionIndex),
onLeaveBack: () => {
const prevIndex = sectionIndex > 0 ? sectionIndex - 1 : 0;
this.updateTitle(titles, prevIndex);
}
});
});
// 마지막 섹션: 페이지 하단 도달 시 활성화 (마지막 key 섹션만)
const isLastKey = keyIndex === this.keyData.length - 1;
if (isLastKey && sections.length > 0) {
ScrollTrigger.create({
trigger: sections[sections.length - 1],
start: 'bottom bottom',
onEnter: () => this.updateTitle(titles, titles.length - 1)
});
}
});
// 스크롤 리프레시 시 초기 상태 설정
ScrollTrigger.addEventListener('refresh', () => {
setTimeout(() => this.setInitialState(), 100);
});
ScrollTrigger.refresh();
// 스크롤 시: right(.js-fixLeft-secs) 내 섹션이 화면 중앙에 가장 가까울 때 해당 li.on
const updateActiveByCenter = () => {
const viewportCenter = window.innerHeight / 2;
const atBottom = this.isAtBottom();
this.keyData.forEach(({ titles, sections }, keyIndex) => {
if (!titles || !sections.length) return;
let activeIndex = 0;
const isLastKey = keyIndex === this.keyData.length - 1;
if (atBottom && isLastKey) {
activeIndex = titles.length - 1;
} else {
let closestDistance = Infinity;
sections.forEach((section, index) => {
if (!section) return;
const rect = section.getBoundingClientRect();
const sectionCenter = rect.top + rect.height / 2;
const distance = Math.abs(sectionCenter - viewportCenter);
if (distance < closestDistance) {
closestDistance = distance;
activeIndex = index;
}
});
}
this.updateTitle(titles, activeIndex);
});
};
window.addEventListener('scroll', () => {
updateActiveByCenter();
}, { passive: true });
}
/**
* 클릭 핸들러 초기화
*/
initClickHandlers() {
this.keyData.forEach(({ titles, sections }) => {
if (!titles || !sections) return;
titles.forEach((title, index) => {
title.addEventListener('click', () => {
this.scrollToSection(sections, titles, index);
});
// 키보드 접근성
title.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.scrollToSection(sections, titles, index);
}
});
});
});
}
/**
* 타이틀 업데이트
*/
updateTitle(titles, activeIndex) {
if (!titles || !titles.length) return;
titles.forEach((title, index) => {
const isActive = index === activeIndex;
title.classList.toggle('on', isActive);
title.setAttribute('aria-selected', isActive ? 'true' : 'false');
title.setAttribute('tabindex', isActive ? '0' : '-1');
});
}
/**
* 섹션으로 스크롤
*/
scrollToSection(sections, titles, index) {
const section = sections[index];
if (!section) return;
// 섹션 클래스명 찾기 (analysis: spatial01, statistics01 등 / primary: sec-area-input 등)
const sectionClass = Array.from(section.classList).find((c) =>
/^(spatial|statistics|attribute)\d+$/.test(c) || c.startsWith('sec-')
);
if (typeof ScrollToPlugin !== 'undefined' && sectionClass) {
gsap.to(window, {
duration: this.config.ANIMATION.duration,
scrollTo: {
y: '.' + sectionClass,
offsetY: this.config.SCROLL.offsetY
}
});
} else {
section.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
this.updateTitle(titles, index);
}
/**
* 초기 상태 설정
*/
setInitialState() {
const atBottom = this.isAtBottom();
this.keyData.forEach(({ titles, sections }, keyIndex) => {
if (!titles || !sections) return;
let activeIndex = 0;
// 마지막 key 섹션이고 페이지 하단이면 마지막 타이틀 활성화
const isLastKey = keyIndex === this.keyData.length - 1;
if (atBottom && isLastKey) {
activeIndex = titles.length - 1;
} else {
const viewportCenter = window.innerHeight / 2;
let closestIndex = 0;
let closestDistance = Infinity;
// 각 섹션의 중앙점과 뷰포트 중앙의 거리를 계산
sections.forEach((section, index) => {
if (!section) return;
const rect = section.getBoundingClientRect();
const sectionCenter = rect.top + (rect.height / 2);
const distance = Math.abs(sectionCenter - viewportCenter);
// 섹션이 화면에 보이는 경우에만 고려
if (rect.top < window.innerHeight && rect.bottom > 0) {
if (distance < closestDistance) {
closestDistance = distance;
closestIndex = index;
}
}
// 섹션의 상단이 뷰포트 중앙을 지나갔으면 해당 인덱스로 설정
if (rect.top <= viewportCenter && rect.bottom > viewportCenter) {
activeIndex = index;
}
});
// 가장 가까운 섹션이 있으면 그것을 사용
if (closestDistance < Infinity) {
activeIndex = closestIndex;
}
}
this.updateTitle(titles, activeIndex);
});
}
/**
* 페이지 하단 여부 확인
*/
isAtBottom() {
return (
window.scrollY + window.innerHeight >=
document.documentElement.scrollHeight - this.config.SCROLL.bottomThreshold
);
}
}
// ============================================
// Export
// ============================================
window.LayoutFixController = LayoutFixController;
})();

63
kngil/js/pop_temp.js Normal file
View File

@@ -0,0 +1,63 @@
/**
* pop_temp.html - URL 파라미터로 팝업 오픈
* CSP 대응: 인라인 스크립트 대신 외부 파일로 분리
*/
(function () {
function getParam(name) {
var m = new RegExp('[?&]' + name + '=([^&#]*)').exec(location.search);
return m ? decodeURIComponent(m[1]) : '';
}
var popMap = {
login: '#pop_login',
login2: '#pop_login2',
join: '#pop_join',
join2: '#pop_join2',
join3: '#pop_join3',
agreement: '#pop_agreement',
mypage01: '#pop_mypage01',
mypage02: '#pop_mypage02',
mypage03: '#pop_mypage03',
mypage04: '#pop_mypage04',
mypage05: '#pop_mypage05',
mypage06: '#pop_mypage06',
cancel: '#pop_cancel',
search: '#pop_search',
password: '#pop_password',
privacy: '#pop_privacy'
};
var retryCount = 0;
var RETRY_MAX = 20;
function init() {
var pop = getParam('pop').toLowerCase();
var tab = getParam('tab').toLowerCase();
if (!pop) return;
if (typeof popupManager === 'undefined' || !popupManager.open) {
if (retryCount < RETRY_MAX) {
retryCount += 1;
setTimeout(init, 30);
}
return;
}
if (pop === 'privacy') {
if (popupManager.openPrivacy) {
popupManager.openPrivacy(tab === 'agreement' ? 'agreement' : 'privacy');
}
return;
}
var sel = popMap[pop];
if (sel) popupManager.open(sel);
}
if (typeof $ !== 'undefined' && $.fn && $.fn.jquery) {
$(init);
} else {
document.addEventListener('DOMContentLoaded', init);
}
})();

43
kngil/js/primary.js Normal file
View File

@@ -0,0 +1,43 @@
/**
* Primary Page Controller
* 스크롤 기반 타이틀 전환 및 섹션 네비게이션
* LayoutFixController 공통 모듈 사용
*/
(function() {
'use strict';
// ============================================
// Configuration
// ============================================
const CONFIG = {
SELECTORS: {
keySection: '.primary .key'
}
};
// ============================================
// Layout Fix Controller Instance
// ============================================
let layoutFixController = null;
// ============================================
// Initialization
// ============================================
const init = () => {
// LayoutFixController가 로드되었는지 확인
if (typeof LayoutFixController === 'undefined') {
console.warn('[Primary] LayoutFixController not loaded');
return;
}
// 공통 모듈 초기화 (1개 key 섹션)
layoutFixController = new LayoutFixController();
layoutFixController.init(CONFIG.SELECTORS.keySection);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

309
kngil/js/provided.js Normal file
View File

@@ -0,0 +1,309 @@
/**
* Provided Page Controller
* 스크롤 기반 애니메이션 및 네비게이션 제어
*/
(function() {
'use strict';
// ============================================
// Configuration
// ============================================
const CONFIG = {
SELECTORS: {
fixLeftTit: '.js-fixLeft-tit',
fixLeftTitItems: '.js-fixLeft-tit > li',
fixLeftBg: '.js-fixLeft-bg',
fixLeftSecs: '.js-fixLeft-secs',
route: '.route'
},
ANIMATION: {
bgScale: 1,
titScale: 0.7,
titTranslate: '-47%',
duration: 0.5
}
};
// ============================================
// Utility Functions
// ============================================
const Utils = {
$(selector) {
return document.querySelector(selector);
},
$$(selector) {
return document.querySelectorAll(selector);
}
};
// ============================================
// Smooth Scroll Function
// ============================================
window.goto = function(id) {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: 'smooth' });
}
};
// ============================================
// FixLeft Controller (Scroll-based Animation)
// ============================================
const FixLeftController = {
titElements: null,
bgElements: null,
sections: null,
init() {
const titRoot = Utils.$(CONFIG.SELECTORS.fixLeftTit);
if (!titRoot) {
RouteController.init();
return;
}
// GSAP 및 ScrollTrigger 확인
if (typeof gsap === 'undefined' || typeof ScrollTrigger === 'undefined') {
console.warn('[FixLeftController] GSAP or ScrollTrigger not loaded');
RouteController.init();
return;
}
gsap.registerPlugin(ScrollTrigger);
this.titElements = Utils.$$(CONFIG.SELECTORS.fixLeftTitItems);
this.bgElements = Utils.$$(CONFIG.SELECTORS.fixLeftBg);
this.sections = Utils.$$(`${CONFIG.SELECTORS.fixLeftSecs} > div, ${CONFIG.SELECTORS.fixLeftSecs} > section`);
this.setupScrollTriggers();
this.setupClickHandlers();
},
setupScrollTriggers() {
this.sections.forEach((section, index) => {
if (!section) return;
ScrollTrigger.create({
trigger: section,
start: 'top center',
onEnter: () => this.updateElements(index),
onLeaveBack: () => this.updateElements(index)
});
});
},
updateElements(activeIndex) {
// 배경 애니메이션
this.bgElements.forEach((bg, index) => {
const isActive = index === activeIndex;
bg.classList.toggle('on', isActive);
this.setBgActive(bg, isActive);
});
// 타이틀 애니메이션 및 접근성
this.titElements.forEach((tit, index) => {
const isActive = index === activeIndex;
tit.classList.toggle('on', isActive);
tit.setAttribute('aria-selected', isActive ? 'true' : 'false');
tit.setAttribute('tabindex', isActive ? '0' : '-1');
this.setTitActive(tit, isActive);
});
},
setupClickHandlers() {
if (!this.titElements.length || !this.sections.length) return;
this.titElements.forEach((title, index) => {
title.addEventListener('click', () => {
this.scrollToSection(index);
});
title.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.scrollToSection(index);
}
});
});
},
scrollToSection(index) {
const section = this.sections[index];
if (!section) return;
section.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
this.updateElements(index);
},
setBgActive(element, active) {
gsap.to(element, {
transform: active ? `scale(${CONFIG.ANIMATION.bgScale})` : 'scale(1)',
duration: CONFIG.ANIMATION.duration
});
},
setTitActive(element, active) {
gsap.to(element, {
opacity: active ? 1 : 0.5,
transform: active
? 'scale(1) translate(0%, 0%)'
: `scale(${CONFIG.ANIMATION.titScale}) translate(${CONFIG.ANIMATION.titTranslate}, 0%)`,
duration: CONFIG.ANIMATION.duration
});
}
};
// ============================================
// Route Controller (Intersection Observer)
// ============================================
const RouteController = {
routeElement: null,
sections: null,
tabs: null,
subs: null,
imgs: null,
observer: null,
init() {
this.routeElement = Utils.$(CONFIG.SELECTORS.route);
if (!this.routeElement) return;
this.sections = this.routeElement.querySelectorAll('#sec1, #sec2, #sec3');
this.tabs = this.routeElement.querySelectorAll('.tabs .tabs-li');
this.subs = this.routeElement.querySelectorAll('.subs li');
this.imgs = this.routeElement.querySelectorAll('.imgs li');
if (this.sections.length === 0) return;
this.setupObserver();
},
setupObserver() {
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
root: null,
rootMargin: '0px',
threshold: 0.5
}
);
this.sections.forEach(section => {
this.observer.observe(section);
});
},
handleIntersection(entries) {
entries
.filter(entry => entry.isIntersecting)
.forEach(entry => {
const id = entry.target.id;
const index = id ? parseInt(id.replace('sec', ''), 10) - 1 : -1;
if (index < 0) return;
// 모든 그룹에 동일한 인덱스 적용
[this.tabs, this.subs, this.imgs].forEach(group => {
group.forEach((el, i) => {
el.classList.toggle('on', i === index);
});
});
});
}
};
// ============================================
// Initialization
// ============================================
const init = () => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
FixLeftController.init();
});
} else {
FixLeftController.init();
}
};
init();
})();
/**
* Data Provision - Responsive Offset Path
* .data-bullet 요소의 offset-path를 화면 크기에 맞춰 동적으로 업데이트
*/
(function() {
'use strict';
// 반응형 offset-path 업데이트 함수
function updateOffsetPath() {
const container = document.querySelector('.provided .data-provision');
const bullets = document.querySelectorAll('.data-bullet');
if (!container || bullets.length === 0) return;
// 컨테이너의 실제 너비와 높이 가져오기
const containerWidth = container.offsetWidth;
const containerHeight = container.offsetHeight;
// 원본 비율 (720 x 270)
const originalWidth = 720;
const originalHeight = 270;
const originalRadius = 135;
// 실제 크기에 맞춰 계산
const radius = (containerWidth / originalWidth) * originalRadius;
const width = containerWidth;
const height = containerHeight;
// SVG path 생성 (둥근 사각형 형태)
const pathData = `M ${radius},0 L ${width - radius},0 A ${radius} ${radius} 0 0 1 ${width} ${radius} A ${radius} ${radius} 0 0 1 ${width - radius} ${height} L ${radius},${height} A ${radius} ${radius} 0 0 1 0 ${radius} A ${radius} ${radius} 0 0 1 ${radius} 0 Z`;
// 모든 bullet 요소에 적용
bullets.forEach(bullet => {
bullet.style.offsetPath = `path("${pathData}")`;
});
}
// ResizeObserver를 사용한 반응형 처리
function initResponsiveOffsetPath() {
const container = document.querySelector('.provided .data-provision');
if (!container) {
console.warn('Data provision container not found');
return;
}
// ResizeObserver 생성 (성능 최적화)
const resizeObserver = new ResizeObserver(entries => {
// requestAnimationFrame으로 성능 최적화
requestAnimationFrame(() => {
updateOffsetPath();
});
});
// 컨테이너 관찰 시작
resizeObserver.observe(container);
// 초기 실행
updateOffsetPath();
}
// DOM 로드 완료 후 초기화
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initResponsiveOffsetPath);
} else {
initResponsiveOffsetPath();
}
// 폰트 로드 완료 후 재계산 (레이아웃 변경 가능성)
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => {
setTimeout(updateOffsetPath, 100);
});
}
})();

350
kngil/js/results.js Normal file
View File

@@ -0,0 +1,350 @@
/**
* Results Page Controller
* 탭 전환 및 리포트 페이지 애니메이션
*
* @version 2.1.0 (Simplified)
*/
(function() {
'use strict';
// ============================================
// 설정
// ============================================
const config = {
// 선택자
selectors: {
wrap: '.results-wrap',
tabs: '.tab-list li a',
panels: '.tab-content',
pages: '.report-page'
},
// 탭 ID
tabIds: ['key-natural', 'key-social', 'key-cost'],
// 애니메이션 설정
animation: {
delay: 100, // 각 페이지 간격 (ms)
duration: 600, // 애니메이션 지속 (ms)
distance: 120 // 시작 위치 (px)
},
// 스크롤 탭 전환
scrollTab: {
enabled: true,
distance: '150%' // 핀 유지 거리
}
};
// ============================================
// 유틸리티
// ============================================
const $ = (sel, ctx = document) => ctx.querySelector(sel);
const $$ = (sel, ctx = document) => ctx.querySelectorAll(sel);
const hasGSAP = () => typeof gsap !== 'undefined' && typeof ScrollTrigger !== 'undefined';
// Debounce
const debounce = (fn, ms) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
};
// Throttle
const throttle = (fn, ms) => {
let waiting = false;
return (...args) => {
if (!waiting) {
fn(...args);
waiting = true;
setTimeout(() => waiting = false, ms);
}
};
};
// ============================================
// 탭 컨트롤러
// ============================================
const TabController = {
wrap: null,
tabs: null,
panels: null,
init() {
this.wrap = $(config.selectors.wrap);
if (!this.wrap) return;
this.tabs = $$(config.selectors.tabs);
this.panels = $$(config.selectors.panels);
this.setupEvents();
},
setupEvents() {
this.tabs.forEach((tab, i) => {
// 클릭
tab.addEventListener('click', (e) => {
e.preventDefault();
this.switch(i);
});
// 키보드
tab.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.switch(i);
}
});
});
},
switch(index) {
const targetId = config.tabIds[index];
const target = document.getElementById(targetId);
if (!target) return;
// 모든 패널 숨김
this.panels.forEach(panel => {
if (panel !== target) {
panel.classList.remove('on');
Animation.reset(panel);
}
});
// 선택된 패널 표시
target.classList.add('on');
// 탭 상태 업데이트
this.tabs.forEach((tab, i) => {
const li = tab.closest('li');
li.classList.toggle('on', i === index);
tab.setAttribute('aria-selected', i === index);
});
// 애니메이션 실행
setTimeout(() => Animation.run(target), 50);
// 스크롤 탭 인덱스 동기화
if (ScrollTab.enabled) {
ScrollTab.currentIndex = index;
}
}
};
// ============================================
// 애니메이션
// ============================================
const Animation = {
animated: new Set(),
init() {
if (!hasGSAP()) return;
const panels = $$(config.selectors.panels);
// 각 패널에 스크롤 트리거 설정
panels.forEach(panel => {
ScrollTrigger.create({
trigger: panel,
start: 'top center',
onEnter: () => this.onEnter(panel),
onEnterBack: () => this.onEnter(panel)
});
});
// 초기 상태 확인
this.checkInitial();
},
onEnter(panel) {
if (!panel.classList.contains('on')) return;
this.run(panel);
},
checkInitial() {
const active = $('.tab-content.on');
if (!active) return;
const rect = active.getBoundingClientRect();
if (rect.top <= window.innerHeight / 2) {
this.run(active);
}
},
run(panel) {
const pages = $$(config.selectors.pages, panel);
if (!pages.length) return;
const { delay, duration, distance } = config.animation;
if (hasGSAP()) {
// GSAP 애니메이션
gsap.set(pages, { opacity: 0, y: distance });
gsap.to(pages, {
opacity: 1,
y: 0,
duration: duration / 1000,
stagger: delay / 1000,
ease: 'power2.out'
});
} else {
// CSS 애니메이션
pages.forEach((page, i) => {
page.style.cssText = `
opacity: 0;
transform: translateY(${distance}px);
transition: none;
`;
requestAnimationFrame(() => {
setTimeout(() => {
page.style.cssText = `
opacity: 1;
transform: translateY(0);
transition: opacity ${duration}ms ease-out,
transform ${duration}ms ease-out;
`;
}, i * delay);
});
});
}
this.animated.add(panel.id);
},
reset(panel) {
const pages = $$(config.selectors.pages, panel);
if (!pages.length) return;
const { distance } = config.animation;
if (hasGSAP()) {
gsap.set(pages, { opacity: 0, y: distance });
} else {
pages.forEach(page => {
page.style.cssText = `
opacity: 0;
transform: translateY(${distance}px);
transition: none;
`;
});
}
}
};
// ============================================
// 스크롤 탭
// ============================================
const ScrollTab = {
enabled: false,
currentIndex: -1,
trigger: null,
init() {
if (!config.scrollTab.enabled || !hasGSAP()) return;
const wrap = $(config.selectors.wrap);
if (!wrap) return;
const tabCount = config.tabIds.length;
this.trigger = ScrollTrigger.create({
trigger: wrap,
start: 'top top',
end: `+=${config.scrollTab.distance}`,
pin: true,
invalidateOnRefresh: true,
onUpdate: throttle((self) => {
const index = Math.min(
Math.floor(self.progress * tabCount),
tabCount - 1
);
if (index !== this.currentIndex && index >= 0) {
this.currentIndex = index;
TabController.switch(index);
}
}, 50)
});
this.enabled = true;
}
};
// ============================================
// 리사이즈 핸들러
// ============================================
const ResizeHandler = {
init() {
window.addEventListener('resize', debounce(() => {
if (hasGSAP()) {
ScrollTrigger.refresh();
}
}, 250));
}
};
// ============================================
// 페이지 로드
// ============================================
const PageLoad = {
init() {
// 스크롤 복원 차단
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
// 최상단으로
if (window.scrollY > 0) {
window.scrollTo(0, 0);
}
// 로딩 완료
window.addEventListener('load', () => {
document.body.classList.add('loaded');
if (hasGSAP()) {
ScrollTrigger.refresh();
}
});
}
};
// ============================================
// 초기화
// ============================================
const init = () => {
// 즉시 실행
PageLoad.init();
// DOM 준비 후
const start = () => {
if (hasGSAP()) {
gsap.registerPlugin(ScrollTrigger);
}
TabController.init();
Animation.init();
ScrollTab.init();
ResizeHandler.init();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
};
// ============================================
// 전역 API
// ============================================
window.ResultsPageController = {
switchTab: (i) => TabController.switch(i),
refresh: () => hasGSAP() && ScrollTrigger.refresh(),
config: config
};
// 시작
init();
})();

96
kngil/js/value.js Normal file
View File

@@ -0,0 +1,96 @@
/**
* Value Page Animation Controller
* Intersection Observer를 사용한 스크롤 애니메이션
*/
(function() {
'use strict';
// ============================================
// Configuration
// ============================================
const CONFIG = {
SELECTORS: {
animationTarget: '.js-ani'
},
OBSERVER: {
root: null,
rootMargin: '0px',
threshold: [0, 0.7]
},
ANIMATION: {
cardClass: 'card-ani',
lineClass: 'move-ani',
duration: 1200
}
};
// ============================================
// Animation Controller
// ============================================
const AnimationController = {
hasRun: false,
targetElement: null,
observer: null,
init() {
this.targetElement = document.querySelector(CONFIG.SELECTORS.animationTarget);
if (!this.targetElement) {
return;
}
this.setupObserver();
},
setupObserver() {
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
CONFIG.OBSERVER
);
this.observer.observe(this.targetElement);
},
handleIntersection(entries) {
entries.forEach(entry => {
if (!this.hasRun && entry.intersectionRatio >= 0.7) {
this.startAnimation();
this.hasRun = true;
this.observer.unobserve(this.targetElement);
}
});
},
startAnimation() {
// 카드 애니메이션 클래스 추가
this.targetElement.classList.add(CONFIG.ANIMATION.cardClass);
// 라인 애니메이션 클래스 추가
const linesElement = this.targetElement.querySelector('.lines');
if (linesElement) {
linesElement.classList.add(CONFIG.ANIMATION.lineClass);
}
// 애니메이션 종료 후 클래스 제거
setTimeout(() => {
this.targetElement.classList.remove(CONFIG.ANIMATION.cardClass);
}, CONFIG.ANIMATION.duration);
}
};
// ============================================
// Initialization
// ============================================
const init = () => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
AnimationController.init();
});
} else {
AnimationController.init();
}
};
init();
})();