20260205 업데이트(컨텐츠 페이지 연결)
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
108
kngil/js/analysis.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
1165
kngil/js/common.js
1165
kngil/js/common.js
File diff suppressed because one or more lines are too long
@@ -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
316
kngil/js/layout-fix.js
Normal 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
63
kngil/js/pop_temp.js
Normal 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
43
kngil/js/primary.js
Normal 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
309
kngil/js/provided.js
Normal 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
350
kngil/js/results.js
Normal 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
96
kngil/js/value.js
Normal 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();
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user