/************************************************************************** * 문자나라 공용 Progress / Loading Layer * * 파일명 : progress_layer.js * 버전 : Ver 1.00 * 수정일 : 2026.07.27 * * 사용 기능 * ------------------------------------------------------------------------ * ProgressLayer.loading(options) * - 진행률을 알 수 없는 작업에 사용 * - count를 전달하면 처리 건수를 표시 * - 완료 시 ProgressLayer.complete()를 호출하면 100% 진행바로 표시 * * ProgressLayer.progress(options) * - 진행률 또는 처리 건수를 표시하는 작업에 사용 * - progressType은 'percent' 또는 'count' * - estimatedSeconds를 직접 전달하면 해당 시간을 사용 * - estimatedSeconds를 생략하고 count, sampleCount, sampleSeconds를 * 전달하면 실제 측정값을 기준으로 예상 소요시간을 자동 계산 * - count가 1,000건 이하이면 예상 소요시간을 '즉시'로 표시 * - 소량 작업은 25%에서 대기하고, 일반 작업은 예상시간 동안 100%까지 진행 * - 일반 작업은 95~98%에서 인위적으로 멈추지 않음 * * ProgressLayer.update(options) * - 진행 중인 제목, 설명, 건수, 진행률 등을 갱신 * * ProgressLayer.setPercent(percent) * - 진행률을 퍼센트 기준으로 직접 갱신 * * ProgressLayer.complete(options) * - 작업 완료 상태로 변경 * - 진행바를 100%로 표시 * - 실제 소요시간 표시 * - 확인 버튼 활성화 * * ProgressLayer.fail(options) * - 작업 실패 상태로 변경 * - 오류 메시지를 빨간색으로 표시 * - 확인 버튼 활성화 * * ProgressLayer.close() * - 진행창 닫기 * * 기본 사용 예제 * ------------------------------------------------------------------------ * * 1. 진행률을 알 수 없는 작업 * * ProgressLayer.loading({ * title: '데이터 조회', * message: '데이터를 조회하고 있습니다.' * }); * * 2. 건수를 표시하는 Loading * * ProgressLayer.loading({ * title: '엑셀 파일 다운로드', * message: '엑셀 파일을 생성하고 있습니다.', * countLabel: '다운로드 건수', * count: row * }); * * 3. 예상시간이 있는 Progress * * ProgressLayer.progress({ * title: '엑셀 파일 다운로드', * message: '엑셀 파일을 생성하고 있습니다.', * countLabel: '다운로드 건수', * count: row, * sampleCount: 100000, * sampleSeconds: 5, * progressType: 'count' * }); * * - 위 예제는 100,000건 처리에 5초가 걸린 실제 측정값을 기준으로 * 현재 count의 예상 소요시간을 내부에서 자동 계산한다. * - 정확한 예상시간을 이미 알고 있다면 estimatedSeconds를 직접 전달한다. * * 4. 진행값 갱신 * * ProgressLayer.update(5000); * * 또는 * * ProgressLayer.setPercent(50); * * 5. 작업 완료 * * ProgressLayer.complete({ * message: '작업이 완료되었습니다.', * statusText: '' * }); * * 6. 작업 실패 * * ProgressLayer.fail({ * message: '작업 처리 중 오류가 발생했습니다.' * }); * **************************************************************************/ ((window, document) => { 'use strict'; const state = { mode: '', timer: null, done: false, startTime: 0, expectMs: 0, option: null, confirmCallback: null, progressType: 'percent', totalCount: 0, countSuffix: '건', percent: 0, allowEscape: false }; const ids = { layer: 'common_work_layer', box: 'common_work_box', title: 'common_work_title', countRow: 'common_work_count_row', countLabel: 'common_work_count_label', count: 'common_work_count', message: 'common_work_message', progressArea: 'common_work_progress_area', progressBar: 'common_work_progress_bar', progressTime: 'common_work_progress_time', progressValue: 'common_work_progress_value', loadingArea: 'common_work_loading_area', loadingText: 'common_work_loading_text', status: 'common_work_status', buttonArea: 'common_work_button_area', confirmButton: 'common_work_confirm_button' }; const getElement = id => document.getElementById(id); const setText = (id, value = '') => { const element = getElement(id); if (element) element.textContent = String(value ?? ''); }; const show = id => { const element = getElement(id); if (element) element.hidden = false; }; const hide = id => { const element = getElement(id); if (element) element.hidden = true; }; const formatNumber = value => { const number = Number.parseInt(value, 10); return Number.isFinite(number) && number > 0 ? number.toLocaleString('ko-KR') : '0'; }; const formatSeconds = seconds => { const value = Math.max(1, Math.ceil(Number.parseFloat(seconds) || 1)); if (value < 60) return `약 ${value}초`; const minute = Math.floor(value / 60); const remainSeconds = value % 60; return remainSeconds ? `약 ${minute}분 ${remainSeconds}초` : `약 ${minute}분`; }; /** * 실제 측정된 기준값을 이용하여 현재 작업의 예상 소요시간을 계산한다. * * 예: 100,000건 처리에 5초가 걸렸다면 * calcEstimatedSeconds(현재건수, 100000, 5) * * @param {number} currentCount 현재 처리할 건수 * @param {number} sampleCount 기준 테스트 건수 * @param {number} sampleSeconds 기준 테스트 소요시간(초) * @returns {number} 올림 처리된 예상 소요시간(초). 계산 불가 시 0 */ const calcEstimatedSeconds = ( currentCount, sampleCount, sampleSeconds ) => { const current = Number.parseFloat(currentCount); const sample = Number.parseFloat(sampleCount); const seconds = Number.parseFloat(sampleSeconds); if ( !Number.isFinite(current) || !Number.isFinite(sample) || !Number.isFinite(seconds) || current <= 0 || sample <= 0 || seconds <= 0 ) { return 0; } // 소량 작업은 예상 소요시간을 즉시로 표시한다. if (current <= 1000) { return 0; } return Math.max( 1, Math.ceil(current * seconds / sample) ); }; const screenDisabled = disabled => { if (typeof window.ScreenDisabled === 'function') { window.ScreenDisabled(disabled ? 1 : 0); } }; const clearTimer = () => { if (state.timer !== null) { window.clearInterval(state.timer); state.timer = null; } }; const createLayer = () => { if (getElement(ids.layer)) return; const layer = document.createElement('div'); layer.id = ids.layer; layer.className = 'progress-layer'; layer.hidden = true; layer.setAttribute('aria-hidden', 'true'); layer.innerHTML = ` `; document.body.appendChild(layer); document.addEventListener('keydown', event => { if ( event.key === 'Escape' && state.allowEscape && !getElement(ids.layer)?.hidden ) { close(); } }); }; const setBoxState = type => { const box = getElement(ids.box); if (!box) return; box.classList.remove('is-running', 'is-success', 'is-error'); box.classList.add(`is-${type}`); }; const resetLayer = () => { clearTimer(); Object.assign(state, { mode: '', done: false, startTime: 0, expectMs: 0, option: null, confirmCallback: null, progressType: 'percent', totalCount: 0, countSuffix: '건', percent: 0, allowEscape: false }); setBoxState('running'); hide(ids.countRow); hide(ids.progressArea); hide(ids.loadingArea); hide(ids.status); show(ids.buttonArea); show(ids.progressValue); show(ids.progressTime); const progressValue = getElement(ids.progressValue); if (progressValue) { progressValue.classList.remove('is-complete'); } setText(ids.title); setText(ids.message); setText(ids.countLabel); setText(ids.count); setText(ids.progressTime); setText(ids.progressValue, '0%'); setText(ids.loadingText); setText(ids.status); setText(ids.confirmButton, '확인'); const bar = getElement(ids.progressBar); if (bar) { bar.style.width = '0%'; bar.classList.remove('has-value'); } const track = getElement(ids.progressArea)?.querySelector('[role="progressbar"]'); if (track) track.setAttribute('aria-valuenow', '0'); const button = getElement(ids.confirmButton); if (button) { button.disabled = true; button.onclick = null; } }; const openLayer = option => { createLayer(); resetLayer(); state.allowEscape = option?.allowEscape === true; const layer = getElement(ids.layer); layer.hidden = false; layer.setAttribute('aria-hidden', 'false'); screenDisabled(true); }; const updateProgressText = percent => { if (state.progressType === 'count' && state.totalCount > 0) { let currentCount = Math.floor(state.totalCount * percent / 100); if (percent >= 100) currentCount = state.totalCount; currentCount = Math.min(Math.max(currentCount, 0), state.totalCount); setText( ids.progressValue, `${formatNumber(currentCount)}${state.countSuffix}` ); return; } setText(ids.progressValue, `${percent}%`); }; const setPercent = percent => { const value = Math.min( 100, Math.max(0, Number.parseInt(percent, 10) || 0) ); state.percent = value; updateProgressText(value); const bar = getElement(ids.progressBar); if (bar) { bar.style.width = `${value}%`; bar.classList.toggle('has-value', value > 0); } const track = getElement(ids.progressArea)?.querySelector('[role="progressbar"]'); if (track) track.setAttribute('aria-valuenow', String(value)); }; const startAutoProgress = () => { state.timer = window.setInterval(() => { if (state.done || state.mode !== 'progress') return; const elapsed = Date.now() - state.startTime; // 예상시간 동안 0%에서 99%까지 자연스럽게 진행한다. // 100%는 실제 complete()가 호출됐을 때만 표시한다. const percent = state.expectMs > 0 ? Math.min(99, Math.floor((elapsed / state.expectMs) * 99)) : 99; setPercent(percent); if (percent >= 99) { clearTimer(); } }, 100); }; const progress = (option = {}) => { openLayer(option); const totalCount = Math.max( 0, Number.parseInt(option.count, 10) || 0 ); /* * estimatedSeconds가 전달되면 해당 값을 우선 사용한다. * 생략된 경우 count, sampleCount, sampleSeconds가 모두 있으면 * calcEstimatedSeconds()로 실제 측정 비율에 따라 자동 계산한다. * * 1,000건 이하는 예상시간을 0초로 계산하여 '즉시'로 표시한다. * 계산 기준 자체가 없거나 올바르지 않은 경우에만 기본 2초를 사용한다. */ let estimatedSeconds; const hasSampleData = totalCount > 0 && Number.parseFloat(option.sampleCount) > 0 && Number.parseFloat(option.sampleSeconds) > 0; if (option.estimatedSeconds !== undefined) { estimatedSeconds = Math.max( 0, Number.parseFloat(option.estimatedSeconds) || 0 ); } else if (hasSampleData) { estimatedSeconds = calcEstimatedSeconds( totalCount, option.sampleCount, option.sampleSeconds ); } else { /* * 기준값이 없는 경우 기본 예상시간 2초를 사용한다. */ estimatedSeconds = 2; } Object.assign(state, { mode: 'progress', option, startTime: Date.now(), expectMs: estimatedSeconds * 1000, progressType: option.progressType === 'count' ? 'count' : 'percent', totalCount, countSuffix: option.countSuffix !== undefined ? String(option.countSuffix) : '건' }); setText(ids.title, option.title || '작업 진행 중'); setText(ids.message, option.message || '데이터를 처리하고 있습니다.'); show(ids.progressArea); show(ids.progressValue); show(ids.progressTime); if (option.count !== undefined) { show(ids.countRow); setText(ids.countLabel, option.countLabel || '처리 건수'); setText(ids.count, `${formatNumber(totalCount)}${state.countSuffix}`); } setText( ids.progressTime, estimatedSeconds <= 0 ? `${option.timeLabel || '예상 소요시간'} : 즉시` : `${option.timeLabel || '예상 소요시간'} : ${formatSeconds(estimatedSeconds)}` ); if (estimatedSeconds <= 0) { /* * 1,000건 이하의 소량 작업은 예상시간을 '즉시'로 표시한다. * 진행바는 약 0.7초 동안 0%에서 99%까지 빠르게 증가한 뒤 * 실제 완료 신호가 올 때까지 99%에서 기다린다. */ setPercent(option.percent ?? 0); if (option.autoProgress !== false) { const quickStartTime = Date.now(); const quickDuration = 700; state.timer = window.setInterval(() => { if (state.done || state.mode !== 'progress') return; const elapsed = Date.now() - quickStartTime; const percent = Math.min( 99, Math.floor((elapsed / quickDuration) * 99) ); setPercent(percent); if (percent >= 99) { clearTimer(); } }, 30); } } else { /* * 대량 작업은 각 작업에서 전달한 sampleCount/sampleSeconds로 * 계산한 예상시간 동안 0%에서 99%까지 자연스럽게 진행한다. */ setPercent(option.percent ?? 0); if (option.autoProgress !== false) { startAutoProgress(); } } /* * 호출한 곳에서 동일한 예상시간을 * 보조 완료 타이머 등에 사용할 수 있도록 반환한다. */ return estimatedSeconds; }; const loading = (option = {}) => { openLayer(option); Object.assign(state, { mode: 'loading', option, startTime: Date.now() }); setText(ids.title, option.title || '처리 중'); setText(ids.message, option.message || '데이터를 처리하고 있습니다.'); setText(ids.loadingText, option.statusText || '잠시만 기다려주세요.'); show(ids.loadingArea); if (option.count !== undefined) { const suffix = option.countSuffix !== undefined ? String(option.countSuffix) : '건'; show(ids.countRow); setText(ids.countLabel, option.countLabel || '처리 건수'); setText(ids.count, `${formatNumber(option.count)}${suffix}`); } }; const update = (option = {}) => { if (!getElement(ids.layer) || getElement(ids.layer).hidden) return; if (option.title !== undefined) setText(ids.title, option.title); if (option.message !== undefined) setText(ids.message, option.message); if (option.countSuffix !== undefined) { state.countSuffix = String(option.countSuffix); } if (option.count !== undefined) { state.totalCount = Math.max( 0, Number.parseInt(option.count, 10) || 0 ); show(ids.countRow); setText( ids.count, `${formatNumber(state.totalCount)}${state.countSuffix}` ); if (state.mode === 'progress') { updateProgressText(state.percent); } } if (option.countLabel !== undefined) { setText(ids.countLabel, option.countLabel); } if ( option.progressType !== undefined && state.mode === 'progress' ) { state.progressType = option.progressType === 'count' ? 'count' : 'percent'; updateProgressText(state.percent); } if ( option.percent !== undefined && state.mode === 'progress' ) { setPercent(option.percent); } if (option.statusText !== undefined) { if (state.mode === 'loading') { setText(ids.loadingText, option.statusText); } else { setText(ids.progressTime, option.statusText); } } }; const setStatus = (statusText, defaultText) => { if (statusText === '') { hide(ids.status); setText(ids.status); return; } show(ids.status); setText(ids.status, statusText ?? defaultText); }; const showConfirmButton = (option = {}) => { const button = getElement(ids.confirmButton); state.confirmCallback = typeof option.onConfirm === 'function' ? option.onConfirm : null; setText(ids.confirmButton, option.buttonText || '확인'); button.disabled = false; button.onclick = () => { const callback = state.confirmCallback; close(); if (typeof callback === 'function') { callback(); } }; window.setTimeout(() => button.focus(), 0); }; const complete = (option = {}) => { if ( !getElement(ids.layer) || getElement(ids.layer).hidden || state.done ) { return; } state.done = true; clearTimer(); setBoxState('success'); /* * progress/loading 구분 없이 완료 상태는 동일하게 표시한다. * 진행바를 100%로 채우고 진행바 중앙에 100%를 고정 표시한 뒤 * 실제 소요시간을 표시한다. */ hide(ids.loadingArea); show(ids.progressArea); show(ids.progressTime); setPercent(100); const progressValue = getElement(ids.progressValue); if (progressValue) { progressValue.textContent = '100%'; progressValue.hidden = false; progressValue.classList.add('is-complete'); } const elapsedSeconds = Math.max( 1, Math.ceil((Date.now() - state.startTime) / 1000) ); setText( ids.progressTime, `${option.timeLabel || '소요시간'} : ${formatSeconds(elapsedSeconds)}` ); setText(ids.title, option.title || '작업 완료'); setText(ids.message, option.message || '작업이 완료되었습니다.'); setStatus( option.statusText, '확인 버튼을 눌러주세요.' ); showConfirmButton(option); }; const fail = (option = {}) => { if (!getElement(ids.layer) || getElement(ids.layer).hidden) return; state.done = true; clearTimer(); setBoxState('error'); hide(ids.loadingArea); hide(ids.progressArea); setText(ids.title, option.title || '처리 오류'); setText(ids.message, option.message || '처리 중 오류가 발생하였습니다.'); setStatus( option.statusText, '잠시 후 다시 시도해주세요.' ); showConfirmButton(option); }; function close() { clearTimer(); state.done = true; const layer = getElement(ids.layer); if (layer) { layer.hidden = true; layer.setAttribute('aria-hidden', 'true'); } screenDisabled(false); } window.ProgressLayer = { progress, loading, update, setPercent, complete, fail, close, formatSeconds, formatNumber }; })(window, document);