PageSourceSearch

https://ssonda.ai/assets/js/pages/site/home.js;jsessionid=B2ACCAF172821ABB729E90E02C9B1EAB?v=1790900716717

js ssonda.ai collected 2026-10-02 00:27:10 UTC 38,992 bytes, 855 lines download raw bytes

1// DOM 로드 후 실행
2document.addEventListener("DOMContentLoaded", async () => {
3    let container = document.querySelector(".app-header");
4
5    document.addEventListener("click", (e) => {
6        let targetEl = e.target;
7
8        if (targetEl.closest(".move-shop-add")) {
9            e.preventDefault();
10            e.stopImmediatePropagation();
11
12            moveToShopAdd(targetEl);
13        }
14
15        if (targetEl.closest('.auto-reply-setting')) {
16            e.preventDefault();
17            showAutoReplyConfirmModal(targetEl.closest('.auto-reply-setting'));
18        }
19    });
20
21    // 자동 댓글 ON/OFF 상태는 sendHttpGetReviewSummary()에서 요약과 함께 렌더 (placeholder 동시 제거)
22    // 남은 바로댓글 개수 프로그레스바 (홈 카드)
23    renderHomeRemainCount();
24
25    // e캐시 카드 초기화 (카드 미출력(기능 OFF) 시 내부에서 즉시 종료)
26    initEcashCard();
27
28    waitForI18nInitialization(() => {
29        if (container?.dataset?.resultCode == "W2016") {
30            let modal = new DynamicModal(
31                i18n.t('shop.noItemTitle'),   // '사업장을 변경해 주세요'
32                i18n.t('shop.noItemMessage') // '사업장이 등록되어있지 않거나 삭제되었습니다.'
33            );
34    
35            modal.setModalSize(MODAL_SIZE_SM);
36            modal.removeCancelButton();
37            modal.setOptions({allowClose: false});
38            modal.setConfirmButton(i18n.t('confirm'), () => {
39                let shopListModal = document.querySelector("#shopListModal");
40    
41                modal.hide();
42                if (shopListModal) {
43                    let modalInstance = bootstrap.Modal.getOrCreateInstance(shopListModal);
44
45                    // 무조건 변경해야하므로 닫기 버튼 제거
46                    shopListModal.querySelector('.btn-close')?.remove();
47                    modalInstance.show();
48                }
49            });
50    
51            modal.show();
52        }
53
54        if (document.querySelector('.has-subscribe')) {
55            sendHttpGetReviewSummary();
56        }
57    });
58
59    // 가게 변경 모달 띄울때 이벤트 (전환 실패 시 목록 갱신용으로 재호출 가능)
60    async function loadShopListModal(e) {
61        let modal = e.target;
62
63        if (document.querySelector('#homeContainer')) {
64            modal.querySelector('.btn-home').classList.add('d-none');
65        }
66
67        let sendHttp = new SendHttp('/shop/listJson');
68        sendHttp.data = { allItem: '1' };
69        sendHttp.isStoreData = true;
70        sendHttp.storeExpireMs = 1000 * 60; // 사업장 목록은 상태/정보가 서버측에서 바뀔 수 있어 60초로 짧게
71
72        JsLoadingOverlay.setOptions(commonLoaderOpitons);
73        JsLoadingOverlay.show({ containerID: 'shopListModalBody', overlayOpacity: 0.0, lockScroll: false }); // 모달에서 띄울때는 lockScroll을 false로 설정
74        let resultMap = await sendHttp.send();
75        JsLoadingOverlay.hide();
76
77        // 에러 처리
78        if (resultMap.code != RESULT_CODE_SUCCESS) {
79            errorHandling(resultMap.code, resultMap.message);
80            return;
81        }
82
83        let shopList = resultMap.list;
84        if (shopList == null || shopList.length == 0) {
85            // 결과가 없을 경우 (정상적으로는 발생하지 않음)
86            modal.querySelector('.modal-body').innerHTML = `<p class="text-center text-gray-600 mb-0 mt-3">${i18n.t('noList', {item: i18n.withJosa('shop.label', '가')})}</p>`;
87            return;
88        }
89
90        // 목록 생성
91        let shopListEl = document.createElement('div');
92        shopListEl.classList.add('list-group', 'list-group-flush', 'my-n3', 'pb-3');
93
94        let guideText = '<div class="text-gray-600 mb-6">사업장은 최대 5개의 사업장만 등록할 수 있어요.</div>';
95        shopListEl.innerHTML = guideText;
96
97        let currentShopId = container.dataset.shopId;
98        shopList.forEach((shop) => {
99            let shopEl = document.createElement('div');
100            shopEl.classList.add('list-group-item', 'list-group-item-action', 'w-auto', 'd-flex', 'justify-content-between', 'align-items-center', 'rounded-4', "border-0", "mx-n3", "cursor-pointer");
101            shopEl.dataset.shopId = shop.id;
102
103            shopEl.innerHTML = `
104                <div class="form-check form-check-custom form-check-sm form-check-solid d-flex align-items-start py-2 flex-grow-1 min-w-0">
105                    <input class="form-check-input mt-1" type="radio" name="shopI
105d" value="${shop.id}" id="shopId${shop.id}" ${shop.id == currentShopId ? "checked" : ""} />
106                    <label class="d-flex flex-column ms-3 min-w-0" for="shopId${shop.id}">
107                        <div class="d-flex align-items-center">
108                            <span class="fs-4 fw-bold">${shop.businessNumber}</span>
109                            <shop-status contract-status="${shop.contractStatus ?? 0}" amend-complete="${shop?.isCollectionAmendChanged ?? ''}" prepay-status="${shop.prepayStatus ?? 0}" terminate-status="${shop.terminateStatus}" size="sm"></shop-status>
110                        </div>
111                        <div class="text-gray-600 text-truncate">${shop.name}</div>
112                    </label>
113                </div>
114            `;
115
116            shopEl.addEventListener('click', async (e) => {
117                e.preventDefault();
118                e.stopImmediatePropagation();
119
120                let shopId = e.currentTarget.dataset.shopId;
121                // if (shopId == currentShopId) {
122                //   return;
123                // }
124
125                let sendHttp = new SendHttp('/shop/changeCurrentShop');
126                sendHttp.method = 'POST';
127                sendHttp.isNoDelay = true;
128                sendHttp.data = new FormData();
129                sendHttp.data.append('id', shopId);
130
131                JsLoadingOverlay.show();
132                let resultMap = await sendHttp.send();
133                JsLoadingOverlay.hide();
134
135                if (resultMap.code != RESULT_CODE_SUCCESS) {
136                    if (resultMap.code == RESULT_CODE_NO_ITEM) {
137                        toastr.error(i18n.t('shop.deleted'));
138                        shopEl.remove();
139                        removeSessionItem('/shop/listJson');
140                        return;
141                    } else {
142                        errorHandling(resultMap.code, resultMap.message);
143
144                        // 실패 원인이 목록 상태 변화일 수 있으니 캐시 비우고 목록 갱신
145                        removeSessionItem('/shop/listJson');
146                        loadShopListModal({ target: modal });
147
148                        return; // 전환 실패 시 리로드/브로드캐스트 방지
149                    }
150                }
151
152                // 사업장 변경 후 뒤로가기 시 선택된 사업장이 적용이 안되기때문에 뒤로가기 후 새로고침 플래그 설정
153                sessionStorage.setItem('refresh-on-back', true);
154                broadcastShopChange(shopId);
155                location.reload();
156            });
157
158            shopListEl.appendChild(shopEl);
159        });
160
161        modal.querySelector('.modal-body').innerHTML = '';
162        modal.querySelector('.modal-body').append(shopListEl);
163
164        // 사업장 개수를 사업장 추가 버튼에 설정
165        modal.querySelector('.move-shop-add').dataset.shopCount = shopList.length;
166    }
167
168    document.querySelector('#shopListModal')?.addEventListener('show.bs.modal', loadShopListModal);
169
170    async function sendHttpGetReviewSummary() {
171        let sendHttp = new SendHttp('review/summary');
172
173        let resultMap = await sendHttp.send();
174        if (resultMap.code == RESULT_CODE_SUCCESS) {
175            let data = resultMap.data;
176
177            // 자동 댓글 ON/OFF 상태 — 요약과 같은 응답으로 렌더 (placeholder 동시 제거, 홈/리뷰 공용)
178            // useAutoReply: Y=켜짐, N=꺼짐. 미응답/필드 없음 시 기존 dataset('Y' 기본) 유지
179            let autoReplyRow = document.querySelector('.auto-reply-setting');
180            if (autoReplyRow && data.summary != null) {
181                if (data.summary.useAutoReply) {
182                    autoReplyRow.dataset.pause = data.summary.useAutoReply;
183                }
184                autoReplyRow.querySelector('.auto-reply-title').innerText = i18n.t('baroReply.autoReply.title');
185                renderAutoReplyLabel(autoReplyRow, autoReplyRow.dataset.pause !== 'N');
186            }
187
188            if (data.summary != null && document.querySelector('#homeContainer')) {
189                document.querySelector('#reviewSummarySection .total-avg-score .item-content').innerHTML = `
190                    <div class="price-lg d-flex align-items-center">
191                        ${ratingsIcon(data.summary.avgScore, 'icon-sm')}
192                        <span class="number ms-2 mt-1">${data.summary.avgScore.toFixed(1)}</span>
193                    </div>
194                `;
195                document.querySelector('#reviewSummarySection .total-count .item-content').innerHTML = `
196                    <div class="price-lg d-flex align-items-center">
197                        <span class="number">${numberWithCommas(data.summary.totalCount)}</span>
198                        <span class="unit">ê±´</span>
199                    </div>
200                `;
201                document.querySelector('#reviewSummarySection .total-response-count .item-content').innerHTML = `
202                    <div class="price-lg d-flex align-items-center">
203                        <span class="number">${numberWithCommas(data.summary.replyCount)}</span>
204                        <span class="unit">ê±´</span>
205                    </div>
206                `;
207                document.querySelector('#reviewSummarySection .total-no-response-count .item-content').innerHTML = `
208                    <div class="price-lg d-flex align-items-center">
209                        <span class="number">${numberWithCommas(data.summary.noReplyCount)}</span>
210                        <span class="unit">ê±´</span>
211                    </div>
212                `;
213
214            }
215        }
216    }
217
218    // === 자동 댓글 ON/OFF (홈/리뷰 공용) ===
219    // 자동 댓글 상태 라벨 렌더 (켜짐: success, 꺼짐: gray)
220    // fs-4는 텍스트에만 부여 — placeholder는 다른 항목과 동일한 기본 폰트 높이 유지
221    function renderAutoReplyLabel(rowEl, isOn) {
222        let labelEl = rowEl.querySelector('.auto-reply-status');
223        let text = isOn ? i18n.t('baroReply.autoReply.statusOn') : i18n.t('baroReply.autoReply.statusOff');
224        labelEl.innerHTML = `<span class="fs-4 fw-semibold ${isOn ? 'text-success' : 'text-gray-600'}">${text}</span>`;
225    }
226
227    // 자동 댓글 상태 placeholder 렌더 (조회/변경 중 — HTML 초기 마크업과 동일)
228    function renderAutoReplyPlaceholder(rowEl) {
229        rowEl.querySelector('.auto-reply-status').innerHTML = '<span class="placeholder min-w-125px rounded-2 bg-gray-600"></span>';
230    }
231
232    // 자동 등록 확인 모달 — 현재 상태의 반대 동작 1개 + 취소 (상태 인지형)
233    function showAutoReplyConfirmModal(rowEl) {
234        // 현재 꺼짐('N')이면 활성화, 켜짐이면 비활성화
235        let targetOn = (rowEl.dataset.pause === 'N');
236        let prefix = targetOn ? 'baroReply.autoReply.activate' : 'baroReply.autoReply.deactivate';
237
238        let modal = new DynamicModal(i18n.t(prefix + 'Title'));
239        modal.setOptions({ customClass: 'modal-sm' });
240        modal.setContents(`<p class="mb-0">${i18n.t(prefix + 'Desc')}</p>`);
241        modal.setConfirmButton(i18n.t(prefix + 'Confirm'), () => applyAutoReplyStatus(modal, rowEl, targetOn));
242        modal.setCancelButton(i18n.t('cancel'));
243        modal.show();
244    }
245
246    // 자동 댓글 ON/OFF 적용 (사용자 포털 — 세션 가맹점 기준, id 불필요)
247    async function applyAutoReplyStatus(modal, rowEl, on) {
248        // 모달 닫고 변경 중 placeholder 표시 (바뀌는 느낌 부여, send 기본 딜레이 동안 노출)
249        modal.btModal.hide();
250        let prevOn = (rowEl.dataset.pause !== 'N');   // 실패 시 복원용
251        renderAutoReplyPlaceholder(rowEl);
252
253        let sendHttp = new SendHttp('shop/updateUseAutoReply');
254        sendHttp.method = 'POST';
255        sendHttp.data = new FormData();
256        sendHttp.data.append('useAutoReply', on ? 'Y' : 'N');
257
258        let resultMap = await sendHttp.send();
259        if (resultMap?.code != RESULT_CODE_SUCCESS) {
260            renderAutoReplyLabel(rowEl, prevOn);   // 원래 상태로 복원
261            return;
262        }
263
264        rowEl.dataset.pause = on ? 'Y' : 'N';
265        renderAutoReplyLabel(rowEl, on);
266        infoAlert.show('success', on ? i18n.t('baroReply.autoReply.on') : i18n.t('baroReply.autoReply.off'));
267    }
268
269    // === 남은 바로댓글 개수 프로그레스바 (홈 카드 전용) ===
270    async function renderHomeRemainCount() {
271        let contentEl = document.querySelector('.total-remain-count .item-content');
272        if (!contentEl) {
273            return;
274        }
275
276        let resultMap = await new SendHttp('review/getSubscribe').send();
277        if (resultMap?.code != RESULT_CODE_SUCCESS) {
278            return;
279        }
280
281        let data = resultMap.data;
282        let limitCount = data?.limitCount || 0;
283        let useCount = data?.useCount || 0;
284        let isUnlimited = data?.paymentVO?.productSubType == 'unlimit';
285
286        // 구독자: 라벨을 구독중인 구독명(상품명)으로, 그 외엔 기본 라벨로 표시 (로딩 플레이스홀더 제거)
287        let productName = data?.paymentVO?.type == 'smallbiz' ? '소상공인지원' : data?.paymentVO?.productName;
288        let nameEl = document.querySelector('.total-remain-count .remain-count-name');
289        if (nameEl) {
290            nameEl.innerText = productName || '남은 바로댓글';
291        }
292
293        contentEl.innerHTML = buildHomeRemainCount(Math.max(0, limitCount - useCount), limitCount, isUnlimited);
294    }
295
296    // 남은 개수 프로그레스바 HTML (review.js buildRemainCount의 홈 카드용 간소 버전)
297    function buildHomeRemainCount(count, limit, isUnlimited) {
298        if (isUnlimited) {
299            return `
300                <div class="d-flex align-items-center gap-2">
301                    <div class="progress rounded h-10px w-100px bg-secondary"><div class="progress-bar bg-success" style="width: 100%;"></div></div>
302                    <div class="fw-semibold fs-base">무제한</div>
303                </div>
304            `;
305        }
306
307        let percent = limit > 0 ? Math.min((count / limit) * 100, 100) : 0;
308        let barColor = 'bg-success';
309        if (percent <= 10) {
310            barColor = 'bg-danger';
311        } else if (percent <= 50) {
312            barColor = 'bg-warning';
313        }
314
315        return `
316            <div class="d-flex align-items-center gap-2">
317                <div class="progress rounded h-10px w-100px bg-secondary"><div class="progress-bar ${barColor}" style="width: ${percent}%;"></div></div>
318                <div class="price-lg d-flex align-items-center">
319                    <span class="number">${numberWithCommas(count)}</span>
320                    <span class="unit">ê±´</span>
321                </div>
322            </div>
323        `;
324    }
325
326});
327
328// 사업장 추가 버튼 클릭 시 처리
329function moveToShopAdd(targetEl) {
330    let maxCount = 5;
331
332    let currentCount = targetEl.dataset.shopCount ?? 0;
333    if (currentCount >= maxCount) {
334        infoAlert.show('danger', i18n.t('shop.overMaxCount', { count: maxCount }));
335        return;
336    }
337
338    location.href = '/shop/add';
339}
340// e캐시 카드 초기화
341async function initEcashCard() {
342    let ecashCard = document.querySelector('#ecashCard');
343    if (!ecashCard) return;
344
345    let ecashStatus = ecashCard.dataset.ecashStatus;
346    let confirmModal = new bootstrap.Modal(document.querySelector('#ecashRequestConfirmModal'));
347
348    // 상태 판별: active / locked / prepayStop / pending / rejected / inactive
349    let state = 'inactive';
350    let rejectReason = '';
351
352    let accountFlags = { gas: false, water: false };
353    if (ecashStatus == '1') {
354        // 활성화 상태: 금액 요약 노출 + 잔액/내역 로드 (납부 버튼은 항상 노출되어 있음)
355        state = 'active';
356        ecashCard.querySelector('.ecash-amount').classList.remove('d-none');
357        ecashCard.querySelector('.ecash-badge-on').classList.remove('d-none');
358        ecashCard.querySelector('.ecash-more').classList.remove('d-none');
359        await loadEcashInfo();
360        await loadEcashPaymentList();
361        // 저장된 조회 정보 여부 (가스/수도)
362        accountFlags = await loadAccountRegistration();
363    } else if (ecashStatus == '9') {
364        state = 'locked';
365    } else if (ecashStatus == '2') {
366        // 선정산 중지에 의한 이용 제한 (선정산 재개 시 관리자가 복원)
367        state = 'prepayStop';
368    } else {
369        // 비활성: 신청 서브상태 확인 (미신청/대기/거절)
370        let req = await getEcashRequestState();
371        state = req.state;
372        rejectReason = req.rejectReason || '';
373    }
374
375    // 대기/거절/잠금/선정산 중지 상태: 헤더 배지 + 설명문
376    if (state === 'pending') {
377        showEcashNotice('badge-light-brand-1', '심사중', '신청 후 승인까지 최대 1~2일이 소요돼요.');
378    } else if (state === 'rejected') {
379        showEcashNotice('badge-light-danger', '거절', '아쉽지만 신청이 거절되었어요.' + (rejectReason ? ' 사유: ' + escapeHtmlText(rejectReason) : ''));
380    } else if (state === 'locked') {
381        showEcashNotice('badge-light-danger', '이용제한', '관리자에 의해 e캐시 이용이 제한되었습니다.');
382    } else if (state === 'prepayStop') {
383        showEcashNotice('badge-light-danger', '이용제한', i18n.t('ecash.prepayStopNotice'));
384    } else if (state === 'inactive') {
385        showEcashNotice('badge-secondary', '미이용', null);
386        let badgeStatus = ecashCard.querySelector('.ecash-badge-status');
387        if (badgeStatus) {
388            badgeStatus.classList.add('cursor-pointer');
389            badgeStatus.addEventListener('click', () => {
390                openEcashRequestFlow();
391            });
392        }
393    }
394
395    // 납부 버튼 동작: 상태별 분기
396    ecashCard.querySelectorAll('.ecash-pay-btn').forEach(btn => {
397        btn.addEventListener('click', (e) => {
398            if (state === 'active') {
399                // 저장된 조회 정보가 있으면 모달에서 바로 납부, 비상금 정보 없으면 안내
400                let payType = btn.dataset.payType || '';
401                if ((payType === 'gas' || payType === 'water') && accountFlags[payType]) {
402                    // 저장된 정보 있음 → 모달에서 바로 납부 (모달 안에서 원래 이동처로 빠져나갈 수 있게 href 전달)
403                    e.preventDefault();
404                    showEcashPaymentModal(payType, btn.getAttribute('href'));
405                } else if (payType === 'emergency' && !accountFlags.gas && !accountFlags.water) {
406                    // 비상금 정보 없음 → 안내 모달
407                    e.preventDefault();
408                    showEmergencyGuideModal();
409                } else if (btn.getAttribute('href') === '#') {
410                    // 실제 납부 화면 이동 (미구현 항목은 안내)
411                    e.preventDefault();
412                    toastr.info('준비 중인 서비스입니다.');
413                }
414                // 그 외: 링크대로 이동 (아무것도 하
414지 않음)
415                return;
416            }
417            e.preventDefault();
418            if (state === 'pending') {
419                toastr.info('e캐시 사용 신청이 접수되어 승인 대기 중입니다.');
420            } else if (state === 'locked' || state === 'prepayStop') {
421                let content = state === 'prepayStop' ? i18n.t('ecash.prepayStopNotice') : '관리자에 의해 <span class="fw-semibold">e캐시 이용이 제한</span>되었습니다.';
422                let modal = new DynamicModal('e캐시 이용 제한', content);
423                modal.setModalSize(MODAL_SIZE_SM);
424                modal.setConfirmButton(i18n.t('close'), () => modal.hide());
425                modal.setCancelButton(i18n.t('qna.label'), () => {
426                    modal.hide();
427                    location.href = '/qna/add';
428                });
429                modal.show();
430            } else {
431                // inactive / rejected → 신청(재신청) 안내 모달
432                openEcashRequestFlow();
433            }
434        });
435    });
436
437    document.querySelector('#ecashRequestConfirmBtn').addEventListener('click', async () => {
438        confirmModal.hide();
439
440        let sendHttp = new SendHttp('/ecash/request');
441        sendHttp.method = 'POST';
442
443        JsLoadingOverlay.show();
444        let resultMap = await sendHttp.send();
445        JsLoadingOverlay.hide();
446
447        if (resultMap.code != RESULT_CODE_SUCCESS) {
448            // 공동인증서 미등록 → 등록 유도 모달
449            if (resultMap.code === 'W1031') {
450                showCertRequiredForEcash();
451                return;
452            }
453            errorHandling(resultMap.code, resultMap.message);
454            return;
455        }
456
457        toastr.success('e캐시 사용 신청이 접수되었습니다.');
458        setTimeout(() => location.reload(), 1000);
459    });
460}
461
462// === in-flight 가드: 더블클릭 중복 실행 방지 ===
463let ecashRequestFlowBusy = false;
464
465// === e캐시 청구서 조회 결과 캐시: 외부 스크래핑(가스사/수도사 로그인) 성능 개선 ===
466// payType별로 성공 건만 2분간 보관. 조회 실패는 캐시하지 않아 즉시 재시도 가능
467// 납부 성공 시 location.reload()로 페이지 재로드되므로 캐시 무효화 불필요
468let ecashBillCache = {};
469const ECASH_BILL_CACHE_TTL_MS = 2 * 60 * 1000;  // 2분
470
471// === e캐시 신청 진입 공통 함수: 인증서 확인 → 등록 필수 모달 유도 또는 신청 모달 열기 ===
472async function openEcashRequestFlow() {
473    if (ecashRequestFlowBusy) return;
474    ecashRequestFlowBusy = true;
475
476    try {
477        // 공동인증서 등록 여부 확인
478        let sendHttp = new SendHttp('/cert/get');
479        sendHttp.method = 'GET';
480        let result = await sendHttp.send();
481
482        if (result.code === RESULT_CODE_SUCCESS && result.data) {
483            // 인증서 등록됨 → 신청 모달 열기
484            let confirmModal = bootstrap.Modal.getOrCreateInstance(document.querySelector('#ecashRequestConfirmModal'));
485            confirmModal.show();
486        } else {
487            // 인증서 미등록 → 등록 유도 모달
488            showCertRequiredForEcash();
489        }
490    } finally {
491        ecashRequestFlowBusy = false;
492    }
493}
494
495// === 공동인증서 등록 필수 모달 ===
496function showCertRequiredForEcash() {
497    let modal = new DynamicModal(i18n.t('cert.registerTitle'), i18n.t('ecash.cert.requestMessage'));
498    modal.setModalSize(MODAL_SIZE_SM);
499    modal.setConfirmButton(i18n.t('cert.registerButton'), () => {
500        location.href = '/shop/updateCert';
501    });
502    modal.show();
503}
504
505async function loadEcashInfo() {
506    let sendHttp = new SendHttp('/ecash/info');
507    let resultMap = await sendHttp.send();
508
509    if (resultMap.code != RESULT_CODE_SUCCESS) return;
510
511    let data = resultMap.data;
512    let limit = data.ecashLimit || 0;
513    let used = data.usedAmount || 0;
514    let available = data.ecashBalance || 0;
515
516    document.querySelector('.ecash-info-limit').textContent = numberWithCommas(limit);
517    document.querySelector('.ecash-info-used').textContent = numberWithCommas(used);
518    document.querySelector('.ecash-info-available').textContent = numberWithCommas(available);
519
520    // 납부 항목별 진행 중 건 표시 (라벨 옆 '대기 N건' 뱃지 — 입금/납부 방향 중립어)
521    let ecashCard = document.querySelector('#ecashCard');
522    if (ecashCard) {
523        let pendingCountMap = data.pendingCountMap || {};
524        ecashCard.querySelectorAll('.ecash-pending-badge').forEach(badge => {
525            let type = badge.dataset.pendingType;
526            let count = (pendingCountMap[type] || 0);
527            badge.textContent = `대기 ${count}건`;
528            badge.classList.toggle('d-none', count === 0);
529        });
530    }
531}
532
533async function loadEcashPaymentList() {
534    let sendHttp = new SendHttp('/ecash/listJson');
535    sendHttp.data = { recordCountPerPage: 10 };
536    let resultMap = await sendHttp.send();
537
538    if (resultMap.code != RESULT_CODE_SUCCESS) return;
539
540    let list = resultMap.list || [];
541    let listEl = document.querySelector('#ecashCard .ecash-payment-list');
542    if (!listEl) return;
543
544    if (list.length == 0) {
545        listEl.innerHTML = '<p class="text-center text-gray-500 py-4 mb-0">사용 내역이 없습니다.</p>';
546        return;
547    }
548
549    let html = '';
550    list.forEach(item => {
551        let amountHtml = formatEcashAmount(item);
552        html += `
553            <div class="d-flex justify-content-between align-items-center py-2">
554                <div>
555                    <span class="text-gray-800 fw-semibold">${getEcashTypeLabel(item.type)}</span>
556                    <span class="text-gray-500 fs-7 ms-2">${formatDate(item.created)}</span>
557                </div>
558                ${amountHtml}
559            </div>
560        `;
561    });
562
563    listEl.innerHTML = html;
564}
565
566function formatEcashAmount(item) {
567    let amount = item.billAmount || 0;
568    if (item.type === 'balance_adjust') {
569        let sign = amount >= 0 ? '+' : '';
570        let color = amount >= 0 ? 'text-primary' : 'text-gray-600';
571        return `<span class="fw-bold ${color}">${sign}${numberWithCommas(amount)}원</span>`;
572    }
573    return `<span class="fw-bold text-gray-600">-${numberWithCommas(Math.abs(amount))}원</span>`;
574}
575
576function getEcashTypeLabel(type) {
577    switch (type) {
578        case 'gas': return '도시가스';
579        case 'water': return '수도요금';
580        case 'national_tax': return '지로(국세)';
581        case 'local_tax': return '지로(지방세)';
582        case 'general_giro': return '지로(일반지로)';
583        case 'giro': return '지로';
584        case 'emergency': return '비상금';
585        default: return type || '-';
586    }
587}
588
589// 카드 상단 상태 안내: 헤더 상태 배지 + 설명문 (descHtml은 HTML 허용 - 고객센터 링크 등)
590function showEcashNotice(badgeClass, badgeText, descHtml) {
591    let card = document.querySelector('#ecashCard');
592    if (!card) return;
593
594    let iconColorMap = {
595        'badge-light-brand-1': 'text-brand-1',
596        'badge-light-danger': 'text-danger',
597        'badge-secondary': 'text-gray-600'
598    };
599    let iconColor = iconColorMap[badgeClass] ?? '';
600
601    let badge = card.querySelector('.ecash-badge-status');
602    badge.classList.add(badgeClass);
603    badge.innerHTML = '<i class="fa-solid fa-check ' + iconColor + ' me-1"></i><span>' + badgeText + '</span>';
604    badge.classList.remove('d-none');
605
606    if (descHtml != null) {
607        let desc = card.querySelector('.ecash-notice');
608        desc.innerHTML = descHtml;
609        desc.classList.remove('d-none');
610    }
611}
612
613// HTML 이스케이프 (관리자 입력값 등 안전 처리)
614function escapeHtmlText(str) {
615    let div = document.createElement('div');
616    div.textContent = str == null ? '' : str;
617    return div.innerHTML;
618}
619
620// 저장된 조회 정보 여부 조회 (가스/수도)
621async function loadAccountRegistration() {
622    let sendHttp = new SendHttp('/ecash/inquiryAccount');
623    let resultMap = await sendHttp.send();
624
625    if (resultMap.code != RESULT_CODE_SUCCESS) {
626        return { gas: false, water: false };
627    }
628
629    let data = resultMap.data || {};
630    return {
631        gas: data.gas === true,
632        water: data.water === true
633    };
634}
635
636// 로딩 문구 점 애니메이션 (site.js 진행 모달들과 동일: 0.7초 주기로 . → .. → ... 순환)
637// 점을 position-absolute 로 띄워 개수가 늘어도 문구가 밀리지 않는다
638// 반환: 애니메이션 중지 함수 (로딩이 끝나면 반드시 호출해 타이머를 정리한다)
639function startDotsAnimation(textEl, baseText) {
640    let dotCount = 1;
641    let timer = null;
642
643    const tick = () => {
644        textEl.innerHTML = `<div class="position-relative">${baseText}<span class="position-absolute">${'.'.repeat(dotCount)}
644</span></div>`;
645        dotCount = dotCount % 3 + 1;
646        timer = setTimeout(tick, 700);
647    };
648    tick();
649
650    return () => clearTimeout(timer);
651}
652
653// 납부 모달 (저장된 정보로 조회 + 입금)
654// payPageUrl: 기존 납부 화면 주소 (홈 버튼의 href. 청구월 변경·조회 정보 수정은 이 화면에서 한다)
655async function showEcashPaymentModal(payType, payPageUrl) {
656    let config = ECASH_TYPE_CONFIG[payType];
657    if (!config) return;
658
659    // 납부 화면(pay.html)의 '납부 확인' 모달(#ecashPayConfirmModal)과 동일하게 구성한다
660    // 제목 'e캐시 납부', 기본(md) 크기, 수직 가운데 정렬
661    let modal = new DynamicModal('e캐시 납부');
662    modal.setOptions({ verticalCenter: true });
663
664    // 이 모달은 저장된 정보로 조회한 최신 청구서 1건만 보여준다. 다른 청구월은 기존 납부 화면에서 조회한다
665    // 조회 성공 시에는 footer 왼쪽 버튼이, 조회 실패·이미 납부 안내에서는 footer 왼쪽 버튼이 그 통로가 된다
666    const payPageHref = payPageUrl || `/ecash/${payType}`;
667
668    // 로딩은 모달 본문 안 Loader 로 표시하고(prepay.js 상세 모달들과 동일한 관용구) 그 아래에 안내 문구를 둔다.
669    // 로더는 컨테이너 가운데에 절대 배치되므로, 높이를 가진 전용 칸을 주고 문구는 그 다음 흐름에 놓는다
670    // 버튼은 감췄다가(hideButton) 내용이 준비되면 다시 보여준다.
671    // removeConfirmButton/removeCancelButton 은 각각 상대 버튼을 다시 손보므로 둘을 같이 호출하면 안 된다
672    modal.setContents(`<div class="py-4">
673        <div class="ecash-loading-icon min-h-150px"></div>
674        <div class="ecash-loading-text text-center text-gray-600 mt-2"></div>
675    </div>`);
676    modal.hideButton();
677    modal.show();
678
679    // 높이는 min-h-150px 로 고정하므로 Loader 의 기본 높이(min-h-200px)는 끈다
680    let loader = new Loader(modal.container.querySelector('.ecash-loading-icon'), false);
681    loader.show();
682    let stopDots = startDotsAnimation(modal.container.querySelector('.ecash-loading-text'), '청구서를 확인하고 있어요');
683    const endLoading = () => { loader.hide(); stopDots(); };
684
685    let inquireResult;
686    try {
687        // 캐시 확인: payType이 2분 이내에 조회된 결과가 있으면 재사용
688        if (ecashBillCache[payType] && ecashBillCache[payType].expire > Date.now()) {
689            inquireResult = { ok: true, bill: ecashBillCache[payType].bill };
690        } else {
691            // 저장된 정보로 조회 (cred={}, billMonthApi='')
692            inquireResult = await ecashInquireBill(payType, {}, '');
693            // 조회 성공시만 캐시에 저장 (실패는 즉시 재시도할 수 있어야 함)
694            if (inquireResult.ok) {
695                ecashBillCache[payType] = {
696                    bill: inquireResult.bill,
697                    expire: Date.now() + ECASH_BILL_CACHE_TTL_MS
698                };
699            }
700        }
701    } catch (err) {
702        endLoading();
703        showEcashModalNotice(modal, '청구서 조회에 실패했습니다. 잠시 후 다시 시도해주세요.', payPageHref);
704        return;
705    }
706
707    if (!inquireResult.ok) {
708        endLoading();
709        showEcashModalNotice(modal, inquireResult.message || '청구서 조회에 실패했습니다.', payPageHref);
710        return;
711    }
712
713    let bill = inquireResult.bill;
714    let billAmount = parseInt(bill.billAmt) || 0;
715
716    // 이미 납부 여부 확인 + 납부 정보 조회 (병렬 처리)
717    let [checkResult, infoResult] = await Promise.all([
718        ecashCheckPaidBill(payType, bill.payerNo, bill.billDt),
719        ecashLoadIncomeInfo(payType, billAmount)
720    ]);
721    endLoading();
722
723    if (checkResult.paid) {
724        showEcashPaidDetail(modal, checkResult.item, payPageHref);
725        return;
726    }
727
728    if (infoResult.code != RESULT_CODE_SUCCESS) {
729        let msg = ecashIncomeErrorMessage(infoResult) || '납부 정보를 불러오지 못했습니다.';
730        showEcashModalNotice(modal, msg, payPageHref);
731        return;
732    }
733
734    let info = infoResult.data;
735    let feeAmount = info.feeAmount || 0;
736    let totalAmount = info.ecashAmount || 0;
737    let shortage = totalAmount - info.ecashBalance;
738
739    // 홈 모달은 뒤에 조회 결과 화면이 없으므로 청구 내역(고객명·고객번호·청구월)을 모달 안에 함께 넣고,
740    // 아래 금액 박스와 통일되게 같은 bg-light rounded-3 p-4 박스로 묶는다.
741    // (buildDetailItemRow 의 mx-n2/px-2 는 상쇄되므로 글자는 금액 행과 같은 선에 맞는다)
742    // 납부 화면 이동은 footer 왼쪽 버튼이 맡는다 (아래 setCancelButton)
743    // 고객명은 값이 있을 때만 (수도는 미제공)
744    let payerName = (bill.payerName || '').trim();
745    let hasName = payerName !== '' && payerName !== '-';
746    let billInfoHtml = `<div class="bg-light rounded-3 p-4 mb-4">
747            ${hasName ? buildDetailItemRow({ name: '고객명', price: payerName, depth: 2, isNoPrice: true }) : ''}
748            ${buildDetailItemRow({ name: config.payerLabel, price: bill.payerNo || '-', depth: 2, isNoPrice: true })}
749            ${buildDetailItemRow({ name: '청구월', price: ecashFormatBillDt(bill.billDt), depth: 2, isNoPrice: true })}
750        </div>`;
751
752    // 금액 박스 아래(입금 은행·가상계좌·안내)는 납부 화면과 동일한 것을 쓴다 (ecash-pay-core.js)
753    modal.setContents(billInfoHtml + ECASH_PAY_CONFIRM_BODY_HTML);
754    ecashInitBankSelect(modal.container);
755    modal.container.querySelector('#ecashModalAmountBox').innerHTML =
756        ecashAmountBoxHtml(billAmount, feeAmount, totalAmount, info.ecashBalance - totalAmount, info.feeRate, info.ecashBalance, info.transferFee);
757
758    // footer 는 [다른 달 조회][납부]. setConfirmButton 은 취소 버튼 문구를 기본값으로 되돌리므로
759    // setCancelButton 보다 먼저 호출해야 한다. (common.js DynamicModal 주석 참조)
760    modal.setConfirmButton('납부', async () => {
761        // 입금 은행/계좌 검증 (ecash-pay-core.js의 ecashReadPayInput() 호출)
762        let payInput = ecashReadPayInput(modal.container);
763        if (!payInput) return;
764
765        // 잔액 확인
766        if (shortage > 0) {
767            ecashShortageModal({ currentBalance: info.ecashBalance, totalAmount: totalAmount, shortage: shortage });
768            return;
769        }
770
771        // 버튼 비활성화 (중복 클릭 방지)
772        let confirmBtn = modal.container.querySelector('button.confirm');
773        if (confirmBtn) confirmBtn.disabled = true;
774
775        // 납부 요청
776        let extBillName = (config.extPrefix + ' ' + (bill.payerName || '')).trim();
777        let requestResult = await ecashRequestIncome({
778            type: payType,
779            bankId: payInput.bankId,
780            bankAccount: payInput.bankAccount,
781            amount: billAmount,
782            payerNo: bill.payerNo,
783            billMonth: bill.billDt,
784            extBillName: extBillName
785        });
786
787        // 버튼 복구
788        if (confirmBtn) confirmBtn.disabled = false;
789
790        if (requestResult.code != RESULT_CODE_SUCCESS) {
791            // ecash-pay-core.js의 ecashIncomeErrorMessage() 호출 (잔액 부족은 부족 모달로 처리되어 null)
792            let errorMessage = ecashIncomeErrorMessage(requestResult);
793            if (errorMessage) toastr.error(errorMessage);
794            return;
795        }
796
797        toastr.success('납부 요청이 접수되었습니다.');
798        setTimeout(() => location.reload(), 1000);
799    });
800
801    modal.setCancelButton('다른 달 조회', () => { location.href = payPageHref; });
802    modal.showButton();	// 로딩 동안 감춰둔 버튼 영역 복구
803}
804
805// 조회 실패·이미 납부처럼 더 진행할 게 없을 때: 안내만 남기고 footer 버튼으로 정리한다.
806// message: 안내 문구
807// payPageHref: 다른 달 조회 버튼의 이동 주소
808// footer 는 [다른 달 조회][닫기] 두 버튼. setConfirmButton 은 취소 버튼 문구를 기본값으로 되돌리므로
809// setCancelButton 보다 먼저 호출해야 한다. (common.js DynamicModal 주석 참조)
810function showEcashModalNotice(modal, message, payPageHref) {
811    modal.setContents(message);
812    modal.setConfirmButton(i18n.t('close'), () => modal.hide());
813    modal.setCancelButton('다른 달 조회', () => { location.href = payPageHref; });
814    modal.showButton();
815}
816
817// 기납부 건 상세 표시 (pay.html 의 showPaidDetail 과 동일 구조를 모달에서 표시)
818// checkResult.item: 기납부 건 정보
819// payPageHref: 다른 달 조회 버튼의 이동 주소
820function showEcashPaidDetail(modal, item, payPageHref) {
821    let cardHtml = ecashPaidDetailCardHtml(item);
822    modal.setContents(cardHtml);
823    modal.setConfirmButton(i18n.t('close'), () => modal.hide());
824    modal.setCancelButton('다른 달 조회', () => { location.href = payPageHref; });
825    modal.showButton();
826}
827
828// 비상금 안내 모달
829// 안내·유도 모달 공용 템플릿(#iconModal)을 그대로 쓴다 — site.js 의 회원가입 유도 모달 등과 같은 방식
830// 도시가스·수도요금으로 가는 버튼은 두지 않는다. 바로 뒤 e캐시 카드에 이미 같은 버튼이 있어 진입점이 겹친다
831function showEmergencyGuideModal() {
832    let modal = new DynamicModal();
833    modal.cloneModal(document.querySelector('#iconModal'));
834    modal.setOptions({ verticalCenter: true });
835
836    modal.container.querySelector('.item-icon').src = '/assets/images/icon/icon-warning.gif';
837    modal.container.querySelector('.item-title').innerHTML = '아직 비상금을 받을 수 없어요';
838    modal.container.querySelector('.item-subtitle').innerHTML = '도시가스나 수도요금의 청구 내역을<br>한 번이라도 조회해야 받을 수 있어요.';
839
840    modal.show();
841}
842
843// 신청 서브상태 조회 → { state: 'inactive'|'pending'|'rejected', rejectReason }
844async function getEcashRequestState() {
845    let sendHttp = new SendHttp('/ecash/getRequestStatus');
846    let resultMap = await sendHttp.send();
847
848    if (resultMap.code != RESULT_CODE_SUCCESS) return { state: 'inactive' };
849
850    let data = resultMap.data;
851    if (!data || !data.hasRequest) return { state: 'inactive' };
852    if (data.status == 'R') return { state: 'pending' };
853    if (data.status == 'D') return { state: 'rejected', rejectReason: data.rejectReason };
854    return { state: 'inactive' };
855}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.