PageSourceSearch

https://ssonda.ai/assets/js/core/index.js;jsessionid=F09496534807D4B3754C95A47D30A8CA?v=1790258049086

js ssonda.ai collected 2026-09-24 13:54:35 UTC 21,352 bytes, 537 lines download raw bytes

1/**
2 * core/index.js
3 * 
4 * 프로젝트 내에서 공통적으로 사용되는 자바스크립트 코드를 작성하는 파일
5 * 
6 * (1) 각 페이지에서 사용되는 것들은 /page/.. 에 작성,
7 * (2) 각 사이트에서 공통적으로 사용되는 것들은 /core/site.js or /core/admin.js 에 작성
8 * (3) 그 외 공통적으로 사용되는 것들은 이 파일에 작성
9 * 
10 * */
11
12
13/**
14 * 공동인증서 발급자(issuer) 원문을 국내 CA 명칭으로 매핑한다.
15 * 발급자 문자열에 식별자가 포함되면 해당 CA 명칭으로, 어느 것에도 걸리지 않으면 원문 그대로 반환한다.
16 * 매핑 추가/수정은 아래 CERT_ISSUER_MAP만 고치면 된다. (관리자/사용자 공통)
17 */
18const CERT_ISSUER_MAP = {
19    yessign: '금융결제원',
20    kica: '한국정보인증',
21    crosscert: '한국전자인증',
22    ktnet: '한국무역정보통신',
23};
24function mapCertIssuer(raw) {
25    if (raw == null) return raw;
26    const text = String(raw).trim();
27    const lower = text.toLowerCase();
28    const key = Object.keys(CERT_ISSUER_MAP).find(k => lower.includes(k));
29    return key ? CERT_ISSUER_MAP[key] : text;
30}
31
32
33document.addEventListener('DOMContentLoaded', function () {
34
35    // 페이지 내 공동인증서 발급자 요소(.cert-issuer)를 CA 명칭으로 자동 치환 (사용자 등록 화면 등)
36    document.querySelectorAll('.cert-issuer').forEach(function (el) {
37        el.textContent = mapCertIssuer(el.textContent);
38    });
39
40    if (document.querySelector('#dashboardContainer') || document.querySelector('#homeContainer') || document.querySelector('#signUpCompleteContainer')) {
41        requestFcmPermission();
42    }
43
44    // 페이지 로딩 시 스크롤 위치 이동
45    const urlParams = new URLSearchParams(window.location.search);
46    const scrollToValue = urlParams.get('goto');
47    if (scrollToValue) {
48        // 숫자일 경우 픽셀 단위로 스크롤
49        if (!isNaN(scrollToValue)) {
50            scrollByPosition(scrollToValue);
51        } else {
52            // ID를 가진 요소가 존재하면 해당 위치로 스크롤 (여유 공간 추가)
53            scrollById(scrollToValue);
54        }
55        
56        // 완료되면 현재 URL에서 goto만 제거
57        let params = getUrlParams();
58        params.goto = undefined;
59        setUrlParams(params);
60    }
61
62    document.addEventListener('click', function (e) {
63        const scrollEl = e.target.closest('.scroll-to');
64        if (scrollEl) {
65            // 인터랙티브 요소 클릭 시 scroll-to 동작 무시
66            if (e.target.closest('a, button, input, select, textarea')) return;
67
68            const target = scrollEl.dataset.target;
69            const href = scrollEl.dataset.href;
70
71            if (target && href) {
72                // 둘 다 있으면: 같은 페이지이면 스크롤, 다르면 페이지 이동
73                // endsWith로 비교해 서브도메인/경로 프리픽스 환경 모두 대응
74                const targetUrl = new URL(href, location.origin);
75                if (location.pathname === targetUrl.pathname || location.pathname.endsWith(targetUrl.pathname)) {
76                    scrollById(target);
77                } else {
78                    location.href = href;
79                }
80            } else if (target) {
81                // data-target만: 항상 스크롤
82                scrollById(target);
83            } else if (href) {
84                // data-href만: 항상 페이지 이동
85                location.href = href;
86            }
87        }
88    });
89
90    // 3rd party name 생성
91    document.querySelectorAll('.generate-3party-name').forEach((el) => {
92        generate3rdPartyName(el);
93    });
94
95    // qna 유형 텍스트로 변환
96    document.querySelectorAll('.generate-qna-type').forEach((el) => {
97        generateQnaType(el);
98    });
99
100    // event 유형 텍스트로 변환
101    document.querySelectorAll('.generate-event-type').forEach((el) => {
102        generateEventType(el);
103    });
104
105    document.querySelectorAll('.generate-card-company-name').forEach((el) => {
106        let cardCode = el.dataset.cardCode;
107        let name = getKisCardCompanyName(cardCode);
108        el.textContent = name;
109    });
110
111    document.querySelectorAll('.generate-risk-shop-type').forEach((el) => {
112        let otherCount = 0; // ~외 n건
113        if (el.dataset.count != null) {
114            otherCount = numberWithCommas((el.dataset.count - 1));
115        }
116
117        let resStr = '';
118        if (otherCount > 0) {
119            resStr = `${getRiskShopTypeStr(el.dataset.value)} 외 ${otherCount}건`;
120        } else {
121            resStr = getRiskShopTypeStr(el.dataset.value);
122        }
123
124        el.textContent = resStr;
125    });
126
127    // 목록 테이블 정렬 헤더
128    initSortHeader();
129
130});
131
132function scrollByPosition(scrollToValue) {
133    window.scrollTo({
134        top: parseInt(scrollToValue, 10),
135        behavior: "smooth"
136    });
137}
138
139function scrollById(scrollToValue) {
140    // ID를 가진 요소가 존재하면 해당 위치로 스크롤 (여유 공간 추가)
141    const targetElement = document.getElementById(scrollToValue);
142    if (targetElement) {
143        const offset = 100; // 위쪽 여백
144        const targetPosition = targetElement.getBoundingClientRect().top + window.scrollY - offset;
145
146        window.scrollTo({
147            top: targetPosition,
148            behavior: "smooth"
149        });
150    }
151}
152
153// 3rd party name 생성
154function generate3rdPartyName(el) {
155    if (!el) return;
156
157    waitForI18nInitialization(() => {
158        let deliveryCompany = el.dataset.deliveryCompany;
159        let cardCode = el.dataset.cardCode;
160        let key = deliveryCompany && deliveryCompany != '0' ? deliveryCompany : (cardCode ? cardCode : '0');
161    
162        let name = get3rdPartyName(key);
163    
164        el.innerHTML = name;
165    });
166}
167
168function get3rdPartyName(key) {
169    let name = SALES_CORP[key]?.name;
170
171    if (name == null || name == undefined) {
172        name = i18n.t('etc');
173    }
174
175    return name;
176}
177
178// 이벤트 종류 이름으로 변환
179async function generateEventType(el) {
180    if (!el) return;
181    
182    waitForI18nInitialization(() => {
183        let type = el.dataset.type;
184        let name = getEventTypeName(type);
185        el.innerHTML = name;
186    });
187}
188
189// 이벤트 종류 이름 가져오기
190function getEventTypeName(type) {
191    let name = '기타';
192
193    switch (type) {
194        case 'early_signup_reward_ssonda_fee':
195            name = '첫 회원가입 수수료 무료';
196            break;
197        case 'ssonda_fee_discount_in_period':
198            name = '서비스 이용료 기간 할인';
199            break;
200        case 'early_signup_reward_auto_reply':
201            name = '첫 회원가입 바로댓글 무료';
202            break;
203        case 'auto_reply_discount':
204            name = '바로댓글 할인';
205            break;
206        case 'prepay_user':
207            name = '선정산고객 할인';
208            break;
209        case 'lotto':
210            name = '복권';
211            break;
212        case 'smallbiz_support':
213            name = '소상공인지원';	// legacy: DB에 남아있는 기존 데이터 표시용
214            break;
215        case 'smallbiz_support_auto_reply':
216            name = '소상공인지원 Ai바로댓글';
217            break;
218        case 'smallbiz_support_sales_ledger':
219            name = '소상공인지원 바로장부';
220            break;
221        default:
222            name = '기타';
223            break;
224    }
225
226    return name;
227}
228
229async function generateQnaType(el) {
230    if (!el) return;
231    
232    waitForI18nInitialization(() => {
233        let type = el.dataset.type;
234        let name = getQnaTypeStr(type);
235        el.innerHTML = name;
236    });
237}
238
239function getQnaTypeStr(type) {
240    switch (type) {
241        case '0': return i18n.t('qna.type.error');
242        case '1': return i18n.t('qna.type.use');
243        case '2': return i18n.t('qna.type.transaction');
244        case '3': return i18n.t('qna.type.serviceStop');
245        case '9': return i18n.t('qna.type.etc');
246        default: return i18n.t('qna.type.etc');
247    }
248}
249
250function getRiskShopTypeStr(type) {
251    switch (type) {
252        case 'surge_sales': return '매출 급증';
253        case 'drop_sales': return '매출 급감';
254        case 'low_sales': return '장기 매출저조';
255        case 'mass_cancel': return '다수 매출취소';
256        case 'change_adj_account': return '정산계좌 변경';
257        case 'change_sales_account': return '매출계정 연동오류';
258        case 'recovery_decrease': return '상환액 감소';
259        case 'remain_loan_surge': return '미상환액 급증(한도액)';
260        case 'remain_loan_week_surge': return '미상환액 급증(전주)';
261        case 'remain_loan_avg_surge': return '미상환액 급증(전주 평균)';
262        case 'change_open_status': return '영업상태 변경';
263        case 'no_shop_info': return '입점 해지';
264        case 'seizure': return '채권압류';
265        case 'etc': return '기타';
266        default: return '-';
267    }
268}
269
270function getKisCardCompanyName(cardCode) {
271    let name = '기타';
272
273    let cardCorps = PG_CARD_CORP;
274
275    if (cardCorps[cardCode]) {
276        name = cardCorps[cardCode].name;
277    }
278
279    return name;
280}
281
282/**
283 * ApexCharts 다중 시리즈(2개 이상) 공통 툴팁(custom) 생성기.
284 * 기본 다크 박스 내부 행 간격을 좁게 렌더한다. shared:true 차트에서 사용.
285 *
286 * @param {Object} [opts]
287 * @param {function(any, object):string} [opts.title] - 타이틀 포맷 (label, w) => string. 기본: 카테고리 라벨 그대로
288 * @param {function(number):string} [opts.value] - 값 포맷. 기본: numberWithCommas(v) + '원'
289 * @param {number} [opts.minWidth=120] - 툴팁 최소 너비(px). 내용이 짧아도 이 너비 이상 유지
290 * @returns {function} ApexCharts tooltip.custom 함수
291 */
292function buildSharedChartTooltip(opts = {}) {
293    const titleFn = opts.title || ((label) => label);
294    const valueFn = opts.value || ((v) => numberWithCommas(v) + '원');
295    // reverse: 행 순서 뒤집기 — 스택 차트에서 시각적 상단 series를 툴팁 위쪽에 표시
296    const reverse = opts.reverse === true;
297    const minWidth = opts.minWidth ?? 120;
298    const minWidthStyle = minWidth ? ` style="min-width:${minWidth}px;"` : '';
299    return function ({ series, dataPointIndex, w }) {
300        const title = titleFn(w.globals.labels[dataPointIndex], w, dataPointIndex);
301        // 행은 기본 툴팁 클래스 재사용 → 라벨/값(굵게) 스타일 동일. 간격 유발 y-group/series-group 래퍼는 미사용.
302        const order = w.globals.seriesNames.map((_, i) => i);
303        if (reverse) order.reverse();
304        const rows = order.map((i) => {
305            const name = w.globals.seriesNames[i];
306            const color = w.globals.colors[i];
307            const val = series[i][dataPointIndex];
308            return `<div class="d-flex align-items-center lh-1">
309                <span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background:${color};"></span>
310                <span class="apexcharts-tooltip-text-y-label">${name}:</span>
311                <span class="apexcharts-tooltip-text-y-value">${valueFn(val)}</span></div>`;
312        }).join('');
313        // 타이틀 바는 기본 클래스(apexcharts-tooltip-title)로 원본과 동일. 행 간격만 gap-3(12px)로 제어.
314        // minWidth 지정 시 루트 래퍼에 min-width → 짧은 내용에도 최소 너비 유지.
315        return `<div${minWidthStyle}><div class="apexcharts-tooltip-title">${title}</div><div class="d-flex flex-column gap-3 px-3 pt-2 pb-3">${rows}</div></div>`;
316    };
317}
318
319/**
320 * [개발자 도구] 프로젝트 기본 에셋 뷰어
321 * 콘솔에서 showProjectAssets() 로 호출
322 */
323window.showProjectAssets = function () {
324    // 현재 페이지 DOM에서 파비콘·OG 이미지를 동적으로 읽어옴
325    const faviconEl = document.querySelector('link[rel~="icon"]');
326    const faviconSrc = faviconEl ? faviconEl.href : null;
327    const faviconLabel = faviconSrc ? faviconSrc.split('/').pop() : null;
328
329    const ogImageEl = document.querySelector('meta[property="og:image"]');
330    const ogImageSrc = ogImageEl ? ogImageEl.content : null;
331    const ogImageLabel = ogImageSrc ? ogImageSrc.split('/').pop() : null;
332
333    const sections = [
334        faviconSrc ? {
335            title: '파비콘 (현재 페이지)',
336            items: [{ label: faviconLabel, src: faviconSrc }],
337        } : null,
338        {
339            title: '로고',
340            items: [
341                { label: 'logo-brand.svg', src: '/assets/images/logo-brand.svg' },
342                { label: 'logo-brand.png', src: '/assets/images/logo-brand.png' },
343                { label: 'logo-brand-white.svg', src: '/assets/images/logo-brand-white.svg', dark: true },
344                { label: 'logo-icon.svg', src: '/assets/images/logo-icon.svg' },
345                { label: 'logo.svg', src: '/assets/images/logo.svg' },
346            ],
347        },
348        ogImageSrc ? {
349            title: 'OG 이미지 (현재 페이지)',
350            items: [{ label: ogImageLabel, src: ogImageSrc }],
351        } : null,
352    ].filter(Boolean);
353
354    const renderItems = (items) => items.map(item => `
355        <div class='d-inline-flex flex-column align-items-center gap-2 m-2'>
356            <a href='${item.src}' target='_blank' class='d-block p-4 border rounded-3 ${item.dark ? 'bg-dark' : 'bg-light'}' title='${item.src}'>
357                <img src='${item.src}' class='mh-120px mw-175px d-block object-fit-contain' onerror='this.closest("a").style.opacity="0.4"' />
358            </a>
359            <span class='fs-6 text-gray-600 text-center' style='max-width:190px;word-break:break-all;'>${item.label}</span>
360        </div>
361    `).join('');
362
363    const body = sections.map(sec => `
364        <div class='mb-8'>
365            <div class='fs-5 fw-bold text-uppercase text-gray-500 border-bottom border-2 pb-3 mb-5'>${sec.title}</div>
366            <div class='d-flex flex-wrap gap-2'>${renderItems(sec.items)}</div>
367        </div>
368    `).join('') + '<p class=\'fs-7 text-muted mt-2\'>이미지를 클릭하면 새 탭에서 원본을 볼 수 있습니다.</p>';
369
370    const modal = new DynamicModal('프로젝트 기본 에셋', body);
371    modal.setModalSize('lg');
372    modal.setOptions({ verticalCenter: true, scrollBody: true });
373    modal.hideButton();
374    modal.show();
375};
376
377/**
378 * 가이드 툴팁 초기화 (관리자, 파트너에서만 사용)
379 *
380 * @desc [content]가 있으면 우선 표시하고, 없으면 [type]으로 i18n에서 불러옴
381 */
382function initGuideTooltip(container = document) {
383
384    waitForI18nInitialization(() => {
385        container.querySelectorAll('.guide-tooltip').forEach((el) => {
386            let content = el.dataset.tooltipContent;
387            let type = el.dataset.tooltipType;
388
389            // content가 비어있고 type이 있을 경우 i18n에서 불러오기
390            if (!isNullOrEmpty(type) && isNullOrEmpty(content)) {
391                content = i18n.t(`admin:tooltip.${type}`);
392            }
393
394            if (isNullOrEmpty(content)) {
395                return;
396            }
397            
398            el.innerHTML = '<i class="fa-regular fa-circle-question"></i>'
399            const tooltip = new bootstrap.Tooltip(el, {
400                title: i18n.t(`admin:tooltip.${type}`),
401                placement: 'top',
402                // 여러 줄 문구는 data-tooltip-class="custom-tooltip"로 좌측정렬 지정 (기본은 가운데정렬)
403                customClass: el.dataset.tooltipClass || 'tooltip-inverse',
404                html: true,
405            })
406        });
407    });
408}
409
410/**
411 * 암호화(secure) 파일 메타 조회 + 이미지/PDF 판단
412 *
413 * - secure 파일은 파일명이 숫자 id뿐이라 /info 응답
413의 mimeType·originalName으로 판단한다
414 * - mime이 octet-stream 등이면(앱 업로드 contentType 누락) originalName 확장자로 폴백 판단
415 * - typeAttr: file-download/file-upload 태그에 그대로 넣을 속성 문자열 (is-image / is-pdf / 빈값)
416 *
417 * usage: const meta = await getSecureFileMeta(filePath, fileName);
418 */
419async function getSecureFileMeta(filePath, fileName) {
420    const meta = { isImage: false, isPdf: false, originalName: null, typeAttr: '' };
421    if (!/^\d+$/.test(String(fileName))) {
422        return meta;
423    }
424
425    try {
426        const info = await fetch(`${filePath}/${fileName}/info`, { credentials: 'include' }).then((r) => (r.ok ? r.json() : null));
427        const mime = info?.data?.mimeType ?? '';
428        meta.originalName = info?.data?.originalName ?? null;
429
430        const orgExt = (meta.originalName ?? '').split('.').pop().toLowerCase();
431        if (mime.indexOf('image/') === 0 || ['jpg', 'jpeg', 'jfif', 'png', 'gif', 'webp', 'bmp'].includes(orgExt)) {
432            meta.isImage = true;
433        } else if (mime === 'application/pdf' || orgExt === 'pdf') {
434            meta.isPdf = true;
435        }
436    } catch (e) { /* 조회 실패 시 기본값 (다운로드 버튼만 표시) */ }
437
438    meta.typeAttr = meta.isImage ? 'is-image="true"' : meta.isPdf ? 'is-pdf="true"' : '';
439    return meta;
440}
441
442/**
443 * 목록 테이블 정렬 헤더 초기화
444 *
445 * @desc 정렬할 <th> 안에 아래 한 줄만 선언하면 화살표 주입/상태 표시/토글이 전부 처리된다
446 *       <span class="sort-header cursor-pointer text-hover-primary d-inline-flex align-items-center" data-sort="컬럼키">헤더명</span>
447 *
448 *       - data-sort  : 서버 매퍼가 분기할 컬럼 키
449 *       - data-order : 활성 헤더에만 이 함수가 부여하는 현재 방향 (asc/desc)
450 *
451 *       헤더를 누르면 내림차순 → 오름차순 → 해제 3단계를 순환한다.
452 *       화살표(▲▼)는 상태 표시용으로 항상 보이며, 활성 방향만 진하게 강조한다.
453 *
454 *       정렬 값은 form#searchForm 의 hidden sort/order 로 전달한다. (없으면 여기서 만든다)
455 *       csv.js가 searchForm 전체를 FormData로 읽어가므로 다운로드에도 같은 정렬이 적용된다.
456 */
457function initSortHeader() {
458
459    const ARROW_HTML = `
460        <span class="d-inline-flex flex-column lh-1 ms-2 fs-10 text-gray-700" aria-hidden="true">
461            <span class="sort-arrow" data-order="asc">&#9650;</span>
462            <span class="sort-arrow" data-order="desc">&#9660;</span>
463        </span>
464    `;
465
466    let headers = document.querySelectorAll('.sort-header');
467    let searchForm = document.querySelector('form#searchForm');
468
469    if (headers.length == 0 || !searchForm) return;
469   // 정렬 헤더가 없는 화면은 아무것도 하지 않는다
470
471    // 현재 정렬 상태는 URL이 갖고 있다 (이 화면을 그린 조건 그대로)
472    let params = new URLSearchParams(location.search);
473    let sortInput = ensureHiddenInput('sort', params.get('sort'));
474    let orderInput = ensureHiddenInput('order', params.get('order'));
475
476    headers.forEach((header) => {
477        header.insertAdjacentHTML('beforeend', ARROW_HTML);
478
479        // 활성 컬럼에만 data-order를 남겨 현재 정렬 상태를 DOM에 드러낸다
480        // order 없이 sort만 있는 URL은 서버가 내림차순으로 처리하므로 표시도 거기에 맞춘다
481        if (sortInput.value && header.dataset.sort == sortInput.value) {
482            header.dataset.order = orderInput.value || 'desc';
483        }
484
485        paintArrows(header);
486    });
487
488    document.addEventListener('click', (e) => {
489        let header = e.target.closest('.sort-header');
490        if (!header) return;
491
492        // 활성 헤더에만 data-order가 있으므로, 없으면 다른 컬럼이거나 해제 상태다
493        // 헤더를 누르면 내림차순 → 오름차순 → 해제 3단계를 순환한다
494        let order = header.dataset.order;
495        let nextOrder = !order ? 'desc' : (order == 'desc' ? 'asc' : '');
496
497        sortInput.value = nextOrder ? header.dataset.sort : '';   // 해제하면 서버 기본 정렬로 돌아간다
498        orderInput.value = nextOrder;
499
500        searchForm.requestSubmit();   // 정렬이 바뀌면 page 파라미터가 빠져 1페이지부터 다시 조회된다
501    });
502
503    /**
504     * 정렬 값 전달용 hidden input을 searchForm 안에 보장한다.
505     * 폼 밖에 있으면 csv.js의 FormData에 안 잡혀 다운로드만 조용히 기본 정렬로 나가므로,
506     * 템플릿에 맡기지 않고 여기서 만든다.
507     */
508    function ensureHiddenInput(name, value) {
509        let input = searchForm.querySelector(`[name="${name}"]`);
510
511        if (!input) {
512            input = document.createElement('input');
513            input.type = 'hidden';
514            input.name = name;
515            searchForm.appendChild(input);
516        }
517
518        input.value = value ?? '';
519
520        return input;
521    }
522
523    /**
524     * 화살표 농도 갱신. 화살표는 항상 보이되, 활성 방향만 진하게 강조하고
525     * 나머지(반대 방향·정렬 안 된 헤더)는 흐리게 둔다.
526     */
527    function paintArrows(header) {
528        let order = header.dataset.order;
529
530        header.querySelectorAll('.sort-arrow').forEach((arrow) => {
531            let active = order && arrow.dataset.order == order;
532
533            arrow.classList.remove('opacity-25', 'opacity-75');
534            arrow.classList.add(active ? 'opacity-75' : 'opacity-25');
535        });
536    }
537}

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.