PageSourceSearch

http://dmcoffeewalk.com/admin/js/member.js?1791434756

js dmcoffeewalk.com collected 2026-10-08 07:37:30 UTC 245,404 bytes, 6,422 lines download raw bytes

1const MEMBER_MANAGE = (function () {
2  let $form
3  let $member_agree_wrap
4  let header_ctl
5  let $fileupload_profile_img, $profile_img, $photo_tmp_idx
6  let member_code
7  let passwd_html_show_click = false
8  let $admin_group_list_wrap
9  let afterSavePhotoCallback;
10  const tw_address = {}
11
12  const init = function (type, code) {
13    member_code = code
14    $form = $('#dof')
15    $member_agree_wrap = $('#member_agree_wrap')
16    $fileupload_profile_img = $('#fileupload_profile_img')
17    $profile_img = $('#profile_img')
18    $photo_tmp_idx = $('#photo_tmp_idx')
19
20    header_ctl = new HEADER_CONTROL()
21    header_ctl.init()
22    if (type == 'add') {
23      header_ctl.addBtn('cancel', function () {
24        history.go(-1)
25      })
26      header_ctl.addBtn('add', function () {
27        submit('add')
28      })
29    } else {
30      header_ctl.addBtn('delete', function () {
31        adminMember.openDeleteMember(JSON.stringify([{ member_code, delete_old_auth_log: false }]))
32      })
33      header_ctl.addBtn('save', function () {
34        submit('save')
35      })
36    }
37    createEvent()
38    setUpload()
39
40    $admin_group_list_wrap = $('._admin_group_list_wrap')
41    if ($admin_group_list_wrap.find('input[type=checkbox]:checked').length > 0) {
42      $('._manager_info_wrap').show()
43    } else {
44      $('._manager_info_wrap').hide()
45    }
46    $admin_group_list_wrap.find('input[type=checkbox]').off('click').on('click', function () {
47      if ($admin_group_list_wrap.find('input[type=checkbox]:checked').length > 0) {
48        $('._manager_info_wrap').show()
49      } else {
50        $('._manager_info_wrap').hide()
51      }
52      header_ctl.change()
53    })
54  }
55
56  const setAfterSavePhotoCallback = function (callback) {
57	  afterSavePhotoCallback = callback;
58  }
59
60  const changeCountry = function (country, code) {
61    $form.find('._addr_form_wrap').hide()
62    if (country.trim() === '') {
63      return false
64    }
65    $.ajax({
66      url: '/admin/ajax/member/get_site_address_format.cm',
67      data: { country_code: country, unit_code: code },
68      type: 'POST',
69      dataType: 'json',
70      success: function (res) {
71        const format = res.format
72        switch (format) {
73          case 'KR':
74            $form.find('#kr_addr_form_wrap').show()
75            break
76          case 'TW':
77            $form.find('#tw_addr_form_wrap').show()
78            break
79          case 'VN':
80            $form.find('#vn_addr_form_wrap').show()
81            break
82          case 'HK':
83            $form.find('#hk_addr_form_wrap').show()
84            break
85          case '3':
86            $form.find('#jp_addr_form_wrap').show()
87            break
88          case '5':
89            $form.find('#en_addr_form_wrap').show()
90            break
91        }
92      }
93    })
94  }
95
96  const checkSubscribePeriodChange = function () {
97    let subscribe_check = false
98    $form.find('._subscribe_left_date').each(function () {
99      if ($(this).prop('disabled')) return false
100      if ($(this).val() != $(this).attr('placeholder')) {
101        subscribe_check = true
102        return false
103      }
104    })
105    return (!subscribe_check || confirm(getLocalizeString('설명_회원그룹이용권기간변경안내', '', '회원그룹 이용권 기간에 변경된 데이터가 있습니다.\n저장하시겠습니까?')))
106  }
107
108  var submit = function (type) {
109    const data = $form.serializeObject()
110    data.member_agree_info = $member_agree_wrap.serializeObject()
111    if (!checkSubscribePeriodChange()) return false
112    $.ajax({
113      type: 'POST',
114      data,
115      url: ('/admin/ajax/member/add.cm'),
116      dataType: 'json',
117      async: false,
118      cache: false,
119      success: function (res) {
120        if (res.msg == 'SUCCESS') {
121          header_ctl.save()
122          if (res.admin_group_fail === 'Y') {
123            alert(getLocalizeString('설명_운영진설정권한이없어운영진그룹은설정되지않았습니다', '', '운영진 설정 권한이 없어 운영진 그룹은 설정되지 않았습니다.'))
124          }
125	        mixpanel.track('complete_bo_member_list_add',{'site_code': SITE_CODE}
125)
126          if (type == 'add') history.go(-1)
127          else location.reload()
128        } else { alert(res.msg) }
129      }
130    })
131  }
132
133  var createEvent = function () {
134    $form.find('input, textarea, select').off('change, keyup').on('change, keyup, blur', function () {
135      header_ctl.change()
136    })
137    $form.find('input[type=checkbox],input[type=radio],input[type=file],._file_remove').off('click').on('click', function () {
138      if ($(this).attr('name') == 'default_group[]' && $(this).data('subscribe') == 'Y') {
139        const group_idx = $(this).val()
140        const is_checked = $(this).prop('checked')
141        if (!is_checked && !confirm(getLocalizeString('설명_회원그룹이용권구매로지정된그룹제거시안내', '', '이 그룹은 회원그룹 이용권 구매로 자동 지정된 그룹입니다. \n그룹지정을 해제하고 나중에 다시 그룹을 지정하더라도 자동 그룹 해제일에 자동 해제가 되지 않게 됩니다.\n\n그룹 지정을 해제하시겠습니까?'))) {
142          return false
143        }
144
145        // 이용권 해제 예정이므로 기간 수정못하게 disabled 처리
146        $form.find('._subscribe_left_date').each(function () {
147          if (group_idx == $(this).data('group_idx')) {
148            $(this).prop('disabled', !is_checked)
149          }
150        })
151      }
152      header_ctl.change()
153    })
154    $form.find('select').change(function () {
155      header_ctl.change()
156    })
157
158    $form.find('._subscribe_left_date').on('keypress', function (e) {
159      $(this).val($(this).val().replace(/[^0-9]/g, ''))
160      return (e.keyCode >= 48 && e.keyCode <= 57)
161    })
162    $form.find('._subscribe_left_date').on('keyup', function (e) {
163      $(this).val($(this).val().replace(/[^0-9]/g, ''))
164      return (e.keyCode >= 48 && e.keyCode <= 57)
165    })
166
167    $member_agree_wrap.find('input[type=checkbox]').off('click').on('click', function () {
168      header_ctl.change()
169    })
170
171    const $admin_memo_chars_title = $('#admin_memo_chars_title')
172    const $admin_memo = $('#admin_memo')
173    const $btn_admin_memo = $('#btn_admin_memo')
174    if ($admin_memo.length > 0) {
175      autosize($admin_memo)
176      var pre_admin_memo = $admin_memo.val()
177      $admin_memo_chars_title.html(getByteLength(pre_admin_memo) + '/500')
178      $admin_memo.keyup(function (e) {
179        const content = $(this).val()
180        $admin_memo_chars_title.html(getByteLength(content) + '/500')
181      })
182    }
183    $btn_admin_memo.click(function () {
184      const memo = $admin_memo.val()
185      if (pre_admin_memo !== memo) {
186        pre_admin_memo = memo
187        const code = $form.find("input[name='code']").val()
188        if (code !== '') {
189          adminMember.updateAdminMemo(code, memo, function (res) {
190            if (res.msg !== 'SUCCESS') alert(res.msg)
191            else location.reload()
192          })
193        }
194      }
195    })
196
197    $('#create_recommend_code_btn').click(function () {
198      let is_create_recommend_code = false
199      if (!is_create_recommend_code) {
200        const member_code = $(this).data('member-code')
201
202        $.ajax({
203          type: 'POST',
204          data: { member_code },
205          url: ('/admin/ajax/member/create_recommend_code.cm'),
206          dataType: 'json',
207          async: true,
208          cache: false,
209          success: function (res) {
210            is_create_recommend_code = true
211            if (res.msg === 'SUCCESS') {
212              $('._recommend_code_wrap').text(res.recommend_code || '')
213              $('._recommend_tmp_input').val(res.recommend_code)
214              $('._recommend_create_set').addClass('hide')
215              $('._recommend_set').removeClass('hide')
216            } else {
217              alert(res.msg)
218            }
219          }
220        })
221      }
222    })
223  }
224
225  var setUpload = function () {
226    $fileupload_profile_img.setUploadImage({
227      url: '/admin/ajax/upload_image.cm',
228      formData: { temp: 'Y', target: 'member' }
229    }, function (res, data) {
230      $.each(data, function (e, tmp) {
231        if (tmp.tmp_idx > 0) {
232          header_ctl.change();
233        }
234        if (tmp.url != '') {
235          $profile_img.attr('src', CDN_UPLOAD_URL + tmp.url);
236          $photo_tmp_idx.val(tmp.tmp_idx);
237
238	        if (afterSavePhotoCallback) {
239		        afterSavePhotoCallback();
240	        }
241        } else {
242          alert(tmp.error);
243        }
244      })
245    })
246  }
247
248  const changeJoinTypeForm = function () {
249    const join_type_code = $form.find('select[name=join_type_select]').val()
250    $form.find('input[name=join_type_code]').val(join_type_code)
251    $.ajax({
252      type: 'POST',
253      data: { join_type_code },
254      url: ('/admin/ajax/member/change_join_type_form.cm'),
255      dataType: 'json',
256      async: true,
257      cache: false,
258      success: function (res) {
259        if (res.msg === 'SUCCESS') {
260          $form.find('._type_form_wrap').html(res.html)
261          setDaumZipcode()
262          changeCountry('', UNIT_CODE)
263        } else {
264          alert(res.msg)
265        }
266      }
267    })
268  }
269
270  var setDaumZipcode = function () {
271    const join_addr = new ZIPCODE_DAUM()
272    let address = ''
273    let splitAddress = ''
274    let building = ''
275    let street = ''
276    let city = ''
277    let state = ''
278
279    join_addr.init({
280      addr_container: $('#join_addr_container'),
281      addr_pop: $('#join_addr_container ._add_list'),
282      post_code: $('#join_addr_postcode'),
283      addr: $('#join_addr'),
284      onShow: function () {
285        $('#join_addr_container').css({ top: -50 + 'px' })
286        $('#join_addr_detail').parent().css({ top: -50 + 'px' })
287      },
288      onComplete: function (data) {
289        $('#join_addr_detail').focus()
290        address = data.addressEnglish
291        if (address == 'undefined') address = data.jibunAddressEnglish
292        splitAddress = address.split(',')
293        if (splitAddress.length > 5) {
294          building = splitAddress[0]
295          street = splitAddress[1]
296          city = splitAddress[2]
297          state = splitAddress[3] + ' ' + splitAddress[4]
298        } else {
299          building = splitAddress[0]
300          street = splitAddress[1]
301          city = splitAddress[2]
302          state = splitAddress[3]
303        }
304        $("input[name='addr_building']").val(building)
305        $("input[name='addr_street']").val(street)
306        $("input[name='addr_city']").val(city)
307        $("input[name='addr_state']").val(state)
308        $("input[name='addr_zipcode']").val(data.zonecode)
309      },
310      onClose: function () {
311        $('#join_addr_detail').parent().css({ top: 0 + 'px' })
312      },
313      height: '470'
314    })
315  }
316
317  const openChangePasswordMyModal = function (idx) {
318    $.ajax({
319      type: 'GET',
320      url: '/admin/ajax/change_password_my_modal.cm',
321      dataType: 'json',
322      data: { idx },
323      async: true,
324      cache: false,
325      success: function (data) {
326        if (data.msg === 'SUCCESS') {
327          const $html = $(data.html)
328          $.cocoaDialog.open({
329            type: 'site_change_password',
330            custom_popup: $html
331          })
332        } else {
333          alert(data.msg)
334        }
335      }
336    })
337  }
338
339  return {
340    init: function (type, code) {
341      init(type, code)
342    },
343    changeCountry: function (country, code) {
344      changeCountry(country, code)
345    },
346    showPasswdHtml: function () {
347      if (!passwd_html_show_click) {
348        passwd_html_show_click = true
349        let placeholder_text = getLocalizeString('설명_비밀번호', '', '비밀번호');
350        if (member_code !== '') {
351          placeholder_text = getLocalizeString('설명_비밀번호변경시에만입력', '', '비밀번호 변경시에만 입력해주세요');
352        }
353        const passwd_html = "<input type='password'  autocomplete='off' name='passwd' id='user_password' class='form-control' placeholder='" + placeholder_text + "'>";
354        $('#password_wrap .col-md-9').append(passwd_html);
355
356        $form.find('input[name="passwd"]').off('change, keyup').on('change, keyup, blur', function () {
357          header_ctl.change();
358        })
359      }
360    },
361    changeJoinTypeForm: function () {
362      changeJoinTypeForm();
363    },
364    setDaumZipcode: function () {
365      setDaumZipcode();
366    },
367    openChangePasswordMyModal: function (idx) {
368      openChangePasswordMyModal(idx);
369    },
370    setAfterSavePhotoCallback: function(callback) {
371	    setAfterSavePhotoCallback(callback);
372    },
373	  changeData : function(){
374		  header_ctl.change();
375	  }
376  }
377}())
378
379const MEMBER_GROUP = (function () {
380  let modal_type = 'list'
381  let current_member_code, base_url, managers_setting_permission
382  let $group_list, $drop_group_list, $drop_shopping_group_list, $check_drop_group_list,
383    $check_drop_shopping_group_list, $no_group_cnt
384  let group_list_item_tmp, drop_group_list_tmp
385
386  const group_item_html_class = '_group_item'
387  const group_list_data = new DATA()
388  let prev_group_type
389  let prev_drop_group_type
390
391  const setGroupMemberCount = function (data) {
392    group_list_data.data[data.code].member_cnt = data.member_cnt
393  }
394
395  const addGroupList = function (data) {
396    data.idx = parseInt(data.idx)
397    group_list_data.add(data.code, data)
398    addGroupListHtml(data)
399  }
400
401  const setCurrentMemberCode = function (code) {
402    current_member_code = code
403  }
404
405  const updateGroupList = function (code, data) {
406    group_list_data.add(code, data)
407    resetGroupListHtml()
408  }
409
410  const modifyGroupList = function (type, data) {
411    if (type == 'delete') {
412      group_list_data.remove(data.code)
413    } else {
414      if (type == 'add') {
415        data.idx = parseInt(data.idx)
416      }
417      group_list_data.add(data.code, data)
418    }
419  }
420
421  const addDropGroupList = function (data) {
422    let concede_script = ''
423    const group_type = data.group_type
424    let edit_script = "adminMember.openGroupForm('" + data.idx + "');"
425    let li_id = 'group_item_' + data.code
426
427    switch (data.group_type) {
428      case 'shopping':
429      case 'activity':
430        concede_script = "MEMBER_GROUP.concedeChangeSpecialGroup('" + group_type + "',$(this),'" + data.idx + "')"
431        if (data.code == group_type) {
432          edit_script = 'CONFIG_SHOPPING_GROUP.openShoppingGroupDefaultForm();'
433          li_id = group_type + '_group_default'
434        }
435        break
436      case 'admin':
437      case 'shop_in_shop':
438        if (managers_setting_permission) {
439          concede_script = "MEMBER_GROUP.concedeToggleGroup($(this),'" + data.idx + "')"
440        } else {
441          concede_script = "alert(getLocalizeString('설명_운영진그룹설정권한이없습니다', '', '운영진 그룹 설정 권한이 없습니다.'))"
442        }
443        break
444      case 'member':
445        concede_script = "MEMBER_GROUP.concedeToggleGroup($(this),'" + data.idx + "')"
446        break
447    }
448    $drop_group_list = $('._drop_group_list')
449    $drop_shopping_group_list = $('._drop_shopping_group_list')
450    const $drop_list = (data.group_type == 'shopping') ? $drop_shopping_group_list : $drop_group_list
451    const tmp_data = {
452      id: li_id,
453      class: (data.is_default ? 'default_' : '') + group_type + ' ' + (current_member_code == data.code ? 'active checked ' + group_item_html_class : group_item_html_class),
454      code: data.code,
455      href: base_url + 'member_group=' + data.code,
456      title: group_type === 'member' ? data.title : data.group_type_name + ' - ' + data.title,
457      group_type: data.group_type_name,
458      cnt: data.member_cnt > 0 ? parseInt(data.member_cnt) : '',
459      edit_script: edit_script + 'return false;'
460    }
461
462    if (!prev_drop_group_type || prev_drop_group_type !== group_type) {
463      prev_drop_group_type = group_type
464      var use_div = true
465    } else {
466      var use_div = false
467    }
468
469    $drop_list.each(function () {
470      const group_data = $(this).data('groups')
471      let drop_tmp_data = {
472        class: (data.is_default ? 'default_' : '') + group_type + ' ' + ($.inArray(data.idx, group_data) !== -1 ? 'active checked ' + group_item_html_class : group_item_html_class),
473        idx: data.idx,
474        concede_script
475      }
476
477      drop_tmp_data = $.extend(tmp_data, drop_tmp_data)
478      const $drop_group_list_html = $(getTemplateConvert(drop_group_list_tmp, drop_tmp_data)).data(data)
479
480      appendGroupHtml($(this), $drop_group_list_html, group_type, data.is_default, use_div)
481    })
482  }
483
484  var addGroupListHtml = function (data) {
485    let concede_script = ''
486    const group_type = data.group_type
487    let edit_script = "adminMember.openGroupForm('" + data.idx + "');"
488    let li_id = 'group_item_' + data.code
489    switch (data.group_type) {
490      case 'shopping':
491      case 'activity':
492        concede_script = "MEMBER_GROUP.concedeChangeSpecialGroup('" + group_type + "',$(this),'" + data.idx + "')"
493        if (data.code == group_type) {
494          edit_script = 'CONFIG_SHOPPING_GROUP.openShoppingGroupDefaultForm();'
495          li_id = group_type + '_group_default'
496        }
497        break
498      case 'admin':
499      case 'shop_in_shop':
500        if (managers_setting_permission) {
501          concede_script = "MEMBER_GROUP.concedeToggleGroup($(this),'" + data.idx + "')"
502        } else {
503          concede_script = "alert(getLocalizeString('설명_운영진그룹설정권한이없습니다', '', '운영진 그룹 설정 권한이 없습니다.'))"
504        }
505        break
506      case 'member':
507        concede_script = "MEMBER_GROUP.concedeToggleGroup($(this),'" + data.idx + "')"
508        break
509    }
510
511    const tmp_data = {
512      id: li_id,
513      class: (data.is_default ? 'default_' : '') + group_type + ' ' + (current_member_code == data.code ? 'active checked ' + group_item_html_class : group_item_html_class),
514      code: data.code,
515      href: base_url + 'member_group=' + data.code,
516      title: group_type === 'member' ? data.title : data.group_type_name + ' - ' + data.title,
517      group_type: data.group_type_name,
518      cnt: data.member_cnt > 0 ? parseInt(data.member_cnt) : '',
519      edit_script: edit_script + 'return false;'
520    }
521
522    if (data.member_cnt === '9999+') {
523      tmp_data.cnt = '9999+'
524    }
525
526    if (!prev_group_type || prev_group_type !== group_type) {
527      prev_group_type = group_type
528      var use_div = true
529    } else {
530      var use_div = false
531    }
532    const $group_list_html = $(getTemplateConvert(group_list_item_tmp, tmp_data)).data(data)
533    appendGroupHtml($group_list, $group_list_html, group_type, data.is_default, use_div)
534
535    const $drop_list = (data.group_type == 'shopping') ? $drop_shopping_group_list : $drop_group_list
536    $drop_list.each(function () {
537      const group_data = $(this).data('groups')
538      let drop_tmp_data = {
539        class: (data.is_default ? 'default_' : '') + group_type + ' ' + ($.inArray(data.idx, group_data) !== -1 ? 'active checked ' + group_item_html_class : group_item_html_class),
540        idx: data.idx,
541        concede_script
542      }
543
544      drop_tmp_data = $.extend(tmp_data, drop_tmp_data)
545      const $drop_group_list_html = $(getTemplateConvert(drop_group_list_tmp, drop_tmp_data)).data(data)
546      appendGroupHtml($(this), $drop_group_list_html, group_type, data.is_default, use_div)
547    })
548
549    const $check_drop_list = (data.group_type == 'shopping') ? $check_drop_shopping_group_list : $check_drop_group_list
550    const check_group = $check_drop_list.data('groups')
551    let check_drop_tmp_data = {
552      class: (data.is_default ? 'default_' : '') + group_type + ' ' + ($.inArray(data.idx, check_group) !== -1 ? 'active checked ' + group_item_html_class : group_item_html_class),
553      idx: data.idx,
554      concede_script
555    }
556    check_drop_tmp_data = $.extend(tmp_data, check_drop_tmp_data)
557    const $check_drop_group_list_html = $(getTemplateConvert(drop_group_list_tmp, check_drop_tmp_data)).data(data)
558    appendGroupHtml($check_drop_list, $check_drop_group_list_html, group_type, data.is_default, use_div)
559  }
560
561  // 관리자페이지 - 그룹 HTML 추가할 위치 지정
562  var appendGroupHtml = function ($obj, $html, _group_type, is_default, use_divider) {
563    const group_type = (is_default ? 'default_' : '') + _group_type
564    use_divider = (use_divider === true)
565    const $divider = '<li class="divider ' + group_item_html_class + '"></li>'
566    if ($obj.find('.' + group_type).length === 0) {
567      let next_element = []
568      switch (group_type) {
569        case 'default_member' :
570          next_element.push('.member')
571          break
572        case 'member':
573          next_element.push('.admin')
574          break
575        case 'admin':
576          next_element.push('.shopping')
577          break
578        case 'shopping':
579          break
580      }
581      next_element = next_element.length > 0 ? next_element.join(',') : ''
582      const is_first = ($obj.find('.' + group_item_html_class).length === 0)
583
584      if (next_element === '' || $obj.find(next_element).length === 0) {
585        if (use_divider && !is_first) $obj.append($divider)
586        $obj.append($html)
587      } else {
588        $obj.find(next_element).first().before($html)
589        if (use_divider) $obj.find(next_element).first().before($divider)
590      }
591    } else {
592      $obj.find('.' + group_type + ':last').after($html)
593    }
594  }
595
596  var resetGroupListHtml = function () {
597    prev_group_type = ''
598    prev_drop_group_type = ''
599    $('.' + group_item_html_class).remove()
600    $.each(group_list_data.data, function (code, data) {
601      addGroupListHtml(data)
602    })
603  }
604
605  const init = function (code, url, permission, type) {
606    current_member_code = code
607    base_url = url
608    managers_setting_permission = permission
609    $group_list = $('#group_list')
610    $no_group_cnt = $('._no_group_cnt')
611    modal_type = type
612    group_list_item_tmp = '<li class="{class}" id="{id}" onclick="MEMBER_DATA.changeUrl(null, null, \'{href}\', \'group\', \'{code}\', \'1\')"><a href="#"><span id="group_item_title_{code}">{title}</span> <small id="group_item_count_{code}" class="margin-left-lg text-bold opacity-75">{cnt}</small><button onclick="{edit_script}" class="pull-right btn btn-flat no-padding hover-visible"><i class="zmdi zmdi-settings"></i></button></a></li>'
613    drop_group_list_tmp = '<li class="{class}"><a href="javascript:;" data-idx="{idx}" onclick="{concede_script}">{title}</a></li>'
614
615    $drop_group_list = $('._drop_group_list')
616    $drop_shopping_group_list = $('._drop_shopping_group_list')
617
618    $drop_group_list.each(function () {
619      // 기본 그룹
620      let _new_d = []
621      $.each($(this).data('groups').toString().split(','), function (e, v) {
622        v = v == '' ? 0 : parseInt(v)
623        if (v > 0) { _new_d.push(v) }
624      })
625      $(this).data('groups', _new_d)
626
627      // 이용권 그룹
628      _new_d = []
629      $.each($(this).data('subscribe_groups').toString().split(','), function (e, v) {
630        v = v == '' ? 0 : parseInt(v)
631        if (v > 0) { _new_d.push(v) }
632      })
633      $(this).data('subscribe_groups', _new_d)
634    })
635
636    $drop_shopping_group_list.each(function () {
637      let member_data = '' + $(this).data('groups')
638      member_data = member_data.split(',')
639      const _new_d = []
640      $.each(member_data, function (e, v) {
641        v = v == '' ? 0 : parseInt(v)
642        _new_d.push(v)
643      })
644      $(this).data('groups', _new_d)
645    })
646
647    $check_drop_group_list = $('._check_drop_group_list')
648    $check_drop_group_list.data('member', [])
649    $check_drop_group_list.data('groups', [])
650
651    $check_drop_shopping_group_list = $('._check_drop_shopping_group_list')
652    $check_drop_shopping_group_list.data('member', [])
653    $check_drop_shopping_group_list.data('groups', [])
654  }
655
656  const openGroupForm = function (idx, group_type) {
657    $.ajax({
658      type: 'POST',
659      data: { idx, type: modal_type, group_type },
660      url: ('/admin/ajax/config/membership/group_form.cm'),
661      dataType: 'json',
662      async: false,
663      cache: false,
664      success: function (result) {
665        if (result.msg == 'SUCCESS') {
666          $.cocoaDialog.open({ type: 'admin_group_form', custom_popup: result.html, width: 550 })
667        } else {
668          alert(result.msg)
669        }
670      }
671    })
672  }
673
674  const concedeChangeSpecialGroup = function (type, obj, idx) {
675    const $list = obj.closest('._drop_shopping_group_list, ._check_drop_shopping_group_list')
676    const member_idx = $list.data('member')
677
678    let _idx = -1
679
680    if (!$.isArray(member_idx)) {
681      if ($list.find('.' + type + '.active.checked').length == 0) return false
682      _idx = $list.find('.' + type + '.active.checked').data('idx')
683    }
684
685    if (idx != _idx) {
686      idx = parseInt(idx)
687
688      let old_data = {}
689      $.ajax({
690        type: 'POST',
691        data: { group_type: type, member_idx, new_group_code: idx },
692        url: '/admin/ajax/member/group_concede_proc.cm',
693        dataType: 'json',
694        async: true,
695        cache: false,
696        success: function (result) {
697          if (result.msg == 'SUCCESS') {
698            const new_data = group_list_data.get(obj.parent().data('code'))
699            const _group = new_data.title
700
701            if ($.isArray(member_idx)) {
702              $.each(member_idx, function (e, v) {
703                $('._drop_shopping_group_btn_' + v).html(_group)
704                $('._drop_shopping_group_list_' + v).data('groups', [idx])
705
706                $.each(group_list_data.data, function (index, item) {
707                  if (item.idx === idx) {
708                    old_data = item
709                    return false
710                  }
711                })
712
713                // old_data = group_list_data.get($('._drop_shopping_group_list_' + v).find('.active.checked').data('code'));
714                old_data.member_cnt -= 1
715                new_data.member_cnt = new_data.member_cnt == '' ? 0 : new_data.member_cnt
716                new_data.member_cnt += 1
717              })
718            } else {
719              $('._drop_shopping_group_btn_' + member_idx).html(_group)
720              $('._drop_shopping_group_list_' + member_idx).data('groups', [idx])
721
722              // old_data = group_list_data.get($('._drop_shopping_group_list_' + member_idx).find('.active.checked').data('code'));
723              $.each(group_list_data.data, function (index, item) {
724                if (item.idx === idx) {
725                  old_data = item
726                  return false
727                }
728              })
729              old_data.member_cnt -= 1
730              new_data.member_cnt = new_data.member_cnt == '' ? 0 : new_data.member_cnt
731              new_data.member_cnt += 1
732            }
733
734            resetGroupListHtml()
735            MEMBER_DATA.renderGroupCount(idx)
736          } else {
737            alert(result.msg)
738          }
739        }
740      })
741
742      $list.data('groups', [idx])
743    }
744  }
745
746  const concedeToggleGroup = function (obj, idx) {
747    idx = parseInt(idx)
748    const $list = obj.closest('._drop_group_list, ._check_drop_group_list')
749    let member_item_group_data = $list.data('groups')
750    let member_item_subscribe_data = $list.data('subscribe_groups')
751    const member_idx = $list.data('member')
752
753    const $item = obj.parent()
754    const item_data = $item.data()
755    const _old_data = group_list_data.get(item_data.code)
756
757    // 다중 선택 시
758    if ($.isArray(member_idx)) {
759      if (member_idx.length > 1) {
760        const confirmationMessage = '선택한 회원들의 공통 그룹에 이번 변경을 적용하여, 모든 선택 회원의 그룹을 동일하게 설정합니다.\n각 회원이 개별적으로 속한 다른 그룹은 해제됩니다.\n\n진행하시겠습니까?'
761        if (!confirm(confirmationMessage)) return false
762      }
763
764      // 이용권 경고에서 취소해도 기존 선택 배열이 변경되지 않도록 복사한다.
765      member_item_group_data = member_item_group_data.concat()
766      // 변경될 데이터 만들어 주기
767      if ($.inArray(idx, member_item_group_data) !== -1) { // 제거
768        member_item_group_data = deleteArrayValue(member_item_group_data, idx)
769      } else { // 추가
770        member_item_group_data.push(idx)
771      }
772
773      // 회원그룹이용권 구매 회원이 포함되어있는지 체크
774      let subscribe_group_exist_check = 0
775      $.each(member_idx, function (e, v) {
776        const $drop_list = $('._drop_group_list_' + v)
777        const _member_item_subscribe_data = $drop_list.data('subscribe_groups')
778        if (_member_item_subscribe_data.length > 0) { // 이용권 구매해서 포함된 회원이 존재 시
779          subscribe_group_exist_check++
780        }
781      })
782      if ((subscribe_group_exist_check != 0) &&
783				!confirm(getLocalizeString('설명_회원그룹이용권구매회원포함안내', subscribe_group_exist_check, '해당 그룹에 회원그룹 이용권 구매로 자동 지정된 그룹 사용자가 %1명 이상 포함되어 있습니다. 그룹 지정을 해제하고 나중에 다시 그룹을 지정하더라도 자동 그룹 해제일에 자동 해제가 되지 않게됩니다.\n\n그룹 지정을 해제하시겠습니까?'))) {
784        return false
785      }
786      $list.data('groups', member_item_group_data)
787
788      $.each(member_idx, function (e, v) {
789        const $drop_list = $('._drop_group_list_' + v)
790        const _member_item_group_data = $drop_list.data('groups')
791        $.each(member_item_group_data, function (_e, _v) {
792          if ($.inArray(_v, _member_item_group_data) === -1) { // 없으면 추가
793            _old_data.member_cnt = _old_data.member_cnt == '' ? 0 : _old_data.member_cnt
794            _old_data.member_cnt = 1 + parseInt(_old_data.member_cnt)
795          }
796        })
797        $.each(_member_item_group_data, function (_e, _v) {
798          if ($.inArray(_v, member_item_group_data) === -1) { // 없으면 제거
799            let __old_data = false
800            $.each(group_list_data.data, function (__e, __v) {
801              if (__v.idx == _v) {
802                __old_data = group_list_data.get(__e)
803                return false
804              }
805            })
806            if (__old_data !== false) {
807              __old_data.member_cnt = parseInt(__old_data.member_cnt) - 1
808            }
809          }
810        })
811        $drop_list.data('groups', member_item_group_data.concat())
812        $drop_list.data('subscribe_groups', [])
813      })
814    }
815    // 대상 회원 수 한명
816    else {
817      if ($.inArray(idx, member_item_group_data) !== -1) { // 제거
818        if ($.inArray(idx, member_item_subscribe_data) !== -1) { // 제거
819          if (!confirm(getLocalizeString('설명_회원그룹이용권구매로지정된그룹제거시안내', '', '이 그룹은 회원그룹 이용권 구매로 자동 지정된 그룹입니다. \n그룹지정을 해제하고 나중에 다시 그룹을 지정하더라도 자동 그룹 해제일에 자동 해제가 되지 않게 됩니다.\n\n그룹 지정을 해제하시겠습니까?'))) {
820            return false
821          }
822          member_item_subscribe_data = deleteArrayValue(member_item_subscribe_data, idx)
823          $list.data('subscribe_groups', member_item_subscribe_data)
824        }
825
826        member_item_group_data = deleteArrayValue(member_item_group_data, idx)
827        $list.data('groups', member_item_group_data)
828        _old_data.member_cnt = parseInt(_old_data.member_cnt) - 1
829      } else { // 추가
830        if (typeof member_item_group_data !== 'object') {
831          member_item_group_data = member_item_group_data.split(',')
832        }
833
834        member_item_group_data.push(idx)
835        $list.data('groups', member_item_group_data)
836        _old_data.member_cnt = _old_data.member_cnt == '' ? 0 : _old_data.member_cnt
837        _old_data.member_cnt = 1 + parseInt(_old_data.member_cnt)
838      }
839    }
840
841    $.ajax({
842      type: 'POST',
843      data: { member_idx, new_group_code: member_item_group_data },
844      url: ('/admin/ajax/member/group_concede_proc.cm'),
845      dataType: 'json',
846      async: true,
847      cache: false,
848      success: function (result) {
849        if (result.msg == 'SUCCESS') {
850          const check_member = $check_drop_group_list.data('member')
851          checkMember(check_member)
852
853          let main_group_title = ''
854          let i = 0
855          if ($.isArray(member_idx)) {
856            $.each(member_idx, function (e, v) {
857              let main_group_title = ''
858              let i = 0
859              const $drop_list = $('._drop_group_list_' + v)
860              const _member_item_group_data = $drop_list.data('groups')
861              $.each(group_list_data.data, function (code, data) {
862                data.idx = parseInt(data.idx)
863                if ($.inArray(data.idx, _member_item_group_data) !== -1) {
864                  if (i == 0) { main_group_title = data.title }
865                  i++
866                }
867              })
868              let _group = ''
869              if (i == 0) {
870                _group = getLocalizeString('설명_그룹없음', '', '그룹없음')
871              } else {
872                _group = main_group_title + '<span class="text-primary">' + (i > 1 ? '+' + (i - 1) : '') + '</span>'
873              }
874
875              calculateNoGroup($drop_list, i)
876              $('._drop_group_btn_' + v).html(_group)
877            })
878          } else {
879            $.each(group_list_data.data, function (code, data) {
880              if ($.inArray(data.idx, member_item_group_data) !== -1) {
881                if (i == 0) { main_group_title = data.title }
882                i++
883              }
884            })
885            let _group = ''
886            if (i == 0) {
887              _group = getLocalizeString('설명_그룹없음', '', '그룹없음')
888            } else {
889              _group = main_group_title + '<span class="text-primary">' + (i > 1 ? '+' + (i - 1) : '') + '</span>'
890            }
891
892            calculateNoGroup($list, i)
893
894            $('._drop_group_btn_' + member_idx).html(_group)
895          }
896
897          resetGroupListHtml()
898          MEMBER_DATA.renderGroupCount(member_item_group_data)
899        } else {
900          alert(result.msg)
901        }
902      }
903    })
904  }
905
906  var calculateNoGroup = function (obj, cnt) {
907    let _no_group_cnt = $no_group_cnt.text().trim()
908    if (_no_group_cnt === '9999+') {
909      return
910    }
911    _no_group_cnt = _no_group_cnt == '' ? 0 : _no_group_cnt
912    _no_group_cnt = parseInt(_no_group_cnt)
913    if (cnt == 0) {
914      if (obj.data('nogroup') == 'N') {
915        obj.data('nogroup', 'Y')
916        _no_group_cnt = _no_group_cnt + 1
917        $no_group_cnt.text(_no_group_cnt)
918      }
919    } else {
920      if (obj.data('nogroup') == 'Y') {
921        obj.data('nogroup', 'N')
922        _no_group_cnt = _no_group_cnt - 1
923        $no_group_cnt.text(_no_group_cnt)
924      }
925    }
926  }
927
928  var checkMember = function (idxs) {
929    const $list = $('._drop_group_list_' + idxs[0])
930
931    if ($list.length > 0) {
932      var res = $list.data('groups').concat()
933      $.each(idxs, function (e, v) {
934        res = array_intersect(res, $('._drop_group_list_' + v).data('groups').concat())
935      })
936      var result = []
937      $.each(res, function (e, v) {
938        v = v == '' ? 0 : parseInt(v)
939        if (v > 0) { result.push(v) }
940      })
941      $check_drop_group_list.data('groups', result)
942      $check_drop_group_list.data('member', idxs)
943    }
944
945    // 쇼핑등급
946    const $shopping_list = $('._drop_shopping_group_list_' + idxs[0])
947    if ($shopping_list.length > 0) {
948      var res = $shopping_list.data('groups').concat()
949      $.each(idxs, function (e, v) {
950        res = array_intersect(res, $('._drop_shopping_group_list_' + v).data('groups').concat())
951      })
952      var result = []
953      $.each(res, function (e, v) {
954        v = v == '' ? 0 : parseInt(v)
955        result.push(v)
956      })
957      $check_drop_shopping_group_list.data('member', idxs)
958      $check_drop_shopping_group_list.data('groups', result)
959    }
960
961    resetGroupListHtml()
962  }
963
964  const getGroupList = function () {
965    return group_list_data
966  }
967
968  const changeJoinTypeDefaultGroup = function (type_code, group_code) {
969    $.ajax({
970      type: 'POST',
971      data: { type_code, group_code },
972      url: ('/admin/ajax/member/update_join_type_default_group.cm'),
973      dataType: 'json',
974      async: true,
975      cache: false,
976      success: function (result) {
977        if (result.msg !== 'SUCCESS') {
978          alert(result.msg)
979        }
980      }
981    })
982  }
983
984  const setGroupCheck = function (idx) {
985    const check_member = $('._drop_group_list_' + idx).data('groups')
986    checkMember(check_member)
987  }
988
989  const setShoppingGroupCheck = function (idx) {
990    const check_member = $('._drop_shopping_group_list_' + idx).data('groups')
991    checkMember(check_member)
992  }
993
994  return {
995    init: function (code, base_url, permission, type) {
996      init(code, base_url, permission, type)
997    },
998    getModalType: function () {
999      return modal_type
1000    },
1001    addGroupList: function (data) {
1002      addGroupList(data)
1003    },
1004    updateGroupList: function (code, data) {
1005      updateGroupList(code, data)
1006    },
1007    modifyGroupList: function (type, data) {
1008      modifyGroupList(type, data)
1009    },
1010    openGroupForm: function (idx, type) {
1011      openGroupForm(idx, type)
1012    },
1013    concedeChangeSpecialGroup: function (type, obj, idx) {
1014      concedeChangeSpecialGroup(type, obj, idx)
1015    },
1016    concedeToggleGroup: function (obj, idx) {
1017      concedeToggleGroup(obj, idx)
1018    },
1019    checkMember: function (idxs) {
1020      checkMember(idxs)
1021    },
1022    getGroupList: function () {
1023      return getGroupList()
1024    },
1025    changeJoinTypeDefaultGroup: function (type_code, group_code) {
1026      return changeJoinTypeDefaultGroup(type_code, group_code)
1027    },
1028    addDropGroupList: function (data) {
1029      addDropGroupList(data)
1030    },
1031    setGroupMemberCount: function (data) {
1032      setGroupMemberCount(data)
1033    },
1034    setGroupCheck: function (idx) {
1035      setGroupCheck(idx)
1036    },
1037    setShoppingGroupCheck: function (idx) {
1038      setShoppingGroupCheck(idx)
1039    },
1040    setCheck: function () {
1041      setCheck()
1042    },
1043    resetGroupListHtml: function () {
1044      resetGroupListHtml()
1045    },
1046    setPrevDropGroupType: function (type) {
1047      prev_drop_group_type = type
1048    },
1049    setCurrentMemberCode: function (code) {
1050      setCurrentMemberCode(code)
1051    }
1052  }
1053}())
1054
1055const MEMBER_GROUP_SETTING_MODAL = (function () {
1056  let $form, $group_benefit, $member_group_benefit
1057  let group_list = {}
1058  let current_group_type = 'member'
1059  let current_unit_code = ''
1060
1061  const init = function (unit_code, code, type) {
1062    $form = $('#groupf')
1063    $group_benefit = $('#group_benefit_setting')
1064    $member_group_benefit = $('#member_group_benefit_setting')
1065    setGroupListData(code)
1066
1067    current_unit_code = unit_code
1068    addEvent()
1069
1070    if (typeof type !== 'undefined' && type != '') {
1071      changeGroupType(type)
1072    }
1073  }
1074  var addEvent = function () {
1075    $form.find('#group_type').on('change', function () {
1076      changeGroupType($(this).val())
1077    })
1078
1079    // 자동등급 조건 설정
1080    $form.find('#shopping_auto_grouping_not_used').on('change', function () {
1081      changeUseAutoGrouping($(this).prop('checked'))
1082    })
1083
1084    $form.find('.auto_grouping_flag').on('blur', function () {
1085      changeFlagAmount($(this))
1086    })
1087
1088    // 구매혜택 설정부분
1089    $form.find('.benefit_type_radio').find(':radio').on('change', function () {
1090      changeBenefitType()
1091    })
1092    $group_benefit.find('.group_benefit select').on('change', function () {
1093      const is_percent = ($(this).val() == 'percent')
1094      $(this).parents('.input-group').find('.benefit_maximum').toggle(is_percent)
1095      $(this).parents('.input-group').find('.benefit_maximum').find('input').prop('disabled', !is_percent)
1096
1097      // 변경시 혜택 값 초기화
1098      const $amount = $(this).parents('.input-group').find('._benefit_money_format:nth-child(1)')
1099      $amount.val(0)
1100      makeMoneyFormat($amount, !is_percent)
1101    })
1102
1103    $group_benefit.find('input[type="text"]').on('blur', function () {
1104      const v = parseFloat($(this).val())
1105      if (v === 0) $(this).val(v)
1106    })
1107
1108    $form.find('.auto_grouping_flag').each(function () {
1109      makeMoneyFormat($(this), true)
1110    })
1111    $form.find('._benefit_money_format').each(function () {
1112      let use_format = true
1113      if ($(this).hasClass('_benefit_amount')) {
1114        use_format = ($(this).parents('.input-group').find('select').val() == 'price')
1115      }
1116      makeMoneyFormat($(this), use_format)
1117    })
1118  }
1119
1120  var makeMoneyFormat = function ($obj, use_format) {
1121    $obj.unbind('keydown.format keyup.format')
1122    if (use_format) {
1123      set_money_format($obj, $obj.data('decimal-count'), $obj.data('decimal-char'), $obj.data('thousand-char'))
1124    }
1125  }
1126
1127  // 현재 그룹 데이터 기준 조건 리스트 생성
1128  var setGroupListData = function (cd) {
1129    const group_list_data = MEMBER_GROUP.getGroupList()
1130    group_list = { shopping: [], activity: [] }
1131
1132    const auto_set_group_list = ['shopping', 'activity']
1133    $.each(group_list_data.data, function (code, data) {
1134      if (cd === code) {
1135        current_group_type = data.group_type
1136        return true
1137      }
1138      if (auto_set_group_list.indexOf(data.group_type) < 0) return true
1139      let auto_grouping_data = data.auto_grouping_data
1140
1141      switch (data.group_type) {
1142        case 'shopping':
1143          if (data.use_auto_change != 'Y') break
1144          for (const key in auto_grouping_data) {
1145            if (typeof group_list[data.group_type][key] === 'undefined') {
1146              group_list[data.group_type][key] = []
1147            }
1148            auto_grouping_data[key] = parseFloat(auto_grouping_data[key])
1149            group_list[data.group_type][key].push(auto_grouping_data[key])
1150          }
1151          break
1152        case 'activity':
1153          auto_grouping_data = parseFloat(auto_grouping_data)
1154          group_list[data.group_type].push(auto_grouping_data)
1155          break
1156      }
1157    })
1158  }
1159
1160  // 그룹 유형 변경 시
1161  var changeGroupType = function (type) {
1162    current_group_type = type
1163    $form.find('.group_setting').hide()
1164    $form.find('.group_setting.' + type + '_group_setting').show()
1165    switch (type) {
1166      case 'member':
1167      case 'admin':
1168        // changeBenefitType();
1169        break
1170      case 'shopping':
1171        changeBenefitType()
1172        changeUseAutoGrouping($form.find('#shopping_auto_grouping_not_used').prop('checked'))
1173        break
1174      case 'activity':
1175        $group_benefit.hide()
1176        break
1177    }
1178  }
1179
1180  // 쇼핑설정 - 자동 등급 사용 안함
1181  var changeUseAutoGrouping = function (b) {
1182    $('#auto_grouping_setting').toggle(!b)
1183  }
1184
1185  // 자동등급 조건 금액 변경시 on blur 처리
1186  var changeFlagAmount = function ($obj) {
1187    if ($obj.length <= 0) return false
1188
1189    let v = parseFloat($obj.val())
1190    v = (isNaN(v)) ? 0 : v
1191    $obj.val(v)
1192    const code = $obj.data('code')
1193
1194    $form.find('.help_group_flag_setting_' + code).hide()
1195
1196    if (v < 0) {
1197      $form.find('.help_group_flag_setting_' + $obj.data('code')).html(getLocalizeString('설명_자동등급조건은0보다큰값을입력해야합니다', '', '자동 등급 조건은 0보다 큰 값을 입력해야 합니다.')).show()
1198      return 'MINUS'
1199    } else {
1200      // 이미 등록된 조건인지 체크
1201      if (typeof group_list[current_group_type] !== 'undefined') {
1202        let auto_grouping_list = group_list[current_group_type]
1203        if (typeof code !== 'undefined' && typeof auto_grouping_list[code] !== 'undefined') {
1204          auto_grouping_list = auto_grouping_list[code]
1205        }
1206
1207        if (auto_grouping_list.indexOf(v) > -1) {
1208          $form.find('.help_group_flag_setting_' + code).html(getLocalizeString('설명_다른그룹에서설정한조건과동일한조건은입력할수없습니다', '', '다른 그룹에서 설정한 조건과 동일한 조건은 입력할 수 없습니다.')).show()
1209          return 'EXIST'
1210        }
1211      }
1212    }
1213
1214    return true
1215  }
1216
1217  // ( 글로벌 사이트 ) 일반회원그룹 구매혜택 탭 변경시
1218  const changeMemberGroupBenefitTarget = function (obj) {
1219    const $obj = $(obj)
1220    const unit_code = $obj.data('code')
1221    current_unit_code = unit_code
1222    $('#member_group_benefit_setting_tab').find('li.active').removeClass('active')
1223    $obj.parent('li').addClass('active')
1224    $member_group_benefit.find('._member-group-benefit-tab-content').addClass('hidden')
1225    $member_group_benefit.find('._member-group-benefit-tab-content#member_group_benefit_' + unit_code).removeClass('hidden')
1226  }
1227
1228  // ( 글로벌 사이트 ) 구매혜택 탭 변경시
1229  const changeBenefitTarget = function (obj) {
1230    const $obj = $(obj)
1231    const unit_code = $obj.data('code')
1232    current_unit_code = unit_code
1233
1234    $('#benefit_setting_tab').find('li.active').removeClass('active')
1235    $obj.parent('li').addClass('active')
1236
1237    $group_benefit.find('.tab-content').addClass('hidden')
1238    $group_benefit.find('.tab-content#group_benefit_' + unit_code).removeClass('hidden')
1239  }
1240
1241  // 그룹 혜택 변경 시
1242  var changeBenefitType = function () {
1243    $group_benefit.show()
1244    const $group_benefit_obj = $group_benefit.find('#group_benefit_' + current_unit_code)
1245    $group_benefit_obj.find('.group_benefit').hide()
1246
1247    const benefit_type = $group_benefit_obj.find(':radio:checked').val()
1248    switch (benefit_type) {
1249      case 'dc':
1250      case 'point':
1251        $group_benefit_obj.find('.group_benefit.benefit_type_' + benefit_type).show()
1252        break
1253      case 'all':
1254        $group_benefit_obj.find('.group_benefit').show()
1255        break
1256    }
1257  }
1258
1259  // 구몌혜택 체크
1260  const checkBenefit = function () {
1261    if (current_group_type != 'shopping') return true	// 쇼핑그룹만 사용함
1262    if ($group_benefit.length <= 0) return true // 쇼핑 사용 안함 등의 이슈로 혜택이 없을 경우
1263
1264    let $target
1265    const benefit_type = $group_benefit.find(':radio[name=benefit_type]:checked').val()
1266    switch (benefit_type) {
1267      case 'dc':
1268      case 'point':
1269        $target = $group_benefit.find('.group_benefit.benefit_type_' + benefit_type)
1270        break
1271      case 'all':
1272        $target = $group_benefit.find('.group_benefit')
1273        break
1274    }
1275
1276    if (typeof $target === 'undefined') return true
1277
1278    let benefit_error = false
1279    $target.find('input').each(function () {
1280      if ($(this).prop('disabled')) return true
1281      const amt = parseFloat($(this).val())
1282      if (isNaN(amt) || amt <= 0) {
1283        benefit_error = true
1284      }
1285    })
1286    return !benefit_error
1287  }
1288
1289  // 유효성 검사
1290  const checkValidateForm = function () {
1291    if (current_group_type == 'activity' || current_group_type == 'shopping') {
1292      let check = true
1293      const $obj = $form.find('.' + current_group_type + '_group_setting').find('.auto_grouping_flag')
1294      if ($obj.length > 0) {
1295        $obj.each(function () {
1296          if (check !== true) return true
1297          check = changeFlagAmount($(this))
1298        })
1299      }
1300      if (check !== true) {
1301        switch (check) {
1302          case 'MINUS':
1303            alert(getLocalizeString('설명_자동등급조건은0보다큰값을입력해야합니다', '', '자동 등급 조건은 0보다 큰 값을 입력해야 합니다.'))
1304            break
1305          case 'EXIST':
1306            alert(getLocalizeString('설명_자동등급조건이다른등급과동일합니다자동등급조건을수정해주세요', '', '자동 등급 조건이 다른 등급과 동일합니다. 자동 등급 조건을 수정해 주세요.'))
1307            break
1308        }
1309        return false
1310      }
1311    }
1312    if (!checkBenefit()) {
1313      alert(getLocalizeString('설명_할인금액및최대할인금액은0보다큰값을입력해야합니다', '', '할인 금액 및 최대 할인 금액은 0보다 큰 값을 입력해야 합니다.'))
1314      return false
1315    }
1316    return true
1317  }
1318
1319  // 그룹설정저장
1320  const saveGroupForm = function () {
1321    if (!checkValidateForm()) {
1322      return false
1323    }
1324    const data = $form.serializeObject()
1325    const admin_group_setting = $('.admin_group_setting').find('input').serialize()
1326    data.modal_type = MEMBER_GROUP.getModalType()
1327    if (admin_group_setting == '' && data.group_type == 'admin') {
1328      if (confirm(getLocalizeString('설명_1개이상의권한이부여되지않은그룹은일반그룹으로전환됩니다', '', '1개 이상의 권한이 부여되지 않은 그룹은 일반그룹으로 전환되며\n해당 운영 그룹에만 속한 사용자들은 일반회원으로 전환됩니다.\n그룹 권한을 저장하시겠습니까?'))) {
1329        $.ajax({
1330          type: 'POST',
1331          data,
1332          url: ('/admin/ajax/config/membership/group_form_proc.cm'),
1333          dataType: 'json',
1334          async: false,
1335          cache: false,
1336          success: function (result) {
1337            if (result.msg == 'SUCCESS') {
1338              location.reload()
1339            } else {
1340              if (result.msg == 'VERSION ERROR') {
1341                if (confirm(LOCALIZE_ADMIN.설명_업그레이드페이지로이동할까요())) {
1342                  window.location.href = '/admin/payment?mode=detail&type=upgrade'
1343                }
1344              } else {
1345                alert(result.msg)
1346              }
1347            }
1348          }
1349        })
1350      } else {
1351        return false
1352      }
1353    } else {
1354      $.ajax({
1355        type: 'POST',
1356        data,
1357        url: ('/admin/ajax/config/membership/group_form_proc.cm'),
1358        dataType: 'json',
1359        async: false,
1360        cache: false,
1361        success: function (result) {
1362          if (result.msg == 'SUCCESS') {
1363            location.reload()
1364          } else {
1365            if (result.msg == 'VERSION ERROR') {
1366              if (confirm(LOCALIZE_ADMIN.설명_업그레이드페이지로이동할까요())) {
1367                window.location.href = '/admin/payment?mode=detail&type=upgrade'
1368              }
1369            } else {
1370              alert(result.msg)
1371            }
1372          }
1373        }
1374      })
1375    }
1376  }
1377
1378  // 종료시 콜백
1379  const saveCallBack = function (result) {
1380    const modal_type = MEMBER_GROUP.getModalType()
1381    result.data.idx = parseInt(result.data.idx)
1382    if (modal_type == 'config') {
1383      // 쇼핑이면 쇼핑으로 붙게끔 해야함
1384      MEMBER_GROUP.modifyGroupList(result.mode, result.data)
1385      if (typeof CONFIG_MEMBERSHIP !== 'undefined') {
1386        CONFIG_MEMBERSHIP.addGroupHtml(result.data)
1387      }
1388    } else {
1389      if (result.mode == 'add') {
1390        MEMBER_GROUP.addGroupList(result.data)
1391        window.doznutadmin.AppForm.initialize()
1392      } else if (result.mode == 'update') {
1393        MEMBER_GROUP.updateGroupList(result.data.code, result.data)
1394      }
1395    }
1396  }
1397
1398  return {
1399    init: function (unit_code, code, type) {
1400      init(unit_code, code, type)
1401    },
1402    changeBenefitTarget: function (obj) {
1403      changeBenefitTarget(obj)
1404    },
1405    changeMemberGroupBenefitTarget: function (obj) {
1406	  changeMemberGroupBenefitTarget(obj)
1407    },
1408    saveGroupForm: function () {
1409      saveGroupForm()
1410    }
1411  }
1412}())
1413
1414  // 외부 발송·충전 차단(서버 판정). **판정 로직을 JS 에 복제하지 않는다** — 서버가 내려준
1415  // 불리언(window.siteOutboundBlocked)만 본다. 사유는 알리지 않는다(우회 조건 역산 방지).
1416  const isSiteOutboundBlocked = function () {
1417    return window.siteOutboundBlocked === true
1418  }
1419
1420  // **비용 축만** 본 차단(서버가 window.siteOutboundBlockedByCost 로 내려준다). 엑셀 명단 등록·
1421  // 충전 진입이 이걸 본다 — 그 화면들은 발송을 만들지 않으므로 2차 인증 축을 걸면 과차단이다
1422  // (사용자 확정, 2026-10-07: #22817 뒤 2차 인증 미설정 사이트에서 충전·명단 등록까지 막혔다).
1423  //
1424  // **구버전 페이지(롤링 배포 창)에서는 기존 불리언으로 접는다.** 새 불리언을 아직 안 내려주는
1425  // 서버를 만나면 `undefined` 이므로, 그때 종전 값(2차 인증 포함)을 그대로 따른다 — 그쪽이
1426  // **더 엄격한** 판정이라 새는 방향이 아니다(비용 축은 두 판정 모두에 들어 있다).
1427  const isSiteOutboundBlockedByCost = function () {
1428    if (typeof window.siteOutboundBlockedByCost === 'boolean') {
1429      return window.siteOutboundBlockedByCost === true
1430    }
1431
1432    return isSiteOutboundBlocked()
1433  }
1434
1435  // 2차 인증 사유로 막힌 경우, **왜 막혔는지와 켜는 방법**을 띄운다(서버가 문구를 내려준다).
1436  // 기존 사유(만료·신규·무료체험)는 문구가 빈 문자열이라 **종전처럼 조용히 끝난다** — 사유를
1437  // 알려 주면 우회 조건을 역산한다(2026-10-01 결정). 문구는 서버가 만들고 JS 는 그리기만 한다
1438  // (판정 로직을 JS 에 복제하지 않는다).
1439  const showSiteOutboundGuide = function () {
1440    if (typeof window.siteOutboundBlockedGuide === 'string' && window.siteOutboundBlockedGuide !== '') {
1441      alert(window.siteOutboundBlockedGuide)
1442      if (typeof window.siteOutboundBlockedGuideUrl === 'string' && window.siteOutboundBlockedGuideUrl !== '') {
1443        // 설정 화면으로 바로 갈 수 있게 새 창으로 연다 — 목록 화면 상태를 잃지 않는다.
1444        window.open(window.siteOutboundBlockedGuideUrl, '_blank')
1445      }
1446    }
1447    return false
1448  }
1449
1450const MEMBER_EXCEL = (function () {
1451  let $member_multi_add_status, $msg_section
1452  let $group_multi_add_status
1453
1454	const resultExcelDownload = (status, result_idx) => {
1455	  if(status === '완료'){
1456		  return location.href = `/admin/ajax/member/download_excel_member_in_group_result.cm?result_idx=${result_idx}`
1457	  } else if (status === '진행중') {
1458		  alert('결과 엑셀 생성중 입니다.');
1459	  } else {
1460		  alert('결과 엑셀 생성이 실패 하였습니다');
1461	  }
1462	}
1463	const loadGroupExcelResult = function () {
1464      const modalMultiMemberAdd = $('.modal_multi_member_add')
1465      const excelResultTable = modalMultiMemberAdd.find('#_excel_list_body');
1466      const excelEmptyWrap = modalMultiMemberAdd.find('._excel_empty_wrap');
1467		$.ajax({
1468			type: 'GET',
1469			url: ('/admin/ajax/member/get_excel_member_in_group_list.cm'),
1470			dataType: 'json',
1471			success: function (res){
1472				if(res.length !== 0){
1473					excelEmptyWrap.hide();
1474                    excelResultTable.empty();
1475					for(let i = 0; i < res.list.length; i++){
1476						const excelResult = res.list[i];
1477						const row = $('<tr>');
1478						const excelDownloadResultText = excelResult.status === '완료' ? '다운로드' : '생성중';
1479
1480						row.append(`<td style="overflow: hidden; max-width: 114px; white-space: nowrap; text-overflow: ellipsis; word-break: break-all">${excelResult.group_name}</td>`);
1481						row.append(`<td>${excelResult.request_datetime}</td>`);
1482						row.append(`<td class="text-center">${excelResult.status}</td>`);
1483						row.append(`
1484					        <th class="download_btn text-right make_complete text-primary"
1485					         style="padding-right: 20px;" 
1486					         onclick="MEMBER_EXCEL.resultExcelDownload('${excelResult.status}',${excelResult.result_idx})">
1487					          ${excelResult.status === '실패' ? '실패' : excelDownloadResultText}</th>
1488					        <th class="text-right" style="padding-right: 20px;">
1489					          <span class="text-danger" style="cursor: pointer;"
1490					            onclick="MEMBER_EXCEL.deleteGroupExcel(${excelResult.result_idx})">삭제</span>
1491					        </th>
1492						`);
1493						excelResultTable.append(row); // 새로운 <tr> 요소 추가
1494					}
1495				}else{
1496					excelEmptyWrap.show();
1497				}
1498			},
1499			error: function (xhr, status, error) {
1500				console.log("AJAX 요청 실패:", error);
1501			}
1502		})
1503	}
1504
1505  const groupExcelUploadInit = function (groupCode) {
1506    $group_multi_add_status = $('#group_multi_add_status')
1507    const $group_code = groupCode
1508    $group_multi_add_status.fileupload({
1509      url: '/admin/ajax/excel_upload.cm',
1510      formData: { target_code: 'bulk_member_group_excel' },
1511      dataType: 'json',
1512      singleFileUploads: true,
1513      limitMultiFileUploads: 1,
1514      start: function (e, data) {
1515        $group_multi_add_status.addClass('file-loading')
1516      },
1517      done: function (e, data) {
1518        const res_data = data.result
1519        $.ajax({
1520          type: 'POST',
1521          data: {
1522            file_name: res_data.file_name,
1523            group_code: $group_code
1524          },
1525          url: ('/admin/ajax/member/upload_excel_member_group.cm'),
1526          dataType: 'json',
1527          success: function (res) {
1528            if (res.msg === 'SUCCESS') {
1529              var interval = setInterval(function () {
1530                alert('사용자 그룹 일괄 등록 요청이 완료되었습니다.');
1531                $group_multi_add_status.removeClass('file-loading');
1532				loadGroupExcelResult()
1533				// drawExcelUploadResult()
1534                clearInterval(interval)
1535              }, 2000)
1536            } else {
1537              alert(res.msg)
1538              $group_multi_add_status.removeClass('file-loading')
1539            }
1540          }
1541        })
1542      }
1543    })
1544  }
1545
1546
1547  const memberExcelUploadInit = function () {
1548    $member_multi_add_status = $('#member_multi_add_status')
1549    $member_multi_add_status.fileupload({
1550      url: '/admin/ajax/excel_upload.cm',
1551      formData: { target_code: 'multi_member_excel' },
1552      dataType: 'json',
1553      singleFileUploads: true,
1554      limitMultiFileUploads: 1,
1555      start: function () {
1556        $member_multi_add_status.addClass('file-loading');
1557      },
1558      done: function (e, data) {
1559        const res_data = data.result
1560        $.ajax({
1561          type: 'POST',
1562          data: {
1563            file_name: res_data.file_name
1564          },
1565          url: ('/admin/ajax/member/upload_excel_member.cm'),
1566          dataType: 'json',
1567          success: function (res) {
1568            if (res.msg === 'SUCCESS') {
1569              var interval = setInterval(function () {
1570                $member_multi_add_status.removeClass('file-loading')
1571	            drawExcelUploadResult()
1572                clearInterval(interval)
1573              }, 2000)
1574            } else {
1575              alert(res.msg)
1576              $member_multi_add_status.removeClass('file-loading')
1577            }
1578          }
1579        })
1580      },
1581      fail: function () {
1582
1583      }
1584    })
1585  }
1586
1587  const drawExcelUploadResult = function () {
1588    $msg_section = $('#msg_section')
1589    $.ajax({
1590      type: 'POST',
1591      url: ('/admin/ajax/member/get_excel_member_upload.cm'),
1592      dataType: 'json',
1593      async: false,
1594      cache: false,
1595      success: function (res) {
1596        if (res.msg === 'SUCCESS') {
1597          $msg_section.html(res.html).show()
1598        }
1599      },
1600      error: function (request, status, error) {
1601        // alert("code:"+request.status+"\n"+"message:"+request.responseText+"\n"+"error:"+error);
1602      }
1603
1604    })
1605  }
1606
1607  const showBulkGroupAdd = function (groupCode) {
1608    // **비용 축만** — 그룹 일괄 등록은 명단 **준비**이고 발송은 뒤의 sendSms/sendPush 가 막는다.
1609    if (isSiteOutboundBlockedByCost()) {
1610      return showSiteOutboundGuide()
1611    }
1612
1613    // 사용자 그룹 일괄 등록 모달
1614    $.cocoaDialog.close()
1615    $.ajax({
1616      type: 'POST',
1617      data: {
1618        'group_code' : groupCode
1619      },
1620      url: ('/admin/ajax/open_group_batch.cm'),
1621      dataType: 'html',
1622      async: true,
1623      cache: false,
1624      success: function (html) {
1625        const $html = $(html)
1626        $.cocoaDialog.open({ type: 'multi_member_add', custom_popup: $html })
1627      }
1628    })
1629  }
1630
1631  const showMultiMemberAdd = function () {
1632    // **비용 축만** — 회원 명단 일괄 등록(엑셀 업로드)은 발송이 아니다(upload_excel_member.cm 과 같은 판단).
1633    if (isSiteOutboundBlockedByCost()) {
1634      return showSiteOutboundGuide()
1635    }
1636
1637    $.cocoaDialog.close()
1638    $.ajax({
1639      type: 'POST',
1640      data: {},
1641      url: ('/admin/ajax/open_member_batch.cm'),
1642      dataType: 'html',
1643      async: true,
1644      cache: false,
1645      success: function (html) {
1646        const $html = $(html)
1647        $.cocoaDialog.open({ type: 'multi_member_add', custom_popup: $html })
1648      }
1649    })
1650  }
1651
1652  const openModalMemberExcelDownload = function (member_group) {
1653    $.ajax({
1654      type: 'POST',
1655      url: ('/admin/ajax/member/open_download_excel_member_list.cm'),
1656      data: { member_group },
1657      dataType: 'json',
1658      async: false,
1659      cache: false,
1660      success: function (res) {
1661        if (res.msg === 'SUCCESS') {
1662          $.cocoaDialog.open({ type: 'admin_member_excel_download', custom_popup: res.html, width: 550 })
1663        } else {
1664          alert(res.msg)
1665        }
1666      }
1667    })
1668  }
1669
1670  const loadExcelList = function () {
1671    $.ajax({
1672      type: 'POST',
1673      url: ('/admin/ajax/member/get_excel_member_list.cm'),
1674      dataType: 'json',
1675      async: false,
1676      cache: false,
1677      success: function (res) {
1678        if (res.msg == 'SUCCESS') {
1679          $('.modal_admin_member_excel_download').find('._excel_empty_wrap').hide()
1680          $('.modal_admin_member_excel_download').find('#_excel_list_body').html(res.html)
1681        } else {
1682          $('.modal_admin_member_excel_download').find('#_excel_list_body').empty()
1683          $('.modal_admin_member_excel_download').find('._excel_empty_wrap').show()
1684        }
1685      }
1686    })
1687  }
1688
1689  const runMemberExcelMake = function (member_group) {
1690    const $dashboard_loader_sub = $('#dashboard_loader_sub')
1691    const member_privacy = $('#member_privacy').is(':checked')
1692    const join_type = $('#join_type').val()
1693
1694    $.ajax({
1695      type: 'POST',
1696      url: '/admin/ajax/member/request_excel_member_list.cm',
1697      dataType: 'json',
1698      data: { member_group, member_privacy, join_type },
1699      cache: false,
1700      beforeSend: function () {
1701        $dashboard_loader_sub.show()
1702      },
1703      success: function (res) {
1704        if (res.msg === 'SUCCESS') {
1705          var interval = setInterval(function () {
1706            $dashboard_loader_sub.hide()
1707            loadExcelList()
1708
1709            let all_download = true
1710            $('#_excel_list_body .download_btn').each(function (key, el) {
1711	            if ($(el).hasClass('make_progress')) {
1712                all_download = false
1713              }
1714            })
1715
1716	          if (all_download) {
1717              clearInterval(interval)
1718            }
1719          }, 2000)
1720        } else {
1721          alert(res.msg)
1722          $dashboard_loader_sub.hide()
1723        }
1724      },
1725      error: function (res) {
1726        $dashboard_loader_sub.hide()
1727      }
1728    })
1729  }
1730  const deleteGroupExcel = function (idx) {
1731	  if (confirm(getLocalizeString('설명_삭제하시겠습니까', '', '삭제하시겠습니까?'))) {
1732		  $.ajax({
1733			  type: 'POST',
1734			  data: { result_idx : idx },
1735			  url: ('/admin/ajax/member/delete_excel_group.cm'),
1736			  dataType: 'json',
1737			  async: false,
1738			  cache: false,
1739			  success: function (res) {
1740				  if (res.msg === 'SUCCESS') {
1741					  loadGroupExcelResult();
1742				  } else {
1743					  alert(res.msg)
1744				  }
1745			  }
1746		  })
1747	  }
1748  }
1749
1750  const deleteGroupUploadExcel = function (idx) {
1751    if (confirm(getLocalizeString('설명_삭제하시겠습니까', '', '삭제하시겠습니까?'))) {
1752      $.ajax({
1753        type: 'POST',
1754        data: { idx },
1755        url: ('/admin/ajax/member/delete_excel_member.cm'),
1756        dataType: 'json',
1757        async: false,
1758        cache: false,
1759        success: function (res) {
1760          if (res.msg == 'SUCCESS') {
1761            loadExcelList()
1762          } else {
1763            alert(res.msg)
1764          }
1765        }
1766      })
1767    }
1768  }
1769  const deleteMemberExcel = function (idx) {
1770    if (confirm(getLocalizeString('설명_삭제하시겠습니까', '', '삭제하시겠습니까?'))) {
1771      $.ajax({
1772        type: 'POST',
1773        data: { idx },
1774        url: ('/admin/ajax/member/delete_excel_member.cm'),
1775        dataType: 'json',
1776        async: false,
1777        cache: false,
1778        success: function (res) {
1779          if (res.msg == 'SUCCESS') {
1780            loadExcelList()
1781          } else {
1782            alert(res.msg)
1783          }
1784        }
1785      })
1786    }
1787  }
1788
1789  const deleteMemberUploadExcel = function (idx) {
1790    if (confirm(getLocalizeString('설명_삭제하시겠습니까', '', '삭제하시겠습니까?'))) {
1791      $.ajax({
1792        type: 'POST',
1793        data: { idx },
1794        url: ('/admin/ajax/member/delete_excel_upload_member.cm'),
1795        dataType: 'json',
1796        async: false,
1797        cache: false,
1798        success: function (res) {
1799          if (res.msg == 'SUCCESS') {
1800            drawExcelUploadResult()
1801          } else {
1802            alert(res.msg)
1803          }
1804        }
1805      })
1806    }
1807  }
1808
1809  return {
1810    init: function () {
1811      init()
1812    },
1813    memberExcelUploadInit: function () {
1814      memberExcelUploadInit()
1815    },
1816	  resultExcelDownload: function (status, result_idx) {
1817		resultExcelDownload(status, result_idx)
1818	  },
1819    groupExcelUploadInit: function (groupCode) {
1820	  groupExcelUploadInit(groupCode)
1821    },
1822    deleteGroupExcel: function (idx) {
1823        deleteGroupExcel(idx)
1824    },
1825	  loadGroupExcelResult: function () {
1826		loadGroupExcelResult()
1827	  },
1828    drawExcelUploadResult: function () {
1829      drawExcelUploadResult()
1830    },
1831    showBulkGroupAdd: function (groupCode) {
1832      showBulkGroupAdd(groupCode)
1833    },
1834    showMultiMemberAdd: function () {
1835      showMultiMemberAdd()
1836    },
1837    openModalMemberExcelDownload: function (member_group) {
1838      openModalMemberExcelDownload(member_group)
1839    },
1840    loadExcelList: function () {
1841      loadExcelList()
1842    },
1843    runMemberExcelMake: function (member_group) {
1844      runMemberExcelMake(member_group)
1845    },
1846    deleteMemberExcel: function (idx) {
1847      deleteMemberExcel(idx)
1848    },
1849    deleteMemberUploadExcel: function (idx) {
1850      deleteMemberUploadExcel(idx)
1851    }
1852  }
1853}())
1854
1855/**
1856 * 수동 메시지 발송 전 인증
1857 * send_sms.cm 응답의 data.type으로 진입하고, 인증에 성공하면 같은 발송을 다시 요청한다.
1858 */
1859var ADMIN_MESSAGE_STEP_UP = (function () {
1860  const REQUEST_URL = '/admin/ajax/two_factor_auth/request.cm'
1861  const VERIFY_URL = '/admin/ajax/two_factor_auth/verify.cm'
1862  const PURPOSE = 'message_send'
1863  const TYPE_TWO_FACTOR_SETTING_REQUIRED = 'two_factor_setting_required'
1864  const TYPE_TWO_FACTOR_AUTH_REQUIRED = 'two_factor_auth_required'
1865  const TYPE_CHANNEL_SETTING_REQUIRED = 'two_factor_channel_setting_required'
1866  const REDIRECT_LIST = ['/admin/config/security', '/admin/member/mail', '/admin/member/sms', '/admin/']
1867  const DEFAULT_SETTING_TITLE = '2차 인증 설정이 필요합니다'
1868  // 용도별 문구. 없으면 메시지 발송 기준 문구를 쓴다. {receiver}는 서버가 내려준 마스킹 수신처다.
1869  const PURPOSE_TEXT = {
1870    security_otp_setting: {
1871      title: '사이트 소유주 휴대폰 인증',
1872      desc_pending: 'OTP 설정 및 변경은 사이트 소유주 휴대폰으로 인증번호 인증이 필요합니다. 인증번호를 보내는 중입니다.',
1873      desc: 'OTP 설정 및 변경은 사이트 소유주 휴대폰{receiver}으로 인증번호 인증이 필요합니다. 받은 인증번호를 입력해 주세요.',
1874      confirm: '인증 후 저장'
1875    },
1876    sms_sender_setting: {
1877      title: '사이트 소유주 휴대폰 인증',
1878      desc_pending: '발신번호를 등록하거나 변경하려면 사이트 소유주 휴대폰 인증이 필요합니다. 인증번호를 보내는 중입니다.',
1879      desc: '사이트 소유주 휴대폰{receiver}으로 받은 인증번호를 입력해 주세요. 인증 후 발신번호 등록을 요청합니다.',
1880      confirm: '인증 후 등록'
1881    }
1882  }
1883  let modal_seq = 0
1884
1885  const escapeHtml = function (text) {
1886    return $('<div>').text(text || '').html()
1887  }
1888
1889  const getChannelLabel = function (channel) {
1890    if (channel === 'sms') return 'SMS'
1891    if (channel === 'email') return '이메일'
1892    if (channel === 'otp') return '인증 앱'
1893    return ''
1894  }
1895
1896  const isSettingRequired = function (data) {
1897    return !!data && typeof data === 'object' &&
1898      (data.type === TYPE_TWO_FACTOR_SETTING_REQUIRED || data.type === TYPE_CHANNEL_SETTING_REQUIRED)
1899  }
1900
1901  // 두 모달이 함께 쓰는 Clay 토큰 스타일(버튼은 member/send .ms-btn과 같은 토큰). scope는 모달의 header·body·footer에 붙는다.
1902  const buildBaseStyle = function (s) {
1903    return s + ' .modal-title{color:var(--clay-text);font-size:var(--clay-text-2xlarge);font-weight:var(--clay-font-bold);line-height:var(--clay-leading-8)}' +
1904      s + '.modal-body{color:var(--clay-text-sub);font-size:var(--clay-text-small);line-height:var(--clay-leading-5)}' +
1905      s + ' .btn.step-up-btn{border:var(--clay-border-1) solid transparent;border-radius:var(--clay-rounded-base);box-shadow:none;padding:var(--clay-space-2) var(--clay-space-3);font-family:var(--clay-typeface-base);font-size:var(--clay-text-small);font-weight:var(--clay-font-semibold);line-height:var(--clay-leading-6)}' +
1906      s + ' .btn.step-up-btn--primary,' + s + ' .btn.step-up-btn--primary:focus{background:var(--clay-action-primary);color:var(--clay-text-inverse)}' +
1907      s + ' .btn.step-up-btn--primary:hover{background:var(--clay-action-primary-hover);color:var(--clay-text-inverse)}' +
1908      s + ' .btn.step-up-btn--primary:active{background:var(--clay-action-primary-pressed);color:var(--clay-text-inverse)}' +
1909      s + ' .btn.step-up-btn--primary:disabled{background:var(--clay-action-primary-disabled);color:var(--clay-text-disabled);opacity:1}' +
1910      s + ' .btn.step-up-btn--secondary,' + s + ' .btn.step-up-btn--secondary:focus{background:var(--clay-action-secondary);border-color:var(--clay-border);color:var(--clay-text-secondary)}' +
1911      s + ' .btn.step-up-btn--secondary:hover{background:var(--clay-action-secondary);border-color:var(--clay-border-hover);color:var(--clay-text-secondary)}' +
1912      s + ' .btn.step-up-btn--secondary:active{background:var(--clay-action-secondary);border-color:var(--clay-border-sub);color:var(--clay-text-secondary)}'
1913  }
1914
1915  // 디자인 교체 시 이 함수만 바꾼다. 제목·설명·남은 시간·오류 문구는 open()에서 text로 채운다.
1916  const buildModalHtml = function (scope) {
1917    const s = '.' + scope
1918    const clock_icon = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">' +
1919      '<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.5"/>' +
1920      '<path d="M8 4.75V8l2.25 1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>'
1921    return '<style>' +
1922      buildBaseStyle(s) +
1923      s + ' .step-up-desc{margin:0 0 var(--clay-space-5);color:var(--clay-text-sub)}' +
1924      s + ' .step-up-label{display:block;margin:0 0 var(--clay-space-2);font-weight:var(--clay-font-semibold);color:var(--clay-text)}' +
1925      s + ' .step-up-input{height:var(--clay-space-10);padding:0 var(--clay-space-3);border:var(--clay-border-1) solid var(--clay-border);border-radius:var(--clay-rounded-medium);box-shadow:none;color:var(--clay-text)}' +
1926      s + ' .step-up-timer{display:flex;align-items:center;justify-content:space-between;margin-top:var(--clay-space-2);padding:var(--clay-space-2) var(--clay-space-3);border-radius:var(--clay-rounded-medium);background:var(--clay-surface-sub)}' +
1927      s + ' .step-up-timer__remain{display:inline-flex;align-items:center;gap:var(--clay-space-15);color:var(--clay-text-sub)}' +
1928      s + ' .step-up-timer__remain strong{color:var(--clay-text-accent);font-weight:var(--clay-font-semibold)}' +
1929      s + ' .step-up-timer__extend{color:var(--clay-text-sub);text-decoration:underline}' +
1930      s + ' .step-up-error{margin:var(--clay-space-2) 0 0;color:var(--clay-text-critical)}' +
1931      s + ' .step-up-error:empty{display:none}' +
1932      s + ' .step-up-otp{margin:0 0 var(--clay-space-5)}' +
1933      // 자식 display 에 의존하지 않도록 래퍼를 flex 로 둔다(테마가 img·canvas 를 block 으로 둘 수 있다).
1934      s + ' .step-up-otp__qr-wrap{display:flex;justify-content:center}' +
1935      s + ' .step-up-otp__qr{margin:0 0 var(--clay-space-3)}' +
1936      s + ' .step-up-otp__line{margin:0 0 var(--clay-space-2);color:var(--clay-text-sub)}' +
1937      s + ' .step-up-otp__key{word-break:break-all;color:var(--clay-text);font-weight:var(--clay-font-semibold)}' +
1938      '</style>' +
1939      '<div class="modal-header ' + scope + '">' +
1940      '<h4 class="modal-title _step_up_title"></h4>' +
1941      '</div>' +
1942      '<div class="modal-body ' + scope + '">' +
1943      '<p class="step-up-desc _step_up_desc"></p>' +
1944      '<div class="step-up-otp _step_up_otp" style="display:none">' +
1945      '<div class="step-up-otp__qr-wrap"><div class="step-up-otp__qr _otp_qr"></div></div>' +
1946      '<p class="step-up-otp__line"><a href="javascript:;" class="_step_up_otp_link">휴대폰에서 이 링크를 눌러 등록</a></p>' +
1947      '<p class="step-up-otp__line">직접 입력용 키 : <span class="step-up-otp__key _step_up_otp_secret"></span></p>' +
1948      '</div>' +
1949      '<label class="step-up-label" for="' + scope + '_code">인증번호 입력</label>' +
1950      '<input type="text" id="' + scope + '_code" class="form-control step-up-input _step_up_code" inputmode="numeric" autocomplete="one-time-code" maxlength="5" placeholder="인증번호를 입력해 주세요">' +
1951      '<div class="step-up-timer _step_up_timer">' +
1952      '<span class="step-up-timer__remain">' + clock_icon + '남은 시간 <strong class="_step_up_remain">-</strong></span>' +
1953      '<a href="javascript:;" class="step-up-timer__extend _step_up_resend">시간 연장</a>' +
1954      '</div>' +
1955      '<p class="step-up-error _step_up_error"></p>' +
1956      '</div>' +
1957      '<div class="modal-footer ' + scope + '">' +
1958      '<button type="button" class="btn step-up-btn step-up-btn--secondary" data-dismiss="modal">취소</button>' +
1959      '<button type="button" class="btn step-up-btn step-up-btn--primary _step_up_confirm">인증 및 메시지 보내기</button>' +
1960      '</div>'
1961  }
1962
1963  const formatRemainTime = function (second) {
1964    const minute = Math.floor(second / 60)
1965    const rest = second % 60
1966    return minute + ':' + (rest < 10 ? '0' : '') + rest
1967  }
1968
1969  const openSettingRequired = function (msg, data) {
1970    modal_seq++
1971    const scope = '_two_factor_setting_' + modal_seq
1972    let content = escapeHtml(msg)
1973    if (data.sub_msg) content += '<br>' + escapeHtml(data.sub_msg)
1974    const title = typeof data.title === 'string' && data.title !== '' ? data.title : DEFAULT_SETTING_TITLE
1975    const redirect = REDIRECT_LIST.indexOf(data.redirect) > -1 ? data.redirect : '/admin/'
1976    $.cocoaDialog.open({
1977      type: 'alert_clay',
1978      title: escapeHtml(title),
1979      content: '<style>' + buildBaseStyle('.' + scope) + '</style><div class="' + scope + '">' + content + '</div>',
1980      confirm_text: '확인',
1981      close_block: true
1982    }, function () {
1983      window.location.href = redirect
1984    })
1985
1986    // alert_clay 공용 확인 버튼(btn-primary)을 이 모달에서만 Clay primary로 바꾼다. header·body·footer는 다음 open 때 새로 만들어진다.
1987    const $content = $('.' + scope).closest('.modal-content')
1988    $content.children('.modal-header, .modal-body, .modal-footer').addClass(scope)
1989    $content.find('._confirm').removeClass('btn-primary').addClass('step-up-btn step-up-btn--primary')
1990  }
1991
1992  /**
1993   * @param {function} retry 인증 성공 후 받은 토큰으로 같은 발송을 다시 요청하는 함수
1994   * @param {string} [channel] 거부 응답의 data.channel(sms|email). 모르면 인증번호 요청 응답으로 채운다.
1995   * TODO(FS-3681): OMS 임시 연동을 위해 onCancel·닫기 함수·supportsCancellation을 추가했다.
1996   * 제거 시 신규 호출자를 확인하고 공통 인증 UI와 기존 호출자는 유지한다.
1997   * @param {function} [onCancel] 취소 또는 설정 안내 전환 시 대기 중인 호출자를 해제한다.
1998   * @param {string} [target_purpose] 거부 응답의 data.purpose. 없으면 메시지 발송 용도로 인증한다.
1999   * @returns {function} 현재 인증 모달을 닫는 함수
2000   */
2001  const open = function (retry, channel, onCancel, target_purpose) {
2002    modal_seq++
2003    const purpose = typeof target_purpose === 'string' && target_purpose !== '' ? target_purpose : PURPOSE
2004    // 서버가 내려준 값이라 constructor 같은 프로토타입 속성이 잡히지 않게 자기 속성만 본다.
2005    const purpose_text = Object.prototype.hasOwnProperty.call(PURPOSE_TEXT, purpose) ? PURPOSE_TEXT[purpose] : null
2006    const scope = '_message_step_up_' + modal_seq
2007    $.cocoaDialog.open({ type: 'message_step_up', custom_popup: buildModalHtml(scope) })
2008
2009    const $scope = $('.' + scope)
2010    // alert_clay 모달의 제목·본문·버튼 스타일을 재사용한다. 다음 open 때 cocoaDialog가 class를 초기화한다.
2011    const $modal = $scope.closest('.modal').addClass('modal_alert_clay')
2012    const $title = $scope.find('._step_up_title')
2013    const $desc = $scope.find('._step_up_desc')
2014    const $code = $scope.find('._step_up_code')
2015    const $remain = $scope.find('._step_up_remain')
2016    const $error = $scope.find('._step_up_error')
2017    const $confirm = $scope.find('._step_up_confirm')
2018    const $otp = $scope.find('._step_up_otp')
2019    const $timer_wrap = $scope.find('._step_up_timer')
2020    let timer = null
2021    let expires_at = 0
2022    let is_busy = false
2023    let request_id = ''
2024    let is_closed = false
2025    let is_completed = false
2026    let is_otp = false
2027    let code_length = 5
2028    let masked_receiver = ''
2029    let is_sent = false
2030
2031    const setChannel = function (target_channel) {
2032      const label = getChannelLabel(target_channel)
2033      is_otp = target_channel === 'otp'
2034      // 인증 앱 코드는 6자리이고 발송이 없어 남은 시간·시간 연장을 쓰지 않는다.
2035      code_length = is_otp ? 6 : 5
2036      $code.attr('maxlength', code_length)
2037      if (is_otp) {
2038        $title.text('인증 앱 2차 인증')
2039        $desc.text('인증 앱에 표시된 6자리 숫자를 입력해 주세요.')
2040        $timer_wrap.hide()
2041        return
2042      }
2043      $timer_wrap.show()
2044      $otp.hide()
2045      if (purpose_text) {
2046        $title.text(purpose_text.title)
2047        // 발송 성공 응답 전에는 완료형 문구를 쓰지 않는다.
2048        $desc.text(is_sent
2049          ? purpose_text.desc.replace('{receiver}', masked_receiver ? '(' + masked_receiver + ')' : '')
2050          : purpose_text.desc_pending)
2051        return
2052      }
2053      $title.text(label ? label + ' 2차 인증' : '2차 인증')
2054      $desc.text(label ? label + '로 수신된 인증번호를 입력해 주세요.' : '수신된 인증번호를 입력해 주세요.')
2055    }
2056    setChannel(channel)
2057    if (purpose_text && purpose_text.confirm) $confirm.text(purpose_text.confirm)
2058
2059    // 등록 단계 QR 은 사내 vendor 라이브러리를 그때만 받아 그린다(외부 CDN 금지).
2060    const renderOtpEnroll = function (data) {
2061      const uri = (data && data.otpauth_uri) || ''
2062      const secret = (data && data.secret) || ''
2063      if (!data || !data.enroll || uri === '' || secret === '') {
2064        $otp.hide()
2065        return
2066      }
2067      $desc.text('인증 앱에 아래 계정을 등록한 뒤, 앱에 표시된 6자리 숫자를 입력해 주세요.')
2068      $otp.find('._step_up_otp_link').attr('href', uri)
2069      $otp.find('._step_up_otp_secret').text(secret)
2070      $otp.show()
2071
2072      const $qr = $otp.find('._otp_qr')
2073      const lib = data.qr_lib || ''
2074      $qr.empty().show()
2075      if (lib === '') {
2076        $qr.hide()
2077        return
2078      }
2079      const draw = function () {
2080        try {
2081          new QRCode($qr.get(0), {
2082            text: uri,
2083            width: 160,
2084            height: 160,
2085            correctLevel: QRCode.CorrectLevel.M
2086          })
2087        } catch (e) {
2088          // QR 을 못 그려도 링크·키로 등록할 수 있게 QR 영역만 숨긴다.
2089          $qr.hide()
2090        }
2091      }
2092      if (typeof QRCode !== 'undefined') {
2093        draw()
2094        return
2095      }
2096      $.getScript(lib).done(draw).fail(function () {
2097        $qr.hide()
2098      })
2099    }
2100
2101    const stopTimer = function () {
2102      if (timer) {
2103        clearInterval(timer)
2104        timer = null
2105      }
2106    }
2107
2108    $modal.one('hidden.bs.modal', function () {
2109      is_closed = true
2110      stopTimer()
2111      if (!is_completed && typeof onCancel === 'function') onCancel()
2112    })
2113
2114    const tick = function () {
2115      // 모달이 닫히거나 다른 모달로 교체되면 타이머를 멈춘다.
2116      if (!$.contains(document.body, $scope[0]) || !$modal.is(':visible')) {
2117        stopTimer()
2118        return
2119      }
2120      const remain = Math.max(0, Math.ceil((expires_at - Date.now()) / 1000))
2121      $remain.text(formatRemainTime(remain))
2122      if (remain <= 0) {
2123        stopTimer()
2124        $error.text('인증번호 입력 시간이 초과되었습니다. 시간 연장을 눌러 인증번호를 다시 받아 주세요.')
2125      }
2126    }
2127
2128    // 인증 모달이 닫힌 뒤 재요청해야 결과 모달이 같은 레이어에 겹치지 않는다.
2129    const closeAndRetry = function (auth_token) {
2130      if (is_closed) return
2131      is_completed = true
2132      stopTimer()
2133      $modal.one('hidden.bs.modal', function () {
2134        setTimeout(function () {
2135          retry(auth_token)
2136        }, 0)
2137      })
2138      $modal.modal('hide')
2139    }
2140
2141    // 이미 닫혔거나 다른 모달로 교체된 뒤 도착한 응답은 안내하지 않는다. 다음 발송 요청이 같은 거부를 다시 받는다.
2142    const closeAndOpenSettingRequired = function (res) {
2143      stopTimer()
2144      if (is_closed || !$.contains(document.body, $scope[0]) || !$modal.is(':visible')) return
2145      is_completed = true
2146      if (typeof onCancel === 'function') onCancel()
2147      $modal.one('hidden.bs.modal', function () {
2148        setTimeout(function () {
2149          openSettingRequired(res.msg, res.data)
2150        }, 0)
2151      })
2152      $modal.modal('hide')
2153    }
2154
2155    const request = function (is_resend) {
2156      if (is_busy) return
2157      is_busy = true
2158      $error.text('')
2159      $.ajax({
2160        type: 'POST',
2161        url: REQUEST_URL,
2162        data: { purpose, resend: is_resend ? 'Y' : 'N' },
2163        dataType: 'json',
2164        cache: false,
2165        success: function (res) {
2166          if (is_closed || !$.contains(document.body, $scope[0])) return
2167          if (res && res.msg === 'SUCCESS' && res.data) {
2168            request_id = res.data.request_id || ''
2169            masked_receiver = typeof res.data.receiver === 'string' ? res.data.receiver : ''
2170            is_sent = true
2171            if (purpose_text || getChannelLabel(res.data.channel)) setChannel(res.data.channel)
2172            stopTimer()
2173            if (is_otp) {
2174              renderOtpEnroll(res.data)
2175            } else {
2176              expires_at = Date.now() + (parseInt(res.data.expires_in, 10) || 180) * 1000
2177              tick()
2178              timer = setInterval(tick, 1000)
2179            }
2180            $code.val('').trigger('focus')
2181          } else if (isSettingRequired(res && res.data)) {
2182            closeAndOpenSettingRequired(res)
2183          } else {
2184            $error.text((res && res.msg) || '인증번호 전송에 실패했습니다.')
2185          }
2186        },
2187        error: function () {
2188          $error.text('인증번호 전송에 실패했습니다. 잠시 후 다시 시도해 주세요.')
2189        },
2190        complete: function () {
2191          is_busy = false
2192        }
2193      })
2194    }
2195
2196    const verify = function () {
2197      if (is_busy) return
2198      const cert_code = $.trim($code.val())
2199      if (!new RegExp('^[0-9]{' + code_length + '}$').test(cert_code)) {
2200        $error.text(is_otp ? '인증 앱의 6자리 숫자를 입력해 주세요.' : '인증번호 5자리를 입력해 주세요.')
2201        return
2202      }
2203      is_busy = true
2204      $error.text('')
2205      $confirm.prop('disabled', true)
2206      $.ajax({
2207        type: 'POST',
2208        url: VERIFY_URL,
2209        data: { purpose, cert_code, request_id },
2210        dataType: 'json',
2211        cache: false,
2212        success: function (res) {
2213          if (is_closed || !$.contains(document.body, $scope[0])) return
2214          if (res && res.msg === 'SUCCESS') {
2215            closeAndRetry((res.data && res.data.auth_token) || '')
2216          } else if (isSettingRequired(res && res.data)) {
2217            closeAndOpenSettingRequired(res)
2218          } else {
2219            $error.text((res && res.msg) || '인증에 실패했습니다.')
2220          }
2221        },
2222        error: function () {
2223          $error.text('인증에 실패했습니다. 잠시 후 다시 시도해 주세요.')
2224        },
2225        complete: function () {
2226          is_busy = false
2227          $confirm.prop('disabled', false)
2228        }
2229      })
2230    }
2231
2232    $code.on('input', function () {
2233      $code.val($code.val().replace(/[^0-9]/g, '').slice(0, code_length))
2234    })
2235    $code.on('keydown', function (e) {
2236      if (e.keyCode === 13) {
2237        e.preventDefault()
2238        verify()
2239      }
2240    })
2241    $scope.find('._step_up_resend').on('click', function () {
2242      request(true)
2243    })
2244    $confirm.on('click', verify)
2245
2246    request(false)
2247    return function () {
2248      if (!is_closed) $modal.modal('hide')
2249    }
2250  }
2251
2252  /**
2253   * 발송 응답이 인증 게이트 거부면 안내·인증을 시작하고 true를 돌려준다.
2254   * @param {object} res 발송 엔드포인트 응답
2255   * @param {function} retry 인증 성공 후 받은 토큰(auth_token)으로 같은 발송을 다시 요청하는 함수
2256   */
2257  const handleSendResponse = function (res, retry) {
2258    const data = res && res.data
2259    if (!data || typeof data !== 'object') return false
2260    if (isSettingRequired(data)) {
2261      openSettingRequired(res.msg, data)
2262      return true
2263    }
2264    if (data.type === TYPE_TWO_FACTOR_AUTH_REQUIRED && typeof retry === 'function') {
2265      open(retry, data.channel, undefined, data.purpose)
2266      return true
2267    }
2268    return false
2269  }
2270
2271  return {
2272    handleSendResponse,
2273    supportsCancellation: true,
2274    open
2275  }
2276}())
2277
2278/**
2279 * TODO(FS-3681): OMS 임시 인증 연결. 대체 인증 적용 후 이 IIFE와 order-v1 수신 분기를 제거한다.
2280 * PHP는 인증만 처리하며, verified를 받은 OMS Admin이 기존 API로 발송한다.
2281 */
2282var OMS_MANUAL_SMS = (function () {
2283  // 부모 BO의 인증 모달은 공유되므로 다른 주문의 인증 요청과 겹치지 않게 한다.
2284  let active = false
2285
2286  const handle = function (event, appOrigin) {
2287    const iframe = document.querySelector('#OMSA iframe')
2288
2289    if (
2290      !iframe ||
2291      event.source !== iframe.contentWindow ||
2292      event.origin !== appOrigin ||
2293      !event.ports ||
2294      event.ports.length !== 1
2295    ) {
2296      return
2297    }
2298
2299    const port = event.ports[0]
2300    const requestId = event.data.requestId
2301
2302    if (typeof requestId !== 'string' || !/^[a-zA-Z0-9-]{1,80}$/.test(requestId)) {
2303      port.close()
2304      return
2305    }
2306
2307    const reply = function (result) {
2308      port.postMessage(Object.assign({ requestId }, result))
2309    }
2310
2311    if (active) {
2312      reply({ status: 'error', message: '진행 중인 인증을 먼저 완료해 주세요.' })
2313      port.close()
2314      return
2315    }
2316
2317    // 구 member.js에는 취소 결과가 없어 OMS가 계속 대기하므로 준비되지 않으면 닫는다.
2318    if (typeof ADMIN_MESSAGE_STEP_UP === 'undefined' || !ADMIN_MESSAGE_STEP_UP.supportsCancellation) {
2319      reply({ status: 'error', message: '인증 기능을 준비 중입니다. 페이지를 새로고침해 주세요.' })
2320      port.close()
2321      return
2322    }
2323
2324    active = true
2325    reply({ status: 'accepted' })
2326
2327    let settled = false
2328    let sending = false
2329    let cancelModal = null
2330    let controller = null
2331    let watch = null
2332
2333    // 인증 취소·네트워크 응답·iframe 이동이 겹쳐도 최종 결과는 한 번만 돌려준다.
2334    const finish = function (result) {
2335      if (settled) return
2336
2337      settled = true
2338      active = false
2339      clearInterval(watch)
2340      iframe.removeEventListener('load', cancel)
2341      port.onmessage = null
2342
2343      reply(result)
2344      port.close()
2345    }
2346
2347    const cancel = function () {
2348      if (settled) return
2349
2350      finish({ status: 'cancelled' })
2351
2352      if (controller) controller.abort()
2353      if (cancelModal) cancelModal()
2354    }
2355
2356    port.onmessage = function (message) {
2357      if (message.data && message.data.type === 'cancel' && message.data.requestId === requestId) {
2358        cancel()
2359      }
2360    }
2361
2362    port.start()
2363    iframe.addEventListener('load', cancel, { once: true })
2364
2365    // iframe 제거는 load를 발생시키지 않을 수 있어 연결 상태도 확인한다.
2366    watch = setInterval(function () {
2367      if (!iframe.isConnected || iframe.contentWindow !== event.source) cancel()
2368    }, 500)
2369
2370    const submit = async function (authToken) {
2371      if (settled || sending) return
2372
2373      sending = true
2374
2375      const body = new URLSearchParams()
2376
2377      if (authToken) body.set('two_factor_auth_token', authToken)
2378
2379      controller = new AbortController()
2380
2381      try {
2382        const response = await fetch('/admin/ajax/oms/verify_manual_sms_auth.cm', {
2383          method: 'POST',
2384          credentials: 'same-origin',
2385          body,
2386          signal: controller.signal
2387        })
2388
2389        if (!response.ok) throw new Error('request_failed')
2390
2391        const result = await response.json()
2392        sending = false
2393
2394        if (settled) return
2395
2396        if (result.msg === 'SUCCESS') {
2397          finish({ status: 'verified' })
2398          return
2399        }
2400
2401        if (result.data && result.data.type === 'two_factor_auth_required') {
2402          // 재호출 토큰이 거절되면 자동으로 새 인증을 반복하지 않는다.
2403          if (authToken) {
2404            finish({ status: 'error', message: '인증이 만료되었습니다. 다시 시도해 주세요.' })
2405            return
2406          }
2407
2408          cancelModal = ADMIN_MESSAGE_STEP_UP.open(
2409            submit,
2410            result.data.channel,
2411            function () {
2412              finish({ status: 'cancelled' })
2413            }
2414          )
2415          return
2416        }
2417
2418        // 101/103은 기존 PHP 설정 안내 UI가 맡고, OMS의 제출 대기는 해제한다.
2419        if (ADMIN_MESSAGE_STEP_UP.handleSendResponse(result, submit)) {
2420          finish({ status: 'cancelled' })
2421          return
2422        }
2423
2424        finish({
2425          status: 'error',
2426          message: typeof result.msg === 'string' ? result.msg : '인증 요청에 실패했습니다.'
2427        })
2428      } catch (error) {
2429        if (!settled) {
2430          finish({
2431            status: 'error',
2432            message: '인증 결과를 확인할 수 없습니다. 다시 인증해 주세요.'
2433          })
2434        }
2435      } finally {
2436        sending = false
2437      }
2438    }
2439
2440    // 인증 필요 여부는 PHP가 결정한다. 최초 요청에는 2차 인증 토큰을 싣지 않는다.
2441    submit('')
2442  }
2443
2444  return { handle }
2445}())
2446
2447const remove_group_list = []
2448let is_writing = false
2449let is_join_confirm = false
2450var adminMember = {
2451  openGroupForm: function (idx) {
2452    $.ajax({
2453      type: 'POST',
2454      data: { idx },
2455      url: ('/admin/ajax/config/membership/group_form.cm'),
2456      dataType: 'json',
2457      async: false,
2458      cache: false,
2459      success: function (result) {
2460        if (result.msg == 'SUCCESS') {
2461          $.cocoaDialog.open({ type: 'admin_group_form', custom_popup: result.html, width: 550 })
2462        } else {
2463          alert(result.msg)
2464        }
2465      }
2466    })
2467  },
2468  deleteGroupForm: function (idx) {
2469    $.ajax({
2470      type: 'POST',
2471      data: { mode: 'delete', idx },
2472      url: ('/admin/ajax/config/membership/group_form_proc.cm'),
2473      dataType: 'json',
2474      async: false,
2475      cache: false,
2476      success: function (result) {
2477        if (result.msg == 'SUCCESS') {
2478          // 성공시 전체 사용자로 리다이렉트
2479          window.location.href = '/admin/member/list';
2480        }
2481        else alert(result.msg)
2482      }
2483    })
2484  },
2485  addGroupForm: function () {
2486    const f = $('#groupf')
2487    const data = f.serializeObject()
2488    $.ajax({
2489      type: 'POST',
2490      data,
2491      url: ('/admin/ajax/config/membership/group_form_proc.cm'),
2492      dataType: 'json',
2493      async: false,
2494      cache: false,
2495      success: function (result) {
2496        if (result.msg == 'SUCCESS') {
2497          if (result.mode == 'add') {
2498            const base_url = $('#new_group_btn').data('base_url')
2499            const groupCode = String(result.code || '')
2500            const $groupItem = $('<li>', { id: 'group_item_' + groupCode })
2501            const $groupLink = $('<a>', { href: base_url + '&member_group=' + encodeURIComponent(groupCode) })
2502            $groupLink.append(document.createTextNode(result.title || ''))
2503            $groupLink.append($('<small>', { 'class': 'margin-left-lg text-bold opacity-75' }))
2504            $groupItem.append($groupLink)
2505            $('#new_group_btn').before($groupItem)
2506
2507            window.doznutadmin.AppForm.initialize()
2508          } else if (result.mode == 'update') {
2509            $('#group_item_title_' + result.code).text(result.title || '')
2510            $('#group_hidden_item_title_' + result.code).text(result.title || '')
2511            $('#group_hidden_list_title').text(result.title || '')
2512          }
2513          $.cocoaDialog.close()
2514        } else {
2515          if (result.msg == 'VERSION ERROR') {
2516            if (confirm(LOCALIZE_ADMIN.설명_업그레이드페이지로이동할까요())) {
2517              window.location.href = '/admin/payment?mode=detail&type=upgrade'
2518            }
2519          } else {
2520            alert(result.msg)
2521          }
2522        }
2523      }
2524    })
2525  },
2526	 openDeleteMemberGroup: function (code, group_code) {
2527    $.ajax({
2528      type: 'POST',
2529      data: { code, group_code },
2530      url: ('/admin/ajax/member/delete_member_group.cm'),
2531      dataType: 'json',
2532      async: false,
2533      cache: false,
2534      success: function (res) {
2535        if (res.msg == 'SUCCESS') {
2536          $.cocoaDialog.open({
2537            type: 'admin',
2538            custom_popup: res.html,
2539            width: 550,
2540            reopen: true,
2541            use_enter: true,
2542            hide_event: function () {
2543              $(window).unbind('keydown')
2544            }
2545          })
2546        } else {
2547          alert(res.msg)
2548        }
2549      }
2550    })
2551  },
2552  openUpdateMemberConfirm: function (code, type) {
2553    $.ajax({
2554      type: 'POST',
2555      data: { code, type },
2556      url: ('/admin/ajax/member/update_member_confirm.cm'),
2557      dataType: 'html',
2558      async: false,
2559      cache: false,
2560      success: function (html) {
2561        $.cocoaDialog.open({ type: 'admin', custom_popup: html })
2562      }
2563    })
2564  },
2565  UpdateMemberConfirm: function (code, type) {
2566    if (is_join_confirm) return
2567    is_join_confirm = true
2568    $.ajax({
2569      type: 'POST',
2570      data: { code },
2571      url: ('/admin/ajax/member/update_member_confirm_proc.cm'),
2572      dataType: 'json',
2573      async: false,
2574      cache: false,
2575      success: function (result) {
2576        if (result.msg == 'SUCCESS') {
2577          location.reload()
2578        } else {
2579          alert(result.msg)
2580          is_join_confirm = false
2581        }
2582      }
2583    })
2584  },
2585  openDeleteMember: function (data) {
2586    $.ajax({
2587      type: 'POST',
2588      data: { data },
2589      url: ('/admin/ajax/member/delete_member.cm'),
2590      dataType: 'html',
2591      async: false,
2592      cache: false,
2593      success: function (html) {
2594        $.cocoaDialog.open({ type: 'admin', custom_popup: html })
2595      },
2596      error: function () {
2597        alert(getLocalizeString('설명_잠시후다시시도해주세요', '', '잠시 후 다시 시도해주세요.'))
2598      }
2599    })
2600  },
2601  openDeleteInactiveMember: function (code) {
2602    $.ajax({
2603      type: 'POST',
2604      data: { code: JSON.stringify(code) },
2605      url: ('/admin/ajax/member/delete_inactive_member.cm'),
2606      dataType: 'html',
2607      async: false,
2608      cache: false,
2609      success: function (html) {
2610        $.cocoaDialog.open({ type: 'admin', custom_popup: html })
2611      }
2612    })
2613  },
2614  openInactiveMember: function (code) {
2615    $.ajax({
2616      type: 'POST',
2617      data: { code: JSON.stringify(code) },
2618      url: ('/admin/ajax/member/inactive_member.cm'),
2619      dataType: 'html',
2620      async: false,
2621      cache: false,
2622      success: function (html) {
2623        $.cocoaDialog.open({ type: 'admin', custom_popup: html })
2624      }
2625    })
2626  },
2627  UpdateMemberAutoGroupingSetting: function (code, use) {
2628    $.ajax({
2629      type: 'POST',
2630      data: { code, use },
2631      url: '/admin/ajax/member/member_autogrouping_setting_proc.cm',
2632      dataType: 'json',
2633      async: false,
2634      cache: false,
2635      success: function (result) {
2636        if (result.msg == 'SUCCESS') {
2637          location.reload()
2638        } else { alert(result.msg) }
2639      }
2640    })
2641  },
2642  deleteMember: function (data) {
2643    $.ajax({
2644      type: 'POST',
2645      data: { data },
2646      url: ('/admin/ajax/member/delete_member_proc.cm'),
2647      dataType: 'json',
2648      async: false,
2649      cache: false,
2650      success: function (result) {
2651        if (result.msg == 'SUCCESS') {
2652	        mixpanel.track('complete_bo_member_list_delete_item', {'count': JSON.parse(data).length,'site_code': SITE_CODE}
2652);
2653          location.reload()
2654        } else { alert(result.msg) }
2655      }
2656    })
2657  },
2658  deleteInactiveMember: function (code) {
2659    $.ajax({
2660      type: 'POST',
2661      data: { mode: 'delete', code },
2662      url: ('/admin/ajax/member/delete_inactive_member_proc.cm'),
2663      dataType: 'json',
2664      async: false,
2665      cache: false,
2666      success: function (result) {
2667        if (result.msg == 'SUCCESS') {
2668          location.reload()
2669        } else { alert(result.msg) }
2670      }
2671    })
2672  },
2673  updateInactiveMember: function (code) {
2674    $.ajax({
2675      type: 'POST',
2676      data: { code },
2677      url: ('/admin/ajax/member/inactive_member_proc.cm'),
2678      dataType: 'json',
2679      async: false,
2680      cache: false,
2681      success: function (result) {
2682        if (result.msg == 'SUCCESS') {
2683          location.reload()
2684        } else { alert(result.msg) }
2685      }
2686    })
2687  },
2688  deleteMemberAuth: function (code) {
2689    if (!confirm(getLocalizeString('설명_본인인증해제', '', '본인인증을 해제하면 해당 회원의 실명 확인 및 중복 가입 방지, 아이디·패스워드 찾기, 성인 여부 확인 기능이 동작하지 않는 점 주의해주세요. 본인인증 해제를 진행 하시겠습니까?'))) {
2690      return false
2691    }
2692    $.ajax({
2693      type: 'POST',
2694      data: { code },
2695      url: ('/admin/ajax/member/delete_member_auth.cm'),
2696      dataType: 'json',
2697      async: false,
2698      cache: false,
2699      success: function (result) {
2700        alert(result.msg)
2701        location.reload()
2702      }
2703    })
2704  },
2705  openBlockMember: function (idx) {
2706    $.ajax({
2707      type: 'POST',
2708      data: { idx },
2709      url: ('/admin/member/list/block_member.cm'),
2710      dataType: 'html',
2711      async: false,
2712      cache: false,
2713      success: function (html) {
2714        $.cocoaDialog.open({ type: 'admin', custom_popup: html })
2715      }
2716    })
2717  },
2718  blockMember: function (idx) {
2719    $.ajax({
2720      type: 'POST',
2721      data: { idx },
2722      url: ('/admin/member/list/block_member_proc.cm'),
2723      dataType: 'json',
2724      async: false,
2725      cache: false,
2726      success: function (result) {
2727        if (result.msg == 'SUCCESS') { $('#member_item_' + idx).addClass('block') } else { alert(result.msg) }
2728      }
2729    })
2730  },
2731  openMemberPushForm: function (member_code_list) {
2732    $.ajax({
2733      type: 'POST',
2734      data: { member_code_list },
2735      url: ('/admin/member/send/member_push_form.cm'),
2736      dataType: 'html',
2737      async: false,
2738      cache: false,
2739      success: function (html) {
2740        $.cocoaDialog.open({ type: 'admin', custom_popup: html, width: 600 })
2741      }
2742    })
2743  },
2744  openMemberSmsForm: function (member_code_list, order_code) {
2745    $.ajax({
2746      type: 'POST',
2747      data: { member_code_list, order_code },
2748      url: ('/admin/member/send/member_sms_form.cm'),
2749      dataType: 'html',
2750      async: false,
2751      cache: false,
2752      success: function (html) {
2753        $.cocoaDialog.open({ type: 'admin', custom_popup: html, width: 600 })
2754      }
2755    })
2756  },
2757  sendPush: function (receiver, alarm_subject, alarm_content, url, alarm_img_tmp_idx, alarm_img, all_send, is_booking, booking_time, alarm_token, alarm_token_key) {
2758    if (isSiteOutboundBlocked()) {
2759      return showSiteOutboundGuide()
2760    }
2761
2762    $.ajax({
2763      type: 'POST',
2764      data: {
2765        receiver,
2766        alarm_subject,
2767        alarm_content,
2768        url,
2769        alarm_img_tmp_idx,
2770        alarm_img,
2771        all_send: (all_send ? 'Y' : 'N'),
2772        is_booking: (is_booking ? 'Y' : 'N'),
2773        booking_time,
2774        alarm_token,
2775        alarm_token_key
2776      },
2777      url: ('/admin/ajax/send_alarm.cm'),
2778      dataType: 'json',
2779      async: false,
2780      cache: false,
2781      success: function (res) {
2782        if (res.msg == 'SUCCESS') {
2783          $.cocoaDialog.open({ type: 'admin', custom_popup: res.html, width: 550 })
2784        } else {
2785          if (res.html !== undefined) {
2786            $.cocoaDialog.open({ type: 'admin', custom_popup: res.html, width: 550 })
2787          } else {
2788            alert(res.msg)
2789          }
2790        }
2791      }
2792    })
2793  },
2794  /**
2795   * 중복 제출 방지용 버튼 잠금. **가드가 없는 화면도 커버한다.**
2796   *
2797   * 가드(`MessageSendGuard`)는 SDK 가 배포된 서버에서만 붙는다(`typeof` 가드). 그 창에서
2798   * 잠금이 없으면 요청 중에 다시 눌러 요청이 두 번 나가고 **크레딧이 두 번 차감**된다.
2799   * 그래서 버튼 요소를 직접 잠근다 — `disabled` 는 클릭을 막고, jQuery UI 버튼이면 클래스도 맞춘다.
2800   *
2801   * 잠글 버튼은 화면이 준 선택자(또는 요소)로 찾는다. 화면마다 버튼 id 가 달라
2802   * (`#sendsms`·모달 푸터) 호출부가 정하게 둔다.
2803   *
2804   * @param {*} target 선택자 | jQuery | DOM. 없으면 잠그지 않는다.
2805   * @param {boolean} on true 면 잠그고 false 면 푼다.
2806   */
2807  setSendButtonLock: function (target, on) {
2808    if (!target) return
2809    const $btn = $(target)
2810    if ($btn.length === 0) return
2811    if (on) {
2812      $btn.prop('disabled', true).addClass('disabled')
2813    } else {
2814      $btn.prop('disabled', false).removeClass('disabled')
2815    }
2816  },
2817  /**
2818   * 중복 클릭 안내 문구. **화면을 새로고침하지 않는다** — 첫 요청이 그대로 살아 있어
2819   * 새로고침하면 결과가 사라지고, 사용자가 다시 눌러 중복 발송이 난다.
2820   */
2821  MESSAGE_SENDING: '전송 중입니다. 잠시만 기다려 주세요.',
2822  /**
2823   * 수동 SMS 발송. 화면이 만든 `MessageSendGuard` 를 9번째 인자로 넘기면
2824   * 버튼 아래 상태 섹션에 검사 중·차단·실패를 표시한다.
2825   *
2826   * 가드가 없어도(다른 화면·구버전 화면) 발송은 그대로 동작한다 — 응답 문구는 서버가 정본이라
2827   * 가드가 없으면 기존 alert 경로가 그 문구를 보여 준다.
2828   *
2829   * **비동기 XHR 이다.** 동기(`async:false`)면 브라우저가 렌더를 멈춰, 판정이 최대 13초 걸리는
2830   * 동안 "검사 중" 섹션이 **한 번도 그려지지 않는다**(응답이 온 뒤에야 화면이 갱신된다).
2831   * 중복 제출은 가드의 버튼 잠금 + `is_writing` + 아래 버튼 직접 잠금이 함께 막는다.
2832   */
2833  sendSms: function (receiver, title, msg, chk_agree, booking_time, is_booking, is_advertisement, two_factor_auth_token, guard, send_button) {
2834    if (isSiteOutboundBlocked()) {
2835      // 기존 사유(만료·신규·무료체험)면 **사유를 알리지 않는다** — 화면 진입 자체가 막히고
2836      // (outbound_blocked.sub 가 빈 상태를 그린다) 여기 도달했다면 이미 안내를 본 상태다.
2837      // 그래서 섹션만 정리해 기존 안내에 맡긴다(실패 문구를 띄우면 사유를 역산하게 된다).
2838      //
2839      // **2차 인증 사유는 예외다** — 그 사유는 이용자가 스스로 해소해야 하는 설정이라,
2840      // 화면 진입이 막히지 않는 경로(모달·재시도)에서는 여기서 안내를 띄워야 한다.
2841      // `showSiteOutboundGuide()` 는 문구가 빈 문자열이면 아무것도 하
2841지 않는다(종전 동작).
2842      if (guard) guard.succeed()
2843      return showSiteOutboundGuide()
2844    }
2845
2846    // 가드·버튼까지 포함해 넘긴다(인덱스 8·9). 2차 인증 재시도 때도 유지돼야
2847    // 재시도 응답이 같은 섹션에 표시되고 같은 버튼이 잠긴다.
2848    const send_args = Array.prototype.slice.call(arguments, 0, 10)
2849    const retry = function (auth_token) {
2850      const retry_args = send_args.slice()
2851      retry_args[7] = auth_token
2852      // 재시도도 검사 중 상태로 돌아간다. 여기서 안 켜면 2차 인증을 마치고 돌아온 뒤
2853      // 섹션이 이전 문구(또는 빈 상태)로 남아 사용자가 진행 중인지 알 수 없다.
2854      if (guard) guard.start()
2855      adminMember.sendSms.apply(adminMember, retry_args)
2856    }
2857    if (is_writing) {
2858      // **새로고침하지 않는다.** 첫 요청이 살아 있는데 페이지를 갈아 버리면 결과가 사라지고,
2859      // 사용자가 다시 눌러 중복 발송·중복 차감이 난다. 안내만 띄운다.
2860      //
2861      // **이 클릭의 가드는 여기서 끝낸다.** 화면이 클릭마다 `guard.start()` 를 부르므로,
2862      // 그냥 return 하면 이 가드의 watchdog 타이머가 살아 남고 섹션이 "검사 중"으로 남는다 —
2863      // 첫 요청이 끝나 결과를 그린 뒤에도 이 가드가 나중에 문구를 덮어쓴다.
2864      // (섹션은 버튼 하나에 공유되므로, 첫 요청의 가드가 응답 때 다시 그린다.)
2865      if (guard) guard.succeed()
2866      alert(adminMember.MESSAGE_SENDING)
2867      return false
2868    }
2869
2870    is_writing = true
2871    // 가드가 없어도 버튼을 잠근다(중복 제출 차단). 가드가 있으면 가드도 같은 버튼을 잠근다.
2872    adminMember.setSendButtonLock(send_button, true)
2873    const data = { receiver, title, msg, chk_agree, booking_time, is_booking: (is_booking ? 'Y' : 'N'), is_advertisement: (is_advertisement ? 'Y' : 'N') }
2874    if (two_factor_auth_token) data.two_factor_auth_token = two_factor_auth_token
2875    $.ajax({
2876      type: 'POST',
2877      data,
2878      url: ('/admin/ajax/sms/send_sms.cm'),
2879      dataType: 'json',
2880      async: true,
2881      cache: false,
2882      // 판정 예산(13초)보다 넉넉하게 둔다. 이 시간이 지나면 `error` 가 오고, 그때 가드가
2883      // 실패 문구를 띄우며 **버튼 잠금을 푼다**(watchdog 은 잠금을 풀지 않는다).
2884      timeout: 60000,
2885      success: function (res) {
2886        // 가드가 있으면 서버 문구를 그 섹션에 그대로 보여 준다(차단·실패·성공 모두).
2887        // 판정 차단 문구도 여기로 오므로 별도 분기가 필요 없다.
2888        const guard_state = guard ? guard.resolve(res) : null
2889        // **이 응답의 처리는 여기서 끝났다.** 아래 `retry`(2차 인증)가 `sendSms` 를 다시 부르는데,
2890        // `is_writing` 을 이 아래에 두면 재시도가 "이미 전송 중"으로 접혀 **실제로 발송되지 않는다**.
2891        // (예전에는 그 자리를 `window.location.reload()` 가 덮어 새로고침으로 보였다.)
2892        // 재시도가 새 요청을 시작하면 그쪽에서 다시 잠그므로, 여기서 먼저 푸는 것이 맞다.
2893        is_writing = false
2894        adminMember.setSendButtonLock(send_button, false)
2895        if (res.msg == 'SUCCESS') {
2896          $.cocoaDialog.open({ type: 'admin', custom_popup: res.html, width: 550, close_block: true })
2897        } else if (!ADMIN_MESSAGE_STEP_UP.handleSendResponse(res, retry)) {
2898          if (res.html !== undefined) {
2899            $.cocoaDialog.open({ type: 'admin', custom_popup: res.html, width: 550 })
2900          } else if (guard_state === 'blocked' || guard_state === 'unavailable') {
2901            // 가드가 이미 같은 문구를 섹션에 띄웠다. alert 로 또 띄우면 두 번 보인다.
2902          } else {
2903            alert(res.msg)
2904          }
2905        }
2906      },
2907      error: function () {
2908        // 네트워크 오류·화면 타임아웃. 가드를 실패로 끝내 **버튼 잠금을 푼다.**
2909        if (guard) guard.fail()
2910        is_writing = false
2911        adminMember.setSendButtonLock(send_button, false)
2912      }
2913    })
2914  },
2915  deleteBookingSms: function (sms_log_idx, sms_type) {
2916    if (!confirm(LOCALIZE_ADMIN.설명_여러명에게전송한메세지의경우일괄취소됩니다())) return false
2917    $.ajax({
2918      type: 'POST',
2919      data: { sms_log_idx, sms_type },
2920      url: ('/admin/ajax/sms/delete_booking_sms.cm'),
2921      dataType: 'json',
2922      async: false,
2923      cache: false,
2924      success: function (obj) {
2925        if (obj.msg === 'SUCCESS') {
2926          window.location.reload()
2927        } else {
2928          if (obj.msg === 'CONFLICT') {
2929            alert(getLocalizeString('설명_이미발송된메세지는취소할수없습니다', '', '이미 발송된 메세지는 취소할 수 없습니다'))
2930            window.location.reload()
2931          } else {
2932            alert(obj.msg)
2933          }
2934        }
2935      }
2936    })
2937  },
2938  openSmsDetailList: function (sms_log_idx, search_type, search_keyword) {
2939    $.ajax({
2940      type: 'POST',
2941      data: { 'sms_log_idx' :  sms_log_idx, 'search_type' : search_type, 'search_keyword':  search_keyword},
2942      url: ('/admin/ajax/sms/sms_detail_list.cm'),
2943      dataType: 'html',
2944      async: false,
2945      cache: false,
2946      success: function (html) {
2947        $.cocoaDialog.open({ type: 'admin_sms', custom_popup: html })
2948      }
2949    })
2950  },
2951  refreshMessageCreditCount: function ($root) {
2952    const $targetRoot = $root ? $($root) : $(document)
2953    const $creditCount = $targetRoot.find('._message_credit_count').add($targetRoot.filter('._message_credit_count'))
2954    if ($creditCount.length === 0) {
2955      return
2956    }
2957
2958    $.ajax({
2959      type: 'GET',
2960      url: ('/admin/ajax/sms/get_sms_credit.cm'),
2961      dataType: 'json',
2962      cache: false,
2963      success: function (res) {
2964        if (res.success !== true || !res.data || typeof res.data.credit === 'undefined') {
2965          return
2966        }
2967
2968        const credit = parseFloat(res.data.credit)
2969        if (isNaN(credit)) {
2970          return
2971        }
2972
2973        $creditCount.text(credit > 0 ? credit.toLocaleString(undefined, { maximumFractionDigits: 1 }) : '0')
2974      }
2975    })
2976  },
2977  openChargeSmsform: function (type, redirectUrlOrOnClose) {
2978    const messageCreditChargeConfig = window.messageCreditChargeConfig || {}
2979    const redirectUrl = typeof redirectUrlOrOnClose === 'string' && redirectUrlOrOnClose !== '' ? redirectUrlOrOnClose : window.location.href
2980    const closeHandler = typeof redirectUrlOrOnClose === 'function' ? redirectUrlOrOnClose : null
2981    const runOnClose = function () {
2982      if (typeof closeHandler === 'function') {
2983        closeHandler()
2984      }
2985    }
2986
2987    if (type === 'member' && typeof mixpanel !== 'undefined' && typeof SITE_CODE !== 'undefined') {
2988      mixpanel.track('click_bo_member_message_sms_purchase', { 'site_code': SITE_CODE })
2989    }
2990
2991    // 만료·신규·무료체험 사이트는 충전 모달을 열지 않는다.
2992    // **사유를 알리지 않는다** — alert 로 이유를 알려 주면 우회 조건(72시간 대기·유료 전환)을
2993    // 역산한다(사용자 결정, 2026-10-01). 그래서 조용히 return 만 한다.
2994    // **비용 축만** — 충전은 발송 경로가 아니다(사용자 확정 2026-10-07).
2995    if (isSiteOutboundBlockedByCost()) {
2996      return showSiteOutboundGuide()
2997    }
2998
2999    if (messageCreditChargeConfig.useMessageCreditChargeModal) {
3000      if (messageCreditChargeConfig.isFreeSite) {
3001        if (confirm(LOCALIZE_ADMIN.설명_업그레이드페이지로이동할까요())) {
3002          location.href = '/admin/payment?mode=detail&type=upgrade'
3003          return false
3004        }
3005
3006        runOnClose()
3007        return false
3008      }
3009
3010      if (!messageCreditChargeConfig.canCharge) {
3011        alert(LOCALIZE_ADMIN.설명_권한이없습니다())
3012        runOnClose()
3013        return false
3014      }
3015
3016      if (messageCreditChargeConfig.isEnabled) {
3017        window.dispatchEvent(new CustomEvent('open:charge'))
3018        runOnClose()
3019        return false
3020      }
3021    }
3022
3023    $.ajax({
3024      type: 'POST',
3025      data: {
3026        type,
3027        current_url: redirectUrl,
3028      },
3029      url: ('/admin/ajax/sms/charge_sms.cm'),
3030      dataType: 'html',
3031      async: false,
3032      cache: false,
3033      success: function (html) {
3034        $.cocoaDialog.open({
3035          type: 'admin_sms',
3036          custom_popup: html,
3037          use_enter: true,
3038          hide_event: function () {
3039            $(window).unbind('keydown')
3040            // 모달 닫힘 콜백 실행
3041            runOnClose()
3042          }
3043        })
3044      }
3045    })
3046  },
3047  sendSmsInit: function () {
3048    let url = decodeURIComponent(location.href)
3049    url = decodeURIComponent(url)
3050    url = url.split('#')
3051    if (url.length > 1 && url[1] == 'typednumber') {
3052      $('input[name="send_to"]:radio[value="number"]').attr('checked', true)
3053      $('.form-group.send_to').hide()
3054      $('.form-group.send_to.number').show()
3055    }
3056  },
3057  openManageMemberPoint: function (code) {
3058    $.ajax({
3059      type: 'POST',
3060      data: { code },
3061      url: ('/admin/ajax/member/manage_member_point.cm'),
3062      dataType: 'html',
3063      async: false,
3064      cache: false,
3065      success: function (html) {
3066        $.cocoaDialog.open({ type: 'admin', custom_popup: html })
3067      }
3068    })
3069  },
3070  manageMemberPoint: function (code) {
3071    const $form = $('#shop_member_point')
3072    const data = $form.serializeObject()
3073    $.ajax({
3074      type: 'POST',
3075      data: { code, data },
3076      url: ('/admin/ajax/member/manage_member_point_proc.cm'),
3077      dataType: 'json',
3078      async: false,
3079      cache: false,
3080      success: function (result) {
3081        if (result.msg == 'SUCCESS') {
3082          location.reload()
3083        } else { alert(result.msg) }
3084      }
3085    })
3086  },
3087  updateAdminMemo: function (member_code, memo, callback) {
3088    $.ajax({
3089      type: 'POST',
3090      data: {
3091        admin_memo: memo,
3092        code: member_code
3093      },
3094      url: ('/admin/ajax/member/update_admin_memo.cm'),
3095      dataType: 'json',
3096      async: false,
3097      cache: false,
3098      success: function (res) {
3099        callback(res)
3100      }
3101    })
3102  },
3103  openAdminMemoForm: function (member_code) {
3104    $.ajax({
3105      type: 'POST',
3106      data: { member_code },
3107      url: ('/admin/ajax/member/admin_memo_form.cm'),
3108      dataType: 'html',
3109      async: false,
3110      cache: false,
3111      success: function (html) {
3112        $.cocoaDialog.open({ type: 'admin', custom_popup: html, width: 550 }, function () {
3113          const $btn_admin_memo = $('#btn_admin_memo')
3114          const $admin_memo = $('#admin_memo')
3115          if ($admin_memo.length > 0) {
3116            const $admin_memo_chars_title = $('#admin_memo_chars_title')
3117            var pre_admin_memo = $admin_memo.val()
3118            $admin_memo_chars_title.html(getByteLength(pre_admin_memo) + '/500')
3119            $admin_memo.off('keyup.admin_memo').on('keyup.admin_memo', function (e) {
3120              const content = $(this).val()
3121              $admin_memo_chars_title.html(getByteLength(content) + '/500')
3122            })
3123          }
3124
3125          $btn_admin_memo.off('click.btn_admin_memo').on('click.btn_admin_memo', function () {
3126            const memo = $admin_memo.val()
3127            if (pre_admin_memo !== memo) {
3128              pre_admin_memo = memo
3129
3130              if (member_code !== '') {
3131                adminMember.updateAdminMemo(member_code, memo, function (res) {
3132                  if (res.msg !== 'SUCCESS') alert(res.msg)
3133                  else location.reload()
3134                })
3135              }
3136            }
3137          })
3138        })
3139      }
3140    })
3141  }
3142}
3143const SMS_DATAIL_PAGE_MODE = (function () {
3144  let page = 1
3145  let end_data = false
3146  const init = function (sms_log_idx, reset) {
3147    getSmsDetailList(sms_log_idx, reset)
3148  }
3149  var getSmsDetailList = function (sms_log_idx, reset) {
3150    if (reset === true) {
3151      page = 1
3152      end_data = false
3153    }
3154    if (end_data) return false
3155    $.ajax({
3156      type: 'GET',
3157      data: { sms_log_idx, page },
3158      url: ('/admin/ajax/sms/get_sms_detail_list.cm'),
3159      dataType: 'json',
3160      async: false,
3161      cache: true,
3162      success: function (res) {
3163        if (res.data) {
3164          if (res.data.length >= 30) {
3165            $('#target-parent').css('height', '50vh').css('overflow-y', 'scroll')
3166          }
3167          page++
3168          res.data.forEach(function (log) {
3169            $('#target').append(
3170              '<tr>' +
3171							'<td class="text-center">' + log.receiver + '</td>\n' +
3172							'<td class="text-center" style="padding : 8px;">' + log.status + '</td>\n' +
3173							'</tr>')
3174          })
3175        } else {
3176          end_data = true
3177        }
3178      }
3179    })
3180  }
3181  return {
3182    init: function (sms_log_idx, reset) {
3183      init(sms_log_idx, reset)
3184    },
3185    getSmsDetailList: function (sms_log_idx, reset) {
3186      getSmsDetailList(sms_log_idx, reset)
3187    }
3188  }
3189}())
3190const ADMIN_SMS_SEND = (function () {
3191  let send_type
3192  let push_member_data
3193  let $receiver, $sender, $title, $content
3194
3195  const init = function (type) {
3196    send_type = type
3197    $sender = $('#sms_num')
3198    $content = $('#sms_content')
3199    $title = $('#sms_subject')
3200
3201    // 전화번호 형식 체크
3202    $('.phonenumber').on('keydown').check_callnum()
3203
3204    // 내용 작성시 바이트 표기
3205    const lms_max_byte = 2000
3206    $content.keyup(function (e) {
3207      const content = $(this).val()
3208      let msg_byte = getByteLengthForKorean(content)
3209
3210      if (msg_byte > 90) {
3211        if (msg_byte > lms_max_byte) {
3212          alert(LOCALIZE_ADMIN.설명_n바이트를초과할수없습니다(lms_max_byte))
3213          $(this).val(cutByLen(content, lms_max_byte))
3214          msg_byte = 2000
3215        }
3216        $('#chars_app_title').html(msg_byte + '/2000')
3217      } else {
3218        $('#chars_app_title').html(msg_byte + '/90')
3219      }
3220    })
3221
3222    const lms_max_subject_byte = 40
3223    $title.keyup(function (e) {
3224      const content = $(this).val()
3225      let msg_byte = getByteLengthForKorean(content)
3226
3227      if (msg_byte > lms_max_subject_byte) {
3228        alert(LOCALIZE_ADMIN.설명_n바이트를초과할수없습니다(lms_max_subject_byte))
3229        $(this).val(cutByLen(content, 40))
3230        msg_byte = 40
3231      }
3232      $('#chars_subject_cnt').html(msg_byte + '/40')
3233    })
3234
3235    $('#sms_subject,#sms_content').keyup()
3236
3237    // 주문리스트에서 호출 시 추가 설정
3238    if (send_type == 'npay') $receiver = $('#receiver_num')
3239    if (send_type == 'order') {
3240      $receiver = $('#receiver_num')
3241      $receiver.data('number', $('input[name="receiver_type"]:checked').data('number'))
3242
3243      // 라디오 버튼 변경시 수신번호 인풋 제어
3244      $('input[name="receiver_type"]').on('change', function () {
3245        $receiver.val($(this).data('number'))
3246        $receiver.data('number', $(this).data('number'))
3247      })
3248
3249      // 번호 직접 수정시에 라디오 체크 해제
3250      $receiver.on('keyup', function () {
3251        if ($(this).data('number') != $(this).val()) {
3252          $('input[name="receiver_type"]:checked').prop('checked', false)
3253        }
3254      })
3255    }
3256  }
3257
3258  const send_push = function (button_element) {
3259    makeReceiver()
3260    if (push_member_data.length <= 0) {
3261      alert(getLocalizeString('설명_받는사람필수항목', '', '받는사람은 필수항목입니다.'))
3262      return false
3263    }
3264    if ($content.val().trim() === '') {
3265      alert(getLocalizeString('설명_내용필수항목', '', '내용은 필수 항목입니다.'))
3266      return false
3267    }
3268    // SMS 화면과 같은 가드·버튼 잠금을 쓴다.
3269    //
3270    // **버튼은 클릭된 요소를 우선 쓴다**(onclick 이 넘긴 `this`). 클래스로 찾으면 안 된다 —
3271    // `$.cocoaDialog` 는 `#cocoaModal`·`#cocoaSubModal` **두 요소를 재사용**하며 내용을
3272    // `children().detach()` + `append()` 로 **교체**하는데(vendor/js/common.js:4101),
3273    // 닫힌 모달의 내용은 DOM 에 그대로 남는다. 그래서 `$('.js_send_push').get(0)` 은
3274    // 문서 순서가 앞선 `#cocoaModal` 의 **숨은 옛 버튼**을 집을 수 있다
3275    // (바깥 모달이 떠 있으면 새 내용은 `#cocoaSubModal` 로 간다 — 같은 파일 3897).
3276    // `this` 가 없으면 보이는 것 중 마지막을 쓴다(폴백).
3277    let send_button = button_element || null
3278    if (!send_button) {
3279      send_button = $('.js_send_push:visible').last().get(0) || null
3280    }
3281    const guard = (typeof MessageSendGuard !== 'undefined') ? MessageSendGuard.attach(send_button) : null
3282    if (guard) guard.start()
3283    // 인자는 기존 호출 그대로 `undefined` 로 둔다 — 빈 문자열로 바꾸면 직렬화되는 필드가
3284    // 달라진다(서버가 받는 폼이 바뀐다). 여기서 더하는 것은 가드·버튼 둘뿐이다.
3285    adminMember.sendSms(push_member_data, $title.val(), $content.val(), undefined, undefined, undefined, undefined, undefined, guard, send_button)
3286  }
3287
3288  var makeReceiver = function () {
3289    // 초기화
3290    push_member_data = []
3291    switch (send_type) {
3292      case 'npay':
3293        push_member_data.push({ type: 'number', value: $receiver.val(), proof: $('#receiver_num').closest('form').data('direct-sms-proof') })
3294        break
3295      case 'order':
3296        const $selected = $('input[name="receiver_type"]:checked')
3297        if ($selected.length && $selected.data('number') == $receiver.val() && $selected.val() == 'member') {
3298          push_member_data.push({ type: 'member', value: $selected.data('member-code') })
3299        } else if ($selected.length && $selected.data('number') == $receiver.val() && $selected.data('proof')) {
3300          push_member_data.push({ type: 'number', value: $receiver.val(), proof: $selected.data('proof') })
3301        } else {
3302          push_member_data.push({ type: 'number', value: $receiver.val(), proof: $receiver.closest('form').data('direct-sms-proof') })
3303        }
3304        break
3305      case 'member':
3306        $('.receiver_list:checked').each(function () {
3307          push_member_data.push({ type: 'member', value: $(this).val() })
3308        })
3309        break
3310    }
3311  }
3312  return {
3313    init: function (type) {
3314      init(type)
3315    },
3316    send_push: function (button_element) {
3317      send_push(button_element)
3318    }
3319  }
3320}())
3321
3322const AUTO_SMS_SETTING = (function () {
3323  let $dialog
3324  let header_ctl
3325  let $form
3326  let $certification_sender_number
3327  let $sms_config_wrap
3328  const MAX_CERT_DOCUMENT_NUMBER = 11
3329  const DOCUMENT_REQUIREMENT = { // 필수로 등록해야 하는 인증 서류
3330    personal: {
3331      p_owner: [1], // 일반 소유자
3332      p_others: [1, 2, 3], // 일반 타인
3333      p_other_company: [1, 2, 3, 4, 5] // 일반 타사
3334    },
3335    business: {
3336      b_owner: [1, 7], // 대표자
3337      b_first_incumbent: [1, 7, 8], // 자사(재직자)
3338      b_first_party: [1, 7], // 자사
3339      b_others: [1, 2, 3, 7], // 타인
3340      b_other_company: [1, 2, 3, 4, 5, 6, 7, 8, 9] // 타사
3341    }
3342  }
3343  const DOCUMENT_REQUIREMENT_ORDER = [1, 2, 7, 8, 6, 4, 5, 3, 9, 11, 10] // 인증 서류 유효성 검사 순서
3344
3345  const init = function ($obj) {
3346    $dialog = $obj
3347    $form = $dialog.find('#sms_setting_form')
3348    $sms_config_wrap = $dialog.find('#sms_config_wrap')
3349
3350    header_ctl = new HEADER_CONTROL()
3351    header_ctl.init2()
3352    header_ctl.addBtn('save', function () {
3353      submit()
3354    })
3355
3356    createEvent()
3357  }
3358
3359  var createEvent = function () {
3360    $dialog.find('input[type=checkbox],input[type=radio]').not('._message_credit_auto_charge_enabled').off('click').on('click', function () {
3361      header_ctl.change()
3362    })
3363    $dialog.find('input[type=text]').off('keydown').on('keydown, change, blur', function () {
3364      header_ctl.change()
3365    })
3366    $dialog.find('select').off('change').on('change', function () {
3367      header_ctl.change()
3368    })
3369    $dialog.find('input[name="is_use"]').on('change', function () {
3370      if ($(this).prop('checked')) {
3371        $sms_config_wrap.show()
3372        $form.find('#admin_receive_callnum_wrap').show()
3373        $form.find('#sender_callnum_wrap').show()
3374        $form.find('#use_type_wrap').show()
3375        if ($dialog.find('input[name="use_type"]:checked').val() === 'kakao_alimtalk') {
3376          $form.find('#kakao_alimtalk_wrap').show()
3377        }
3378      } else {
3379        $sms_config_wrap.hide()
3380        $form.find('#admin_receive_callnum_wrap').hide()
3381        $form.find('#sender_callnum_wrap').hide()
3382        $form.find('#kakao_alimtalk_wrap').hide()
3383        $form.find('#use_type_wrap').hide()
3384      }
3385    })
3386
3387    $dialog.find('input[name="use_type"]').on('change', function () {
3388      if ($(this).val() === 'kakao_alimtalk') {
3389        $form.find('#kakao_alimtalk_wrap').show()
3390      } else {
3391        $form.find('#kakao_alimtalk_wrap').hide()
3392      }
3393    })
3394
3395    $dialog.find('._message_credit_auto_charge_enabled').off('click').on('click', function (event) {
3396      const $checkbox = $(this)
3397      const hasConfig = $form.find('#message_credit_auto_charge_wrap').attr('data-has-auto-charge-config') === 'Y'
3398      const previousChecked = !$checkbox.prop('checked')
3399      const rollbackCheckbox = function () {
3400        event.preventDefault()
3401        $checkbox.prop('checked', previousChecked)
3402      }
3403
3404      if (!guardMessageCreditAutoChargeAction()) {
3405        rollbackCheckbox()
3406        return false
3407      }
3408
3409      if (!hasConfig) {
3410        rollbackCheckbox()
3411        window.dispatchEvent(new CustomEvent('open:auto-charge'))
3412        return false
3413      }
3414
3415      header_ctl.change()
3416    })
3417
3418    $dialog.find('._message_credit_auto_charge_setting').off('click').on('click', function () {
3419      if (!guardMessageCreditAutoChargeAction()) {
3420        return false
3421      }
3422
3423      window.dispatchEvent(new CustomEvent('open:auto-charge'))
3424      return false
3425    })
3426
3427    window.removeEventListener('success:charge', refreshMessageCreditCount)
3428    window.removeEventListener('success:auto-charge-config', refreshMessageCreditAutoChargeConfig)
3429    window.addEventListener('success:charge', refreshMessageCreditCount)
3430    window.addEventListener('success:auto-charge-config', refreshMessageCreditAutoChargeConfig)
3431  }
3432
3433  const guardMessageCreditAutoChargeAction = function () {
3434    const messageCreditChargeConfig = window.messageCreditChargeConfig || {}
3435
3436    // **비용 축만** — 자동충전 **설정**은 발송도, 문구 작성도 아니다(sms_setting_update.cm 과 같은 판단).
3437    if (isSiteOutboundBlockedByCost()) {
3438      return showSiteOutboundGuide()
3439    }
3440
3441    if (messageCreditChargeConfig.isFreeSite) {
3442      if (confirm(LOCALIZE_ADMIN.설명_업그레이드페이지로이동할까요())) {
3443        location.href = '/admin/payment?mode=detail&type=upgrade'
3444      }
3445      return false
3446    }
3447
3448    if (!messageCreditChargeConfig.canCharge) {
3449      alert(LOCALIZE_ADMIN.설명_권한이없습니다())
3450      return false
3451    }
3452
3453    return true
3454  }
3455
3456  const applyMessageCreditAutoChargeConfig = function (config) {
3457    const $wrap = $form.find('#message_credit_auto_charge_wrap')
3458    if ($wrap.length === 0) {
3459      return
3460    }
3461
3462    const hasConfig = !!config.hasAutoChargeConfig
3463    const thresholdCount = parseInt(config.thresholdCount, 10) || 0
3464    const chargeAmount = parseInt(config.chargeAmount, 10) || 0
3465    const $checkbox = $wrap.find('._message_credit_auto_charge_enabled')
3466    const nextChecked = !!config.isEnabled
3467
3468    $wrap.attr('data-has-auto-charge-config', hasConfig ? 'Y' : 'N')
3469    $checkbox.prop('checked', nextChecked)
3470    if (hasConfig) {
3471      $checkbox.attr({ name: 'auto_charge_is_enabled', value: 'ok' })
3472    } else {
3473      $checkbox.removeAttr('name').removeAttr('value')
3474    }
3475
3476    if (hasConfig) {
3477      let chargeAmountText = ''
3478      if (chargeAmount) {
3479        chargeAmountText = chargeAmount % 10000 === 0
3480          ? (chargeAmount / 10000).toLocaleString() + '만원'
3481          : chargeAmount.toLocaleString() + '원'
3482      }
3483
3484      $wrap.find('._message_credit_auto_charge_summary_text').empty()
3485        .append($('<strong />').text(thresholdCount.toLocaleString() + ' 크레딧'))
3486        .append(' 남았을 때 ')
3487        .append($('<strong />').text(chargeAmountText))
3488        .append('씩 자동 충전')
3489      $wrap.find('._message_credit_auto_charge_summary').show()
3490    } else {
3491      $wrap.find('._message_credit_auto_charge_summary').hide()
3492    }
3493  }
3494
3495  const refreshMessageCreditAutoChargeConfig = function () {
3496    $.ajax({
3497      type: 'GET',
3498      url: ('/admin/ajax/member/message_credit_auto_charge_config.cm'),
3499      dataType: 'json',
3500      cache: false,
3501      success: function (res) {
3502        if (res.msg === 'SUCCESS' && res.data) {
3503          applyMessageCreditAutoChargeConfig(res.data)
3504        }
3505      }
3506    })
3507  }
3508
3509  const refreshMessageCreditCount = function () {
3510    adminMember.refreshMessageCreditCount($form)
3511  }
3512
3513  /***
3514	 * 문자발신번호 인증
3515	 */
3516  function setSmsCertification () {
3517    $certification_sender_number = $('#certification_sender_number')
3518    setSmsCertificationLayout()
3519  }
3520
3521  /***
3522	 * 문자 발신번호 인증 레이아웃 구성
3523	 */
3524  function setSmsCertificationLayout () {
3525    const $cert_save_btn = $certification_sender_number.find('._cert_save_btn')
3526    const $document_from = $certification_sender_number.find('._document_from')
3527    const $person_type_p = $document_from.find("select[name='person_type_p']")
3528    const $person_type_b = $document_from.find("select[name='person_type_b']")
3529    let person_type = 'unset'
3530    const applicant_name = $document_from.find("input[name='applicant_name']").val()
3531    const applicant_callnum = $document_from.find("input[name='applicant_callnum']").val()
3532    const applicant_birthday = $document_from.find("input[name='applicant_birthday']").val()
3533    const applicant_encrypt_ci = $document_from.find("input[name='applicant_encrypt_ci']").val();
3534    let business_type = 'personal'
3535    const $sender_callnum_document = $document_from.find("input[name='sender_callnum']")
3536	  const $sender_callnum_info_document = $document_from.find("input[name='sender_callnum_info']")
3537    const $cert_document_comment = $document_from.find("textarea[name='cert_document_comment']")
3538    const $sender_callnum = $document_from.find("input[name='sender_callnum']")
3539	  const $sender_callnum_info = $document_from.find("input[name='sender_callnum_info']")
3540    const $use_owner_callnum_form = $document_from.find('._use_owner_callnum_form')
3541    const owner_callnum = $sender_callnum.attr('data-owner-callnum')
3542	  const owner_name = $sender_callnum_info.attr('data-owner-name')
3543    const can_use_owner_callnum = $use_owner_callnum_form.length > 0
3544    let use_owner_callnum = false
3545
3546    /* 발신번호 - 제외 숫자만 입력하도록 제한 (number 타입의 경우 스피너 UI가 생기므로 스크립트로 처리 */
3547    $sender_callnum.check_key({
3548      digit: true,
3549      upper: false,
3550      lower: false
3551    })
3552
3553    /* 소유자가 아니면 소유자 휴대전화 번호 사용 불가 */
3554    if (can_use_owner_callnum) {
3555      use_owner_callnum = (person_type === 'p_owner' || person_type === 'b_owner') && ($sender_callnum.val() && owner_callnum && $sender_callnum.val() == owner_callnum)
3556    }
3557
3558    setPersonTypeLayout(business_type, person_type, use_owner_callnum)
3559
3560    $certification_sender_number.find("input[name='business_type']").click(function () {
3561      business_type = this.value
3562      person_type = 'unset'
3563      use_owner_callnum = false
3564      setPersonTypeLayout(business_type, person_type, use_owner_callnum)
3565    })
3566
3567    $person_type_p.change(function () {
3568      person_type = this.value
3569      if (can_use_owner_callnum) {
3570        use_owner_callnum = (person_type === 'p_owner' || person_type === 'b_owner') && ($sender_callnum.val() && owner_callnum && $sender_callnum.val() == owner_callnum)
3571      }
3572      setPersonTypeLayout(business_type, person_type, use_owner_callnum)
3573    })
3574
3575    $person_type_b.change(function () {
3576      person_type = this.value
3577      if (can_use_owner_callnum) {
3578        use_owner_callnum = (person_type === 'p_owner' || person_type === 'b_owner') && ($sender_callnum.val() && owner_callnum && $sender_callnum.val() == owner_callnum)
3579      }
3580      setPersonTypeLayout(business_type, person_type, use_owner_callnum)
3581    })
3582
3583    $sender_callnum.change(function () {
3584      if (person_type === 'p_owner' || person_type === 'b_owner') {
3585        if (can_use_owner_callnum) {
3586          use_owner_callnum = this.value && this.value == owner_callnum
3587	        $sender_callnum_info_document.val(use_owner_callnum ? owner_name : '')
3588        }
3589        setPersonTypeLayout(business_type, person_type, use_owner_callnum)
3590      }
3591    })
3592    if (can_use_owner_callnum) {
3593      $document_from.find("input[name='use_owner_callnum']").click(function () {
3594        if (person_type === 'p_owner' || person_type === 'b_owner') {
3595          use_owner_callnum = this.checked
3596          $sender_callnum.val(use_owner_callnum ? owner_callnum : '')
3597	        ////$sender_callnum_info_document 인풋박스의 내용을 소유주 이름으로 설정
3598	        $sender_callnum_info_document.val(use_owner_callnum ? owner_name : '')
3599          setPersonTypeLayout(business_type, person_type, use_owner_callnum)
3600        }
3601      })
3602    }
3603
3604    for (let file_num = 1; file_num <= MAX_CERT_DOCUMENT_NUMBER; file_num++) {
3605      const attach_upload_text = $certification_sender_number.find(`._attach${file_num}_upload_text`)
3606      $certification_sender_number.find(`._attach${file_num}_upload`).fileupload({
3607        url: '/admin/ajax/upload_file.cm',
3608        formData: { temp: 'Y' },
3609        dataType: 'json',
3610        target: 'sms_config',
3611        singleFileUploads: true,
3612        limitMultiFileUploads: 1,
3613        dropZone: false,
3614        acceptFileTypes: /(\.|\/)(gif|jpe?g|png|pdf|zip)$/i,
3615        start: function (e, data) {
3616
3617        },
3618        progress: function (e, data) {
3619
3620        },
3621        done: function (e, data) {
3622          $.each(data.result.files, function (e, tmp) {
3623            if (tmp.error == null) {
3624              if (file_num === 1) {
3625                /* 통신 서비스 이용증명원 등록 시 유효성 체크 (통신판매업 신고증 혼동 방지) */
3626                if (tmp.org_name.indexOf('판매업') > -1 || tmp.org_name.indexOf('신고증') > -1) {
3627                  alert('통신사에서 1개월 이내 발급받은 통신 서비스 이용증명원을 등록해 주세요.')
3628                  return false
3629                }
3630              }
3631              $certification_sender_number.find(`input[name='cert_document_attach${file_num}']`).val(tmp.tmp_idx)
3632              attach_upload_text.find('._file_unset').addClass('tw-hidden')
3633              /* @TODO GetFileSize 형태 통일 */
3634              attach_upload_text.find('._file_size').text(`(${GetFileSize(tmp.size)}) `)
3635              attach_upload_text.find('._file_name').text(tmp.org_name)
3636              attach_upload_text.find('._file_download').removeClass('tw-hidden')
3637              attach_upload_text.find('._file_remove').removeClass('tw-hidden')
3638            } else {
3639              alert(tmp.error)
3640            }
3641          })
3642        },
3643        fail: function (e, data) {
3644          alert(getLocalizeString('설명_업로드에실패하였습니다', '', '업로드에 실패 하였습니다.'))
3645        }
3646      })
3647
3648      attach_upload_text.find('._file_remove').on('click', function () {
3649        $certification_sender_number.find(`input[name='cert_document_attach${file_num}']`).val('')
3650        attach_upload_text.find('._file_unset').removeClass('tw-hidden')
3651        attach_upload_text.find('._file_size').text('')
3652        attach_upload_text.find('._file_name').text('')
3653        attach_upload_text.find('._file_download').attr('href', '').addClass('tw-hidden')
3654        attach_upload_text.find('._file_remove').addClass('tw-hidden')
3655      })
3656    }
3657
3658    // 발신번호 등록 이벤트
3659    $cert_save_btn.off('click.cert_save_btn').on('click.cert_save_btn', function () {
3660      const person_type = business_type === 'personal' ? $person_type_p.val() : $person_type_b.val()
3661      const sender_callnum = $sender_callnum_document.val()
3662	    const sender_callnum_info = $sender_callnum_info_document.val()
3663      const cert_document_comment = $cert_document_comment.val()
3664      const $terms_agree = $certification_sender_number.find('input[name="terms_agree"]')
3665      if (person_type === 'unset') {
3666        alert('발신번호 명의자를 선택해 주세요.')
3667        if (business_type === 'personal') {
3668          $person_type_p.focus()
3669        } else {
3670          $person_type_b.focus()
3671        }
3672        return false
3673      }
3674      if (sender_callnum === '') {
3675        alert(getLocalizeString('설명_발신번호입력', '', '발신번호를 입력해주세요'))
3676        $sender_callnum_document.focus()
3677        return false
3678      }
3679			if (sender_callnum_info === '') {
3680				alert('발신번호 명의자 정보를 입력해주세요')
3681				$sender_callnum_info_document.focus()
3682				return false
3683			}
3684      if (cert_document_comment === '') {
3685        alert(getLocalizeString('설명_요청사유입력', '', '요청사유를 입력해주세요'))
3686        $cert_document_comment.focus()
3687        return false
3688      }
3689      const ajax_data = { type: 'DOCUMENT', person_type, sender_callnum, sender_callnum_info, cert_document_comment }
3690      if (!use_owner_callnum) {
3691        for (let order = 0; order < DOCUMENT_REQUIREMENT_ORDER.length; order++) {
3692          const file_num = DOCUMENT_REQUIREMENT_ORDER[order]
3693          const $cert_document_attach = $certification_sender_number.find(`input[name='cert_document_attach${file_num}']`)
3694          const cert_document_attach = $cert_document_attach.val()
3695          if (file_num === 10) { // 추가 서류는 항상 포함되지만 필수는 아님
3696            ajax_data[`cert_document_attach${file_num}`] = cert_document_attach
3697          } else if (DOCUMENT_REQUIREMENT[business_type][person_type].includes(file_num) || (file_num === 11 && $cert_document_attach.length > 0)) {
3698            /* 필수 제출 서류 체크 (관리 위임장은 노출되는 케이스일 때 필수 체크 */
3699            const cert_document_attach_name = $certification_sender_number.find(`._attach${file_num}_form > label`).attr('data-title')
3700            if (cert_document_attach === '') {
3701              alert(`${cert_document_attach_name}을 첨부해주세요.`)
3702              $cert_document_attach.focus()
3703              return false
3704            }
3705            ajax_data[`cert_document_attach${file_num}`] = cert_document_attach
3706          }
3707        }
3708      }
3709
3710      if (!$terms_agree.prop('checked')) {
3711        alert('개인정보 수집∙이용에 동의해 주세요.')
3712        $terms_agree.focus()
3713        return false
3714      }
3715      ajax_data.applicant_name = applicant_name
3716      ajax_data.applicant_callnum = applicant_callnum
3717      ajax_data.applicant_birthday = applicant_birthday
3718      ajax_data.applicant_encrypt_ci = applicant_encrypt_ci
3719
3720      const saveSenderNumber = function (auth_token) {
3721        const request_data = Object.assign({}, ajax_data)
3722        if (typeof auth_token === 'string' && auth_token !== '') request_data.two_factor_auth_token = auth_token
3723        $.ajax({
3724          type: 'POST',
3725          data: request_data,
3726          url: ('/admin/ajax/member/sender_number_certification_update.cm'),
3727          dataType: 'json',
3728          async: true,
3729          cache: false,
3730          success: function (res) {
3731            if (ADMIN_MESSAGE_STEP_UP.handleSendResponse(res, saveSenderNumber)) return
3732            if (res.msg === 'SUCCESS') {
3733              $form.find('#sender_callnum').text(res.sender_callnum || '')
3734              if (res.status === 'complete') {
3735                $form.find('#sender_status').html(`<span class='text-success'><i style='vertical-align: -10%; margin-left: 8px;' class='icon-check icons'></i> ${getLocalizeString('설명_인증완료', '', '인증 완료')}</span>`)
3736                alert('발신번호 등록이 완료되었습니다.')
3737              } else {
3738                $form.find('#sender_status').html(`<span class='text-warning'><i style='vertical-align: -10%; margin-left: 8px;' class='icon-check icons'></i> ${getLocalizeString('설명_서류인증대기중', '', '서류 인증 대기중')}</span>`)
3739                alert('발신번호 서류 인증신청이 완료되었습니다. 3 영업일 이내에 처리될 예정이며 처리가 완료되기 전까진 SMS 발송이 제한됩니다.')
3740              }
3741              $form.find('._failure_description').remove()
3742              $.cocoaDialog.close()
3743            } else {
3744              alert(res.msg)
3745            }
3746          }
3747        })
3748      }
3749      saveSenderNumber('')
3750    })
3751  }
3752
3753  /**
3754   * 발신번호 서류 인증 요청 폼 레이아웃 설정
3755   * @param b_type  사업자 여부 (personal, business)
3756   * @param p_type  발신번호 명의자
3757   * @param use_owner_callnum  소유자 휴대전화번호 사용 여부 (true, false)
3758   */
3759  function setPersonTypeLayout (b_type = 'personal', p_type = 'unset', use_owner_callnum = false) {
3760    /* 사업자 여부 설정 */
3761    $certification_sender_number.find(`input[name='business_type']:not([value='${b_type}'])`).prop('checked', false)
3762    $certification_sender_number.find(`input[name='business_type'][value='${b_type}']`).prop('checked', true)
3763
3764    /* 발신번호 명의자 설정 */
3765    const $document_from = $certification_sender_number.find('._document_from')
3766    const $person_type_p = $document_from.find("select[name='person_type_p']")
3767    const $person_type_b = $document_from.find("select[name='person_type_b']")
3768    if (b_type == 'personal') {
3769      $person_type_p.removeClass('tw-hidden')
3770      $person_type_b.addClass('tw-hidden')
3771      $person_type_b.val('unset')
3772    } else {
3773      $person_type_b.removeClass('tw-hidden')
3774      $person_type_p.addClass('tw-hidden')
3775      $person_type_p.val('unset')
3776    }
3777
3778    $certification_sender_number.find('._attach_file_form:not(._attach11_form)').addClass('tw-hidden md:tw-hidden')
3779    $certification_sender_number.find('._example').removeClass('_lg_target')
3780    $certification_sender_number.find('._attach_file_form._attach11_form ._example').addClass('_lg_target') // 관리 위임장이 존재하는 케이스는 항상 노출
3781    $certification_sender_number.find('._attach10_form').removeClass('tw-hidden md:tw-hidden') // 추가서류는 항상 노출
3782
3783    /* 소유자 휴대전화번호 사용 설정 */
3784    const $sender_callnum = $document_from.find("input[name='sender_callnum']")
3785    const $use_owner_callnum_form = $document_from.find('._use_owner_callnum_form')
3786    const $use_owner_callnum = $document_from.find("input[name='use_owner_callnum']")
3787    const $required_document_section = $document_from.find('#required_document_section')
3788	  const $sender_callnum_info_document = $document_from.find("input[name='sender_callnum_info']")
3789    $required_document_section.removeClass('tw-hidden')
3790	  $sender_callnum_info_document.prop('disabled', false)
3791    let attach3_title = '위임자(타인) 신분증 사본'
3792    let attach8_title = '자사 담당자 재직증명서'
3793    if (p_type === 'p_owner') {
3794      $use_owner_callnum_form.removeClass('tw-hidden')
3795      $sender_callnum.prop('disabled', use_owner_callnum)
3796      $use_owner_callnum.prop('checked', use_owner_callnum)
3797      if (use_owner_callnum) {
3798        /* 소유자 휴대전화번호 사용 시 제출 서류 영역 숨김 */
3799        $required_document_section.addClass('tw-hidden')
3800	      //$sender_callnum_info_document 인풋박스 비활성화
3801	      $sender_callnum_info_document.prop('disabled', true)
3802        return
3803      }
3804    } else {
3805      if (p_type === 'p_other_company' || p_type === 'b_other_company') {
3806        attach3_title = '위임자(타사) 담당자 신분증 사본'
3807      }
3808      if (p_type === 'b_first_incumbent') {
3809        attach8_title = '재직증명서'
3810      }
3811      $sender_callnum.prop('disabled', false)
3812      $use_owner_callnum.prop('checked', false)
3813      $use_owner_callnum_form.addClass('tw-hidden')
3814    }
3815    $required_document_section.find('._attach3_form > label').attr('data-title', attach3_title).find('> span').text(attach3_title)
3816    $required_document_section.find('._attach3_form > ._example').attr('data-sub-html', attach3_title + ' 예시')
3817    $required_document_section.find('._attach8_form > label').attr('data-title', attach8_title).find('> span').text(attach8_title)
3818    $required_document_section.find('._attach8_form > ._example').attr('data-sub-html', attach8_title + ' 예시')
3819
3820    if (p_type === 'unset') {
3821      $required_document_section.addClass('tw-hidden')
3822      return
3823    }
3824
3825    /* 제출 서류 설정 */
3826    for (const person_type in DOCUMENT_REQUIREMENT[b_type]) {
3827      if (person_type === p_type) {
3828        for (const document_num in DOCUMENT_REQUIREMENT[b_type][person_type]) {
3829          $certification_sender_number.find(`._attach${DOCUMENT_REQUIREMENT[b_type][person_type][document_num]}_form`).removeClass('tw-hidden md:tw-hidden').find('._example').addClass('_lg_target')
3830        }
3831        break
3832      }
3833    }
3834
3835    /* 서류 미리보기 설정 */
3836    const lg_data = $document_from.data('lightGallery')
3837    if (typeof lg_data !== 'undefined') lg_data.destroy(true)
3838    const $lg_target = $document_from.find('._lg_target')
3839    if ($lg_target.length > 0) {
3840
3841      $document_from.lightGallery({
3842        selector: '._lg_target',
3843        thumbnail: true,
3844        hash: false,
3845        speed: 200
3846      });
3847    }
3848  }
3849
3850  const openEditInformation = function () {
3851    if (confirm('발신번호를 등록하기 위해서는 휴대전화 본인인증이 필요합니다. 진행하시겠습니까?')) {
3852      const url = TEST_SERVER ? 'https://imtest.me/edit_information' : 'https://imweb.me/edit_information'
3853      window.open(url, '_blank')
3854    }
3855  }
3856
3857  const openAuthService = function (CI_SVCID, Tradeid, Okurl, call_back_data) {
3858    if (confirm('발신번호를 등록하기 위해서는 휴대전화 본인인증이 필요합니다. 진행하시겠습니까?')) {
3859      AUTH_SERVICE.openMOBILIANSWindow(CI_SVCID, Tradeid, Okurl, call_back_data)
3860      const messageEvent = function (e) {
3861        if (e.data.authorizedData) {
3862          if ( e.data.authorizedData.name &&
3863               e.data.authorizedData.call_num &&
3864               e.data.authorizedData.birthday &&
3865               e.data.authorizedData.encrypt_ci
3866              ) {
3867            window.removeEventListener('message', messageEvent)
3868            openSmsSettingNum(
3869				e.data.authorizedData.name,
3870	            e.data.authorizedData.call_num,
3871	            e.data.authorizedData.birthday,
3872	            e.data.authorizedData.encrypt_ci
3873            )
3874          }
3875        }
3876      }
3877      window.removeEventListener('message', messageEvent)
3878      window.addEventListener('message', messageEvent, false)
3879    }
3880  }
3881
3882  const openAuthServiceFromBo = function () {
3883    if (confirm('발신번호를 등록하기 위해서는 휴대전화 본인인증이 필요합니다. 진행하시겠습니까?')) {
3884      AUTH_SERVICE.openMOBILIANSWindowFromBo()
3885      const messageEvent = function (e) {
3886        if (e.data.authorizedData) {
3887          if ( e.data.authorizedData.name &&
3888              e.data.authorizedData.call_num &&
3889              e.data.authorizedData.birthday &&
3890              e.data.authorizedData.encrypt_ci
3891          ) {
3892            window.removeEventListener('message', messageEvent)
3893            openSmsSettingNum(
3894                e.data.authorizedData.name,
3895                e.data.authorizedData.call_num,
3896                e.data.authorizedData.birthday,
3897                e.data.authorizedData.encrypt_ci
3898            )
3899          }
3900        }
3901      }
3902      window.removeEventListener('message', messageEvent)
3903      window.addEventListener('message', messageEvent, false)
3904    }
3905  }
3906
3907  const group_chang = function (type, $obj, name, code) {
3908    let old_text, new_text
3909
3910    old_text = $obj.find('span').text()
3911    $obj.find('span').text(name)
3912    new_text = $obj.find('span').text()
3913
3914    if (name == '지정안함') {
3915      $('#group_' + type).val('')
3916      $('#group_' + type + '_name').val('')
3917    } else {
3918      $('#group_' + type).val(code)
3919      $('#group_' + type + '_name').val(name)
3920    }
3921
3922    if (old_text != new_text) {
3923      header_ctl.change()
3924    }
3925  }
3926
3927  var submit = function () {
3928    const data = $form.serializeObject();
3929
3930    // 메일・SMS 전용 사이트 이름 바이트 체크, 40바이트 보다 큰 경우 저장 불가 처리
3931    const currentSubNameByte = getByteLengthForKorean(data.sub_name);
3932    const maxSubNameByte = 40;
3933    if (currentSubNameByte > maxSubNameByte) {
3934      return window.alert(`메일・SMS 전용 사이트 이름은 ${maxSubNameByte}바이트를 초과할 수 없습니다(현재: ${currentSubNameByte}바이트).`);
3935    }
3936
3937    $.ajax({
3938      type: 'POST',
3939      data,
3940      url: ('/admin/ajax/member/sms_setting_update.cm'),
3941      dataType: 'json',
3942      async: false,
3943      cache: false,
3944      success: function (res) {
3945        if (res.msg == 'SUCCESS') {
3946          header_ctl.save()
3947        } else {
3948          alert(res.msg)
3949        }
3950      }
3951    })
3952  }
3953
3954  const openSmsPreview = function (type) {
3955    $.ajax({
3956      type: 'GET',
3957      data: { type },
3958      url: ('/admin/ajax/member/sms_preview.cm'),
3959      dataType: 'html',
3960      async: false,
3961      cache: true,
3962      success: function (res) {
3963        const $html = $(res)
3964        $.cocoaDialog.open({ type: 'admin', custom_popup: $html, width: 600 })
3965      }
3966    })
3967  }
3968
3969  var openSmsSettingNum = function (name, callnum, birthday, encrypt_ci) {
3970    $.cocoaDialog.close()
3971    $.ajax({
3972      type: 'POST',
3973      data: { name, callnum, birthday, encrypt_ci },
3974      url: ('/admin/ajax/member/sms_setting_num.cm'),
3975      dataType: 'html',
3976      async: false,
3977      cache: false,
3978      success: function (res) {
3979				if(res === 'alimtalk progress'){
3980					alert("카카오 알림톡 신청 중 발신번호 변경이 불가합니다.\n알림톡 신청이 완료되거나 반려 처리된 후 발신번호 변경을 진행해주세요.")
3981				}else{
3982					const $html = $(res)
3983					$.cocoaDialog.open({ type: 'admin_sms_num', custom_popup: $html, pc_width: 660, close_block: true })
3984				}
3985      }
3986    })
3987
3988    setSmsCertification()
3989  }
3990
3991  const openSmsChargeComplete = function (sms_count, order_no) {
3992    $.ajax({
3993      type: 'POST',
3994      data: { sms_count, order_no },
3995      url: ('/admin/ajax/sms/sms_charge_complete.cm'),
3996      dataType: 'html',
3997      async: false,
3998      cache: false,
3999      success: function (html) {
4000        $.cocoaDialog.open({ type: 'admin_sms', custom_popup: html })
4001      }
4002    })
4003  }
4004
4005  const requestChangeKakaoIdModal = function () {
4006    $.ajax({
4007      type: 'POST',
4008      url: '/admin/ajax/open_change_kakao_id_modal.cm',
4009      dataType: 'html',
4010      async: false,
4011      cache: false,
4012      success: function (res) {
4013        $.cocoaDialog.open({ type: 'admin_sms', custom_popup: res })
4014      }
4015    })
4016  }
4017
4018  const requestChangeKakaoId = function () {
4019    const data = $('#request_form_change_kakao_id').serializeObject()
4020    $.ajax({
4021      type: 'POST',
4022      data: { data },
4023      url: '/admin/ajax/sms/request_change_kakao_id.cm',
4024      dataType: 'json',
4025      async: false,
4026      cache: false,
4027      success: function (res) {
4028        if (res.msg === 'SUCCESS') {
4029          // header_ctl.save(); // 저장시 새로고침 컨펌 안뜨게 하기 위해
4030          alert('카카오 채널 검색용 아이디가 변경되었습니다.')
4031          location.reload()
4032        } else {
4033          alert('저장에 실패하였습니다. 다시한번 시도해주세요')
4034        }
4035      }
4036    })
4037  }
4038
4039  const openRequestKakaoAlimTalk = function (idx) {
4040    $.ajax({
4041      type: 'POST',
4042      data: { idx },
4043      url: ('/admin/ajax/sms/open_kakao_alimtalk_request.cm'),
4044      dataType: 'html',
4045      async: false,
4046      cache: false,
4047      success: function (html) {
4048        $.cocoaDialog.open({ type: 'admin_sms', custom_popup: html })
4049      }
4050    })
4051  }
4052
4053  const requestKakaoAlimTalk = function () {
4054    const data = $('#request_form_kakao_alimtalk').serializeObject()
4055    $.ajax({
4056      type: 'POST',
4057      data: { data },
4058      url: ('/admin/ajax/sms/request_kakao_alimtalk.cm'),
4059      dataType: 'json',
4060      async: false,
4061      cache: false,
4062      success: function (res) {
4063        if (res.msg === 'SUCCESS') {
4064          header_ctl.save() // 저장시 새로고침 컨펌 안뜨게 하기 위해
4065          location.reload()
4066        } else {
4067          alert(res.msg)
4068        }
4069      }
4070    })
4071  }
4072
4073  return {
4074    init: function ($obj) {
4075      init($obj)
4076    },
4077    group_chang: function (type, $obj, name, code) {
4078      group_chang(type, $obj, name, code)
4079    },
4080    openSmsPreview: function (type) {
4081      openSmsPreview(type)
4082    },
4083    openSmsSettingNum: function (name, callnum, birthday, encrypt_ci) {
4084      openSmsSettingNum(name, callnum, birthday, encrypt_ci)
4085    },
4086    openEditInformation: function () {
4087      openEditInformation()
4088    },
4089    openAuthService: function (CI_SVCID, Tradeid, Okurl, call_back_data) {
4090      openAuthService(CI_SVCID, Tradeid, Okurl, call_back_data)
4091    },
4092    openAuthServiceFromBo: function () {
4093      openAuthServiceFromBo()
4094    },
4095    openSmsChargeComplete: function (sms_count, order_no) {
4096      openSmsChargeComplete(sms_count, order_no)
4097    },
4098    openRequestKakaoAlimTalk: function (idx) {
4099      openRequestKakaoAlimTalk(idx)
4100    },
4101    requestChangeKakaoId: function () {
4102      requestChangeKakaoId()
4103    },
4104    requestChangeKakaoIdModal: function () {
4105      requestChangeKakaoIdModal()
4106    },
4107    requestKakaoAlimTalk: function () {
4108      requestKakaoAlimTalk()
4109    }
4110  }
4111}())
4112const MEMBER_BLOCK = (function () {
4113  const openBlockMember = function (member_code, block_member_code, site_code) {
4114    $.ajax({
4115      type: 'post',
4116      data: {
4117        member_code,
4118        block_member_code,
4119        site_code
4120      },
4121      url: '/admin/ajax/member/delete_block_member.cm',
4122      dataType: 'html',
4123      async: false,
4124      cache: false,
4125      success: function (html) {
4126        $.cocoaDialog.open({ type: 'admin', custom_popup: html })
4127      }
4128    })
4129  }
4130  const deleteBlock = function (member_code, block_member_code, site_code) {
4131    $.ajax({
4132      type: 'post',
4133      data: {
4134        member_code,
4135        block_member_code,
4136        site_code
4137      },
4138      url: '/admin/ajax/member/delete_block.cm',
4139      dataType: 'json',
4140      async: false,
4141      cache: false,
4142      success: function (res) {
4143        if (res.msg === 'SUCCESS') {
4144          // header_ctl.save(); // 저장시 새로고침 컨펌 안뜨게 하기 위해
4145          location.reload()
4146        } else {
4147          alert(res.msg)
4148        }
4149      }
4150    })
4151  }
4152  return {
4153    openBlockMember: function (member_code, block_member_code, site_code) {
4154      openBlockMember(member_code, block_member_code, site_code)
4155    },
4156    deleteBlock: function (member_code, block_member_code, site_code) {
4157      deleteBlock(member_code, block_member_code, site_code)
4158    }
4159  }
4160}())
4161
4162/**
4163 * 회원 목록과 상세 화면에서 공통으로 사용하는 인라인 회원태그 combobox입니다.
4164 *
4165 * 목록에서는 회원 데이터 렌더링 후 태그 API를 지연 호출하고, 상세에서는 SSR로 전달된
4166 * 초기 데이터를 hydrate합니다. 선택·해제·생성·수정·삭제는 저장 버튼 없이 즉시 반영하며,
4167 * 서버 요청 실패 시 마지막 확정 상태로 복구합니다.
4168 */
4169var MEMBER_TAG_MANAGE = (function () {
4170  // 사이트에 등록된 태그 정의와 조회 완료 여부를 모든 회원 필드가 공유합니다.
4171  let definitions = []
4172  let definitionsLoaded = false
4173  let definitionRequest = null
4174
4175  // 회원 코드별 선택 태그를 보관하며, 목록의 동일 회원 필드가 여러 개여도 함께 갱신합니다.
4176  const memberTagMap = {}
4177
4178  // 한 번에 하나의 combobox만 열리도록 현재 anchor와 편집 상태를 단일 객체로 관리합니다.
4179  let activeState = null
4180
4181  /**
4182   * 회원태그 전용 스타일을 문서에 한 번만 주입합니다.
4183   * 목록 AJAX 렌더링과 상세 SSR 진입 순서가 달라도 중복 style 노드가 생기지 않습니다.
4184   */
4185  const ensureStyles = function () {
4186    if ($('#member-tag-manage-style').length > 0) return
4187
4188    $('<style id="member-tag-manage-style">' +
4189      '.member-tag-field{display:flex;width:100%;min-height:32px;align-items:center;align-content:center;flex-wrap:wrap;gap:4px;padding:3px 4px;border-radius:3px;background:transparent;cursor:text;outline:0;}' +
4190      '.member-tag-field:hover{background:#f7f8f9;}' +
4191      '.member-tag-field.is-active{background:#fff;box-shadow:inset 0 0 0 1px #aeb3ba;}' +
4192      '.member-tag-field-readonly{cursor:default;}' +
4193      '.member-tag-field:not(.member-tag-field-list):not(.member-tag-field-readonly){cursor:pointer;transition:background-color .12s,box-shadow .12s;}' +
4194      '.member-tag-field:not(.member-tag-field-list):not(.member-tag-field-readonly):hover,.member-tag-field:not(.member-tag-field-list):not(.member-tag-field-readonly):focus,.member-tag-field:not(.member-tag-field-list):not(.member-tag-field-readonly):focus-within{background:#eef0f2;}' +
4195      '.member-tag-field:not(.member-tag-field-list):not(.member-tag-field-readonly).is-active,.member-tag-field:not(.member-tag-field-list):not(.member-tag-field-readonly).is-active:hover,.member-tag-field:not(.member-tag-field-list):not(.member-tag-field-readonly).is-active:focus,.member-tag-field:not(.member-tag-field-list):not(.member-tag-field-readonly).is-active:focus-within{background:#fff;box-shadow:inset 0 0 0 1px #aeb3ba;cursor:text;}' +
4196      '.member-tag-field-list{min-height:40px;cursor:pointer;transition:background-color .12s,box-shadow .12s;}' +
4197      '.member-tag-field-list:not(.member-tag-field-readonly):hover,.member-tag-field-list:not(.member-tag-field-readonly):focus,.member-tag-field-list:not(.member-tag-field-readonly):focus-within{background:#eef0f2;}' +
4198      '.member-tag-field-list.is-active,.member-tag-field-list.is-active:hover,.member-tag-field-list.is-active:focus,.member-tag-field-list.is-active:focus-within{background:#fff;box-shadow:inset 0 0 0 1px #aeb3ba;cursor:text;}' +
4199      '.member-tag-chip{display:inline-flex;max-width:100%;height:24px;align-items:center;padding:0 6px;border-radius:3px;background:#f1f1ef;color:#37352f;font-size:12px;line-height:24px;white-space:nowrap;}
4199' +
4200      '.member-tag-chip-name{max-width:110px;overflow:hidden;text-overflow:ellipsis;}' +
4201      '.member-tag-field-list .member-tag-chip-name{max-width:none;overflow:visible;text-overflow:clip;}' +
4202      '.member-tag-query-input{min-width:22px;max-width:100%;height:24px;flex:1;padding:0 2px;border:0;background:transparent;color:#37352f;font-size:13px;line-height:24px;outline:0;}' +
4203      '.member-tag-popover{position:fixed;z-index:10050;width:340px;max-width:calc(100vw - 16px);border:1px solid #dfe1e5;border-radius:6px;background:#fff;box-shadow:0 4px 14px rgba(0,0,0,.12);overflow:hidden;}' +
4204      '.member-tag-popover-guide{padding:10px 12px 7px;color:#8b9098;font-size:11px;line-height:16px;}' +
4205      '.member-tag-query-error{padding:0 12px 7px;color:#f44336;font-size:11px;line-height:16px;}' +
4206      '.member-tag-option-list{max-height:240px;overflow-y:auto;padding:2px 0 5px;}' +
4207      '.member-tag-option-item{display:flex;min-height:38px;align-items:center;padding:0 10px;color:#37352f;cursor:pointer;}' +
4208      '.member-tag-option-item:hover,.member-tag-option-item.is-active{background:#f7f8f9;}' +
4209      '.member-tag-option-check{width:18px;flex:0 0 18px;margin-right:6px;visibility:hidden;color:inherit;font-size:13px;text-align:center;}' +
4210      '.member-tag-option-item.is-selected .member-tag-option-check{visibility:visible;}' +
4211      '.member-tag-option-name{min-width:0;flex:1;padding:3px 4px;border-radius:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:text;}' +
4212      '.member-tag-option-name:hover{background:#eceef1;}' +
4213      '.member-tag-option-delete{display:inline-flex;width:26px;height:26px;flex:0 0 26px;align-items:center;justify-content:center;margin-left:4px;padding:0;border:0;border-radius:3px;background:transparent;color:#8b9098;opacity:0;transition:opacity .12s,background-color .12s;}' +
4214      '.member-tag-option-item:hover .member-tag-option-delete,.member-tag-option-item:focus-within .member-tag-option-delete,.member-tag-option-item.is-active .member-tag-option-delete{opacity:1;}' +
4215      '.member-tag-option-delete:hover,.member-tag-option-delete:focus{background:#eceef1;color:#37352f;outline:0;}' +
4216      '.member-tag-create-label{margin-right:6px;color:#717680;font-size:11px;}' +
4217      '.member-tag-option-empty{height:38px;padding:10px 12px;color:#9a9fa8;font-size:12px;}' +
4218      '.member-tag-rename-wrap{display:flex;width:100%;min-height:38px;flex-wrap:wrap;align-items:center;padding:4px 10px 4px 34px;}' +
4219      '.member-tag-rename-wrap .form-control{height:30px;}' +
4220      '.member-tag-inline-error{width:100%;margin-top:3px;color:#f44336;font-size:11px;line-height:16px;}' +
4221      '.member-tag-delete-confirm{position:fixed;z-index:10060;width:264px;padding:12px;border:1px solid #dfe1e5;border-radius:6px;background:#fff;box-shadow:0 4px 14px rgba(0,0,0,.14);}' +
4222      '.member-tag-delete-confirm-title{color:#37352f;font-size:13px;font-weight:600;line-height:19px;}' +
4223      '.member-tag-delete-confirm-description{margin-top:4px;color:#717680;font-size:11px;line-height:17px;}' +
4224      '.member-tag-delete-confirm-actions{display:flex;justify-content:flex-end;gap:6px;margin-top:10px;}' +
4225      '.member-tag-fallback-toast{position:fixed;z-index:11000;right:20px;bottom:20px;max-width:320px;padding:10px 14px;border-radius:4px;background:#37352f;color:#fff;font-size:13px;}' +
4226      '</style>').appendTo('head')
4227  }
4228
4229  /**
4230   * API나 SSR에서 받은 태그 배열을 독립적인 화면 상태로 복제합니다.
4231   * 원본 응답 객체를 직접 변경하지 않도록 화면에서 사용하는 필드만 새 객체로 만듭니다.
4232   *
4233   * @param {Array<{tag_code:string, tag_name:string}>} tags 태그 목록
4234   * @returns {Array<{tag_code:string, tag_name:string}>}
4235   */
4236  const copyTags = function (tags) {
4237    return (tags || []).map(function (tag) {
4238      return {
4239        tag_code: tag.tag_code,
4240        tag_name: tag.tag_name
4241      }
4242    })
4243  }
4244
4245  /**
4246   * 선택 코드 배열의 스냅샷을 생성합니다.
4247   * optimistic update 도중 확정값과 화면값이 같은 배열을 참조하지 않게 합니다.
4248   *
4249   * @param {string[]} tagCodes 태그 코드 목록
4250   * @returns {string[]}
4251   */
4252  const copyCodes = function (tagCodes) {
4253    return (tagCodes || []).slice()
4254  }
4255
4256  /**
4257   * 태그 코드의 개수와 순서가 모두 같은지 비교합니다.
4258   * 저장 중 추가 입력이 발생했을 때 후속 동기화가 필요한지 판단하는 데 사용합니다.
4259   *
4260   * @param {string[]} left 비교 대상
4261   * @param {string[]} right 비교 대상
4262   * @returns {boolean}
4263   */
4264  const isSameCodes = function (left, right) {
4265    return left.length === right.length && left.every(function (tagCode, index) {
4266      return tagCode === right[index]
4267    })
4268  }
4269
4270  /**
4271   * 태그 코드 목록을 현재 definitions의 태그 객체 목록으로 변환합니다.
4272   * 삭제되어 더 이상 존재하지 않는 코드는 화면에 다시 노출하지 않습니다.
4273   *
4274   * @param {string[]} tagCodes 태그 코드 목록
4275   * @returns {Array<{tag_code:string, tag_name:string}>}
4276   */
4277  const getTagsByCodes = function (tagCodes) {
4278    return tagCodes.map(function (tagCode) {
4279      let matchedDefinition = null
4280      definitions.some(function (definition) {
4281        if (definition.tag_code !== tagCode) return false
4282
4283        matchedDefinition = definition
4284        return true
4285      })
4286      return matchedDefinition
4287    }).filter(function (definition) {
4288      return !!definition
4289    })
4290  }
4291
4292  /**
4293   * 특정 회원의 현재 화면 태그에서 코드만 추출합니다.
4294   * popover를 열 때 persisted/desired 상태의 동일한 초기값으로 사용합니다.
4295   *
4296   * @param {string} memberCode 회원 코드
4297   * @returns {string[]}
4298   */
4299  const getMemberTagCodes = function (memberCode) {
4300    return (memberTagMap[memberCode] || []).map(function (tag) {
4301      return tag.tag_code
4302    })
4303  }
4304
4305  /**
4306   * 공통 cocoaToast가 있으면 재사용하고, 없는 화면에서는 임시 toast로 안내합니다.
4307   * 사용자 입력은 text로 넣어 HTML이 실행되지 않도록 처리합니다.
4308   *
4309   * @param {string} message 사용자 안내 문구
4310   */
4311  const showToast = function (message) {
4312    const text = message || getLocalizeString('설명_잠시후다시시도해주세요', '', '잠시 후 다시 시도해주세요.')
4313    if ($.cocoaToast && typeof $.cocoaToast.show === 'function') {
4314      $.cocoaToast.show({ html: $('<div>').text(text).html() })
4315      return
4316    }
4317
4318    const $toast = $('<div class="member-tag-fallback-toast">').text(text).appendTo('body')
4319    setTimeout(function () {
4320      $toast.fadeOut(150, function () {
4321        $toast.remove()
4322      })
4323    }, 2500)
4324  }
4325
4326  /**
4327   * 회원태그 필드 하나를 현재 상태에 맞춰 다시 그립니다.
4328   * 목록과 상세 모두 선택된 태그 전체를 표시하고, 활성 필드는 마지막 chip 뒤에 입력창을 표시합니다.
4329   *
4330   * @param {JQuery} $field 렌더링할 회원태그 필드
4331   * @param {Array<{tag_code:string, tag_name:string}>} tags 회원의 태그 목록
4332   */
4333  const renderField = function ($field, tags) {
4334    const memberCode = String($field.data('member-code') || '')
4335    const isEditable = $field.attr('data-editable') !== 'N'
4336    const isActive = !!activeState && activeState.memberCode === memberCode && activeState.$anchor[0] === $field[0]
4337    const visibleTags = tags || []
4338
4339    // 이전 렌더 결과를 제거한 뒤 접근성 상태와 읽기 전용 상태를 현재 값으로 맞춥니다.
4340    $field.empty()
4341      .toggleClass('is-active', isActive)
4342      .toggleClass('member-tag-field-readonly', !isEditable)
4343      .attr('aria-expanded', isActive ? 'true' : 'false')
4344
4345    // 선택된 태그는 해제 버튼 없이 #태그명 chip으로만 렌더링합니다.
4346    visibleTags.forEach(function (tag) {
4347      const $chip = $('<span class="member-tag-chip">')
4348        .attr('data-tag-code', tag.tag_code)
4349        .append($('<span class="member-tag-chip-name">').text('#' + tag.tag_name))
4350
4351      $field.append($chip)
4352    })
4353
4354    // 활성 필드의 마지막 chip 오른쪽에 검색·생성용 텍스트 cursor를 이어 붙입니다.
4355    if (isActive) {
4356      $field.append(
4357        $('<input type="text" class="_member_tag_query_input member-tag-query-input" autocomplete="off" aria-autocomplete="list">')
4358          .attr('aria-controls', activeState.optionListId)
4359          .attr('aria-label', '회원태그 선택 또는 생성')
4360          .prop('disabled', activeState.isCreating)
4361          .val(activeState.query)
4362      )
4363    }
4364  }
4365
4366  /**
4367   * 같은 회원 코드를 가진 모든 필드를 다시 그립니다.
4368   * 목록 재렌더링이나 상세/목록 DOM 중복 상황에서도 표시값을 일관되게 유지합니다.
4369   *
4370   * @param {string} memberCode 회원 코드
4371   */
4372  const renderMember = function (memberCode) {
4373    $('._member_tag_button[data-member-code="' + memberCode + '"]').each(function () {
4374      renderField($(this), memberTagMap[memberCode] || [])
4375    })
4376  }
4377
4378  /**
4379   * 목록 API 응답을 공유 definitions와 회원별 태그 상태에 반영합니다.
4380   * 응답에서 누락된 요청 회원은 태그가 없는 회원으로 명시적으로 초기화합니다.
4381   *
4382   * @param {{definitions?:Array, member_tag_map?:Object}} data API 응답 데이터
4383   * @param {string[]} memberCodes 요청한 회원 코드 목록
4384   */
4385  const applyResponse = function (data, memberCodes) {
4386    if (Object.prototype.hasOwnProperty.call(data, 'definitions')) {
4387      definitions = copyTags(data.definitions)
4388      definitionsLoaded = true
4389    }
4390    const responseMemberTagMap = data.member_tag_map || {}
4391
4392    memberCodes.forEach(function (memberCode) {
4393      memberTagMap[memberCode] = []
4394    })
4395    Object.keys(responseMemberTagMap).forEach(function (memberCode) {
4396      memberTagMap[memberCode] = copyTags(responseMemberTagMap[memberCode])
4397    })
4398  }
4399
4400  /**
4401   * 회원별 태그와 선택적으로 사이트 태그 정의를 POST로 조회합니다.
4402   * success=false 응답도 jQuery fail 흐름에서 동일하게 처리할 수 있도록 reject로 변환합니다.
4403   *
4404   * @param {string[]} memberCodes 조회할 회원 코드 목록
4405   * @param {boolean} includeDefinitions 사이트 태그 정의 포함 여부
4406   * @returns {JQuery.Promise}
4407   */
4408  const requestTagData = function (memberCodes, includeDefinitions) {
4409    return $.ajax({
4410      url: '/admin/ajax/member/member_tag.cm',
4411      type: 'POST',
4412      dataType: 'json',
4413      cache: false,
4414      data: {
4415        action: 'list',
4416        member_codes: memberCodes,
4417        include_definitions: includeDefinitions ? 'Y' : 'N'
4418      }
4419    }).then(function (response) {
4420      if (!response.success) {
4421        return $.Deferred().reject(response.message).promise()
4422      }
4423
4424      applyResponse(response.data, memberCodes)
4425      return response.data
4426    })
4427  }
4428
4429  /**
4430   * 사이트 태그 정의를 최초 한 번만 조회하고 진행 중인 요청도 공유합니다.
4431   * 회원 목록 검색 초기화와 행별 태그 지연 로딩이 동시에 시작되어도 같은 정의 조회를 중복 전송하지 않습니다.
4432   *
4433   * @returns {JQuery.Promise}
4434   */
4435  const loadDefinitions = function () {
4436    if (definitionsLoaded) {
4437      return $.Deferred().resolve(copyTags(definitions)).promise()
4438    }
4439    if (definitionRequest) return definitionRequest
4440
4441    definitionRequest = requestTagData([], true).always(function () {
4442      definitionRequest = null
4443    })
4444
4445    return definitionRequest
4446  }
4447
4448  /**
4449   * 검색 자동완성에서 사용할 현재 사이트 태그 정의의 복사본을 반환합니다.
4450   * 호출부가 정렬이나 필터링을 수행해도 combobox의 공유 definitions 순서가 바뀌지 않습니다.
4451   *
4452   * @returns {Array<{tag_code:string, tag_name:string}>}
4453   */
4454  const getDefinitions = function () {
4455    return copyTags(definitions)
4456  }
4457
4458  /**
4459   * 기존 회원 검색창의 입력을 태그 조건과 일반 검색어로 분리합니다.
4460   * `#태그명`이 현재 사이트 정의와 정확히 일치할 때만 태그 필터로 해석하며,
4461   * 일치하지 않으면 `#`을 포함한 전체 입력을 기존 회원 검색어로 그대로 전달합니다.
4462   *
4463   * @param {string} displayKeyword 검색창에 표시된 전체 입력
4464   * @returns {{keyword:string, memberTagCode:string}}
4465   */
4466  const getMemberSearchCondition = function (displayKeyword) {
4467    const normalizedKeyword = $.trim(String(displayKeyword || ''))
4468    const defaultCondition = {
4469      keyword: normalizedKeyword,
4470      memberTagCode: ''
4471    }
4472    if (normalizedKeyword.charAt(0) !== '#') return defaultCondition
4473
4474    // 태그명이 서로 접두어 관계여도 더 긴 이름을 먼저 비교해 정확한 태그를 선택합니다.
4475    const sortedDefinitions = getDefinitions().sort(function (left, right) {
4476      return String(right.tag_name || '').length - String(left.tag_name || '').length
4477    })
4478    const lowerKeyword = normalizedKeyword.toLocaleLowerCase()
4479    let matchedCondition = null
4480
4481    sortedDefinitions.some(function (definition) {
4482      const tagPrefix = '#' + String(definition.tag_name || '')
4483      const lowerTagPrefix = tagPrefix.toLocaleLowerCase()
4484      const isExactTag = lowerKeyword === lowerTagPrefix
4485      const hasFollowingKeyword = lowerKeyword.indexOf(lowerTagPrefix + ' ') === 0
4486      if (!isExactTag && !hasFollowingKeyword) return false
4487
4488      matchedCondition = {
4489        keyword: $.trim(normalizedKeyword.substring(tagPrefix.length)),
4490        memberTagCode: definition.tag_code
4491      }
4492      return true
4493    })
4494
4495    return matchedCondition || defaultCondition
4496  }
4497
4498  /**
4499   * 회원 목록 SSR 이후 현재 페이지 회원의 태그를 한 번에 지연 로딩합니다.
4500   * 조회 실패 시 잘못된 이전 값이 남지 않도록 요청 대상 셀을 빈 상태로 유지합니다.
4501   *
4502   * @param {string[]} memberCodes 현재 목록에 표시된 회원 코드 목록
4503   */
4504  const loadList = function (memberCodes) {
4505    ensureStyles()
4506    if (!memberCodes || memberCodes.length === 0) return
4507
4508    requestTagData(memberCodes, !definitionsLoaded).done(function () {
4509      memberCodes.forEach(renderMember)
4510    }).fail(function () {
4511      memberCodes.forEach(function (memberCode) {
4512        $('._member_tag_button[data-member-code="' + memberCode + '"]').empty()
4513      })
4514    })
4515  }
4516
4517  /**
4518   * DOM 갱신이 끝난 다음 활성 필드의 입력창으로 focus를 복구합니다.
4519   * chip 재렌더링으로 input 노드가 교체되므로 다음 event loop에서 새 노드를 찾습니다.
4520   *
4521   * @param {Object} state 현재 combobox 상태
4522   */
4523  const focusQueryInput = function (state) {
4524    if (activeState !== state) return
4525
4526    setTimeout(function () {
4527      if (activeState !== state) return
4528
4529      const $input = state.$anchor.find('._member_tag_query_input')
4530      if ($input.length === 0 || $input.prop('disabled')) return
4531
4532      $input.focus()
4533      const input = $input[0]
4534      if (input && typeof input.setSelectionRange === 'function') {
4535        input.setSelectionRange(input.value.length, input.value.length)
4536      }
4537    }, 0)
4538  }
4539
4540  /**
4541   * popover를 anchor 아래에 배치하고 viewport 밖으로 나가면 위쪽으로 전환합니다.
4542   * fixed 좌표와 문서 전체 scroll 감지를 함께 사용해 테이블 overflow에 잘리지 않게 합니다.
4543   *
4544   * @param {Object} state 현재 combobox 상태
4545   */
4546  const positionPopover = function (state) {
4547    if (activeState !== state || !state.$popover || state.$popover.length === 0) return
4548    if (!$.contains(document, state.$anchor[0])) {
4549      closePopover()
4550      return
4551    }
4552
4553    const rect = state.$anchor[0].getBoundingClientRect()
4554    const width = Math.min(340, window.innerWidth - 16)
4555    const height = state.$popover.outerHeight()
4556    const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))
4557    let top = rect.bottom + 4
4558
4559    // 아래쪽 공간이 부족하고 위쪽 공간이 충분한 경우 anchor 위로 뒤집습니다.
4560    if (top + height > window.innerHeight - 8 && rect.top - height - 4 >= 8) {
4561      top = rect.top - height - 4
4562    }
4563    top = Math.max(8, Math.min(top, window.innerHeight - height - 8))
4564    state.$popover.css({ left: left, top: top, width: width })
4565    if (state.deleteConfirm) positionDeleteConfirm(state)
4566  }
4567
4568  /**
4569   * 생성·수정 전에 빈 태그명만 검사합니다.
4570   * 개수와 길이 제한은 서버 검증 결과를 사용합니다.
4571   *
4572   * @param {string} tagName 태그명
4573   * @returns {string} 오류 문구, 정상일 때 빈 문자열
4574   */
4575  const validateTagName = function (tagName) {
4576    if (!tagName) return '태그명을 입력해 주세요.'
4577
4578    return ''
4579  }
4580
4581  /**
4582   * 키보드 또는 hover로 이동한 option을 시각 상태와 aria-activedescendant에 반영합니다.
4583   * 활성 index가 유효하지 않으면 접근성 참조를 제거합니다.
4584   *
4585   * @param {Object} state 현재 combobox 상태
4586   */
4587  const updateActiveOption = function (state) {
4588    if (activeState !== state) return
4589
4590    state.$popover.find('._member_tag_option_item').removeClass('is-active')
4591    if (state.activeIndex < 0 || state.activeIndex >= state.visibleItems.length) {
4592      state.$anchor.find('._member_tag_query_input').removeAttr('aria-activedescendant')
4593      return
4594    }
4595
4596    const itemId = state.visibleItems[state.activeIndex].id
4597    state.$popover.find('#' + itemId).addClass('is-active')
4598    state.$anchor.find('._member_tag_query_input').attr('aria-activedescendant', itemId)
4599  }
4600
4601  /**
4602   * 수정된 태그명을 definitions와 이미 로딩한 모든 회원 chip에 전파합니다.
4603   * 태그 코드가 동일한 객체만 갱신하여 다른 사이트/태그 상태에는 영향을 주지 않습니다.
4604   *
4605   * @param {{tag_code:string, tag_name:string}} definition 수정 API 응답
4606   */
4607  const updateDefinitionState = function (definition) {
4608    definitions.some(function (item, index) {
4609      if (item.tag_code !== definition.tag_code) return false
4610
4611      definitions[index] = {
4612        tag_code: definition.tag_code,
4613        tag_name: definition.tag_name
4614      }
4615      return true
4616    })
4617    Object.keys(memberTagMap).forEach(function (memberCode) {
4618      memberTagMap[memberCode].forEach(function (tag) {
4619        if (tag.tag_code === definition.tag_code) tag.tag_name = definition.tag_name
4620      })
4621      renderMember(memberCode)
4622    })
4623  }
4624
4625  /**
4626   * 삭제 확인 popover를 휴지통 버튼 옆에 배치하고 viewport 충돌을 보정합니다.
4627   * 원본 버튼이 DOM에서 사라졌다면 확인 UI를 닫아 고립된 popover를 방지합니다.
4628   *
4629   * @param {Object} state 현재 combobox 상태
4630   */
4631  const positionDeleteConfirm = function (state) {
4632    if (activeState !== state || !state.deleteConfirm) return
4633
4634    const confirmState = state.deleteConfirm
4635    if (!$.contains(document, confirmState.$button[0])) {
4636      closeDeleteConfirm(state, false)
4637      return
4638    }
4639
4640    const buttonRect = confirmState.$button[0].getBoundingClientRect()
4641    const width = Math.min(264, window.innerWidth - 16)
4642    const height = confirmState.$popover.outerHeight()
4643    let left = buttonRect.right + 6
4644
4645    if (left + width > window.innerWidth - 8) {
4646      left = buttonRect.left - width - 6
4647    }
4648    left = Math.max(8, Math.min(left, window.innerWidth - width - 8))
4649    const top = Math.max(8, Math.min(buttonRect.top - 4, window.innerHeight - height - 8))
4650    confirmState.$popover.css({ left: left, top: top, width: width })
4651  }
4652
4653  /**
4654   * 삭제 확인 상태와 DOM을 정리하고 필요하면 combobox 입력 focus를 복구합니다.
4655   *
4656   * @param {Object} state 현재 combobox 상태
4657   * @param {boolean} restoreFocus 입력 focus 복구 여부
4658   */
4659  const closeDeleteConfirm = function (state, restoreFocus) {
4660    if (!state || !state.deleteConfirm) return
4661
4662    state.pendingDefinitionDelete = null
4663    state.deleteConfirm.$popover.remove()
4664    state.deleteConfirm = null
4665    if (restoreFocus) focusQueryInput(state)
4666  }
4667
4668  /** 이미 로딩한 모든 회원태그 필드를 현재 memberTagMap 기준으로 다시 그립니다. */
4669  const renderAllLoadedMembers = function () {
4670    Object.keys(memberTagMap).forEach(renderMember)
4671  }
4672
4673  /**
4674   * 사이트 태그 삭제 API 실패 시 optimistic update 이전 스냅샷으로 복구합니다.
4675   * 복구 뒤 삭제 중 밀린 회원태그 변경이 있으면 동기화를 다시 시작합니다.
4676   *
4677   * @param {Object} state 현재 combobox 상태
4678   * @param {Object} snapshot 삭제 직전 definitions와 회원별 태그 스냅샷
4679   * @param {string} message 실패 안내 문구
4680   */
4681  const restoreDeletedDefinition = function (state, snapshot, message) {
4682    if (!definitions.some(function (definition) { return definition.tag_code === snapshot.definition.tag_code })) {
4683      definitions.splice(snapshot.definitionIndex, 0, snapshot.definition)
4684    }
4685    Object.keys(snapshot.memberTagMap).forEach(function (memberCode) {
4686      memberTagMap[memberCode] = copyTags(snapshot.memberTagMap[memberCode])
4687    })
4688    state.desiredTagCodes = copyCodes(snapshot.desiredTagCodes)
4689    state.persistedTagCodes = copyCodes(snapshot.persistedTagCodes)
4690    state.isDeletingDefinition = false
4691    renderAllLoadedMembers()
4692    if (activeState === state) {
4693      renderPopover(state)
4694      focusQueryInput(state)
4695    }
4696    showToast(message)
4697    if (!isSameCodes(state.desiredTagCodes, state.persistedTagCodes)) syncMemberTags(state)
4698  }
4699
4700  /**
4701   * 사이트 태그 정의를 삭제하고 로딩된 모든 회원의 동일 태그를 즉시 제거합니다.
4702   * 회원태그 저장 요청과 삭제 요청이 겹치면 저장 완료 후 삭제하도록 보류해 순서 역전을 막습니다.
4703   *
4704   * @param {Object} state 현재 combobox 상태
4705   * @param {{tag_code:string, tag_name:string}} definition 삭제할 태그 정의
4706   */
4707  const requestDefinitionDelete = function (state, definition) {
4708    if (state.isDeletingDefinition) return
4709    if (state.isSyncing) {
4710      closeDeleteConfirm(state, false)
4711      state.pendingDefinitionDelete = definition
4712      return
4713    }
4714
4715    const definitionIndex = definitions.map(function (item) { return item.tag_code }).indexOf(definition.tag_code)
4716    if (definitionIndex === -1) {
4717      closeDeleteConfirm(state, true)
4718      return
4719    }
4720
4721    // 서버 실패 시 원복할 수 있도록 공유 definitions와 모든 로딩 회원 상태를 깊은 복사합니다.
4722    const loadedMemberTagMap = {}
4723    Object.keys(memberTagMap).forEach(function (memberCode) {
4724      loadedMemberTagMap[memberCode] = copyTags(memberTagMap[memberCode])
4725    })
4726    const snapshot = {
4727      definition: {
4728        tag_code: definition.tag_code,
4729        tag_name: definition.tag_name
4730      },
4731      definitionIndex: definitionIndex,
4732      memberTagMap: loadedMemberTagMap,
4733      desiredTagCodes: copyCodes(state.desiredTagCodes),
4734      persistedTagCodes: copyCodes(state.persistedTagCodes)
4735    }
4736
4737    // API 응답을 기다리지 않고 option, chip, 목록 셀에서 삭제 대상을 먼저 제거합니다.
4738    state.isDeletingDefinition = true
4739    definitions.splice(definitionIndex, 1)
4740    Object.keys(memberTagMap).forEach(function (memberCode) {
4741      memberTagMap[memberCode] = memberTagMap[memberCode].filter(function (tag) {
4742        return tag.tag_code !== definition.tag_code
4743      })
4744    })
4745    state.desiredTagCodes = state.desiredTagCodes.filter(function (tagCode) {
4746      return tagCode !== definition.tag_code
4747    })
4748    state.persistedTagCodes = state.persistedTagCodes.filter(function (tagCode) {
4749      return tagCode !== definition.tag_code
4750    })
4751    closeDeleteConfirm(state, false)
4752    renderAllLoadedMembers()
4753    if (activeState === state) {
4754      renderPopover(state)
4755      focusQueryInput(state)
4756    }
4757
4758    // 사이트 태그 정의 삭제는 모든 회원 연결까지 제거하므로 별도 delete_definition action을 호출합니다.
4759    $.ajax({
4760      url: '/admin/ajax/member/member_tag.cm',
4761      type: 'POST',
4762      dataType: 'json',
4763      data: {
4764        action: 'delete_definition',
4765        tag_code: definition.tag_code
4766      }
4767    }).done(function (response) {
4768      if (!response.success) {
4769        restoreDeletedDefinition(state, snapshot, response.message || '회원태그 삭제에 실패했습니다.')
4770        return
4771      }
4772
4773      state.isDeletingDefinition = false
4774      showToast('#' + definition.tag_name + ' 태그를 삭제했습니다.')
4775      if (activeState === state) focusQueryInput(state)
4776      if (!isSameCodes(state.desiredTagCodes, state.persistedTagCodes)) syncMemberTags(state)
4777    }).fail(function () {
4778      restoreDeletedDefinition(
4779        state,
4780        snapshot,
4781        getLocalizeString('설명_잠시후다시시도해주세요', '', '잠시 후 다시 시도해주세요.')
4782      )
4783    })
4784  }
4785
4786  /**
4787   * 태그 option의 휴지통 옆에 사이트 전체 삭제 확인 popover를 엽니다.
4788   * mousedown/click 전파를 모두 차단해 option 선택이나 더블클릭 수정과 충돌하지 않게 합니다.
4789   *
4790   * @param {Object} state 현재 combobox 상태
4791   * @param {{tag_code:string, tag_name:string}} definition 삭제할 태그 정의
4792   * @param {JQuery} $button anchor 휴지통 버튼
4793   */
4794  const openDeleteConfirm = function (state, definition, $button) {
4795    if (activeState !== state || state.editing || state.isCreating || state.isDeletingDefinition || state.pendingDefinitionDelete) return
4796
4797    closeDeleteConfirm(state, false)
4798    const $cancelButton = $('<button type="button" class="btn btn-default btn-xs">').text('취소')
4799    const $deleteButton = $('<button type="button" class="btn btn-danger btn-xs">').text('삭제')
4800    const $popover = $('<div class="_member_tag_delete_confirm member-tag-delete-confirm">')
4801      .append($('<div class="member-tag-delete-confirm-title">').text('#' + definition.tag_name + ' 태그를 삭제할까요?'))
4802      .append('<div class="member-tag-delete-confirm-description">이 태그는 사이트의 모든 회원에게서 제거됩니다.</div>')
4803      .append('<div class="member-tag-delete-confirm-description">회원 수가 많으면 삭제에 시간이 오래 걸릴 수 있습니다.</div>')
4804      .append($('<div class="member-tag-delete-confirm-actions">').append($cancelButton).append($deleteButton))
4805      .appendTo('body')
4806
4807    state.deleteConfirm = {
4808      definition: definition,
4809      $button: $button,
4810      $popover: $popover,
4811      $deleteButton: $deleteButton
4812    }
4813    $cancelButton.on('mousedown', function (event) {
4814      event.preventDefault()
4815      event.stopPropagation()
4816    }).on('click', function (event) {
4817      event.preventDefault()
4818      event.stopPropagation()
4819      closeDeleteConfirm(state, true)
4820    })
4821    $deleteButton.on('mousedown', function (event) {
4822      event.preventDefault()
4823      event.stopPropagation()
4824    }).on('click', function (event) {
4825      event.preventDefault()
4826      event.stopPropagation()
4827      requestDefinitionDelete(state, definition)
4828    })
4829    $popover.on('keydown', function (event) {
4830      if (event.which !== 27) return
4831
4832      event.preventDefault()
4833      event.stopPropagation()
4834      closeDeleteConfirm(state, true)
4835    })
4836    positionDeleteConfirm(state)
4837    $cancelButton.focus()
4838  }
4839
4840  /**
4841   * inline 태그명 수정을 저장하지 않고 취소하고 option 목록으로 복귀합니다.
4842   * blur가 뒤이어 발생해도 isCancelled 상태를 통해 저장 요청이 실행되지 않습니다.
4843   *
4844   * @param {Object} state 현재 combobox 상태
4845   */
4846  const cancelRename = function (state) {
4847    if (activeState !== state || !state.editing) return
4848
4849    state.editing.isCancelled = true
4850    state.editing = null
4851    renderPopover(state)
4852    focusQueryInput(state)
4853  }
4854
4855  /**
4856   * inline input의 태그명을 검증한 뒤 즉시 수정 API로 저장합니다.
4857   * 오류는 alert 대신 현재 input 하단에 유지하고, 성공 후 모든 로딩 회원의 chip 이름을 갱신합니다.
4858   *
4859   * @param {Object} state 현재 combobox 상태
4860   */
4861  const saveRename = function (state) {
4862    if (activeState !== state || !state.editing) return
4863
4864    const editing = state.editing
4865    if (editing.isCancelled || editing.isSaving) return
4866
4867    // 공백을 정리한 실제 저장값으로 길이를 검사하고 오류를 input 주변에 표시합니다.
4868    const tagName = $.trim(editing.$input.val())
4869    const errorMessage = validateTagName(tagName)
4870    if (errorMessage) {
4871      editing.error = errorMessage
4872      editing.$error.text(errorMessage).show()
4873      return
4874    }
4875    if (tagName === editing.definition.tag_name) {
4876      const shouldClose = editing.closeAfterSave
4877      state.editing = null
4878      if (shouldClose) {
4879        closePopover()
4880      } else {
4881        renderPopover(state)
4882        focusQueryInput(state)
4883      }
4884      return
4885    }
4886
4887    // Enter와 blur가 연달아 발생해도 요청이 중복되지 않도록 저장 상태를 먼저 잠급니다.
4888    editing.isSaving = true
4889    editing.error = ''
4890    editing.$error.empty().hide()
4891    editing.$input.prop('disabled', true)
4892    $.ajax({
4893      url: '/admin/ajax/member/member_tag.cm',
4894      type: 'POST',
4895      dataType: 'json',
4896      data: {
4897        action: 'update_definition',
4898        tag_code: editing.definition.tag_code,
4899        tag_name: tagName
4900      }
4901    }).done(function (response) {
4902      if (!response.success) {
4903        editing.isSaving = false
4904        editing.error = response.message || '태그명을 저장하지 못했습니다.'
4905        editing.$input.prop('disabled', false)
4906        editing.$error.text(editing.error).show()
4907        editing.$input.focus().select()
4908        return
4909      }
4910
4911      const shouldClose = editing.closeAfterSave
4912      updateDefinitionState(response.data)
4913      state.editing = null
4914      if (shouldClose) {
4915        closePopover()
4916      } else if (activeState === state) {
4917        renderPopover(state)
4918        focusQueryInput(state)
4919      }
4920    }).fail(function () {
4921      editing.isSaving = false
4922      editing.error = getLocalizeString('설명_잠시후다시시도해주세요', '', '잠시 후 다시 시도해주세요.')
4923      editing.$input.prop('disabled', false)
4924      editing.$error.text(editing.error).show()
4925      editing.$input.focus().select()
4926    })
4927  }
4928
4929  /**
4930   * 선택된 option을 inline input으로 전환하고 전체 선택 상태로 focus합니다.
4931   * 삭제 확인이나 다른 수정이 진행 중이면 상태 전환을 허용하지 않습니다.
4932   *
4933   * @param {Object} state 현재 combobox 상태
4934   * @param {{tag_code:string, tag_name:string}} definition 수정할 태그 정의
4935   */
4936  const startRename = function (state, definition) {
4937    if (activeState !== state || state.editing || state.deleteConfirm || state.isDeletingDefinition) return
4938
4939    state.editing = {
4940      definition: definition,
4941      isCancelled: false,
4942      isSaving: false,
4943      closeAfterSave: false,
4944      error: ''
4945    }
4946    renderPopover(state)
4947    setTimeout(function () {
4948      if (activeState !== state || !state.editing || !state.editing.$input) return
4949
4950      state.editing.$input.focus().select()
4951    }, 0)
4952  }
4953
4954  /**
4955   * 회원태그 저장 실패 시 화면 선택값을 마지막 서버 확정값으로 되돌립니다.
4956   * popover를 닫지 않고 다시 렌더링해 사용자가 연속 작업을 이어갈 수 있습니다.
4957   *
4958   * @param {Object} state 현재 combobox 상태
4959   * @param {string} message 실패 안내 문구
4960   */
4961  const rollbackMemberSync = function (state, message) {
4962    state.desiredTagCodes = copyCodes(state.persistedTagCodes)
4963    memberTagMap[state.memberCode] = getTagsByCodes(state.desiredTagCodes)
4964    renderMember(state.memberCode)
4965    if (activeState === state) {
4966      renderPopover(state)
4967      focusQueryInput(state)
4968    }
4969    showToast(message)
4970  }
4971
4972  /**
4973   * desiredTagCodes 전체를 서버에 저장하고 응답값을 persistedTagCodes로 확정합니다.
4974   * 요청 중 추가 선택이 발생하면 현재 요청 완료 후 최신 desired 상태를 다시 전송합니다.
4975   *
4976   * @param {Object} state 현재 combobox 상태
4977   */
4978  const syncMemberTags = function (state) {
4979    if (state.isSyncing || isSameCodes(state.desiredTagCodes, state.persistedTagCodes)) return
4980
4981    // 전송 시점의 선택값을 고정해 요청 중 사용자의 추가 조작과 섞
4981이지 않게 합니다.
4982    const payload = copyCodes(state.desiredTagCodes)
4983    let failed = false
4984    state.isSyncing = true
4985    $.ajax({
4986      url: '/admin/ajax/member/member_tag.cm',
4987      type: 'POST',
4988      dataType: 'json',
4989      data: {
4990        action: 'replace_member_tags',
4991        member_code: state.memberCode,
4992        tag_codes: JSON.stringify(payload)
4993      }
4994    }).done(function (response) {
4995      if (!response.success) {
4996        failed = true
4997        rollbackMemberSync(state, response.message)
4998        return
4999      }
5000
5001      state.persistedTagCodes = copyCodes(response.data.tag_codes)
5002    }).fail(function () {
5003      failed = true
5004      rollbackMemberSync(state, getLocalizeString('설명_잠시후다시시도해주세요', '', '잠시 후 다시 시도해주세요.'))
5005    }).always(function () {
5006      state.isSyncing = false
5007
5008      // 회원 저장 중 요청된 정의 삭제를 먼저 처리해 삭제된 태그가 다시 저장되는 것을 막습니다.
5009      if (state.pendingDefinitionDelete) {
5010        const pendingDefinition = state.pendingDefinitionDelete
5011        state.pendingDefinitionDelete = null
5012        requestDefinitionDelete(state, pendingDefinition)
5013        return
5014      }
5015      // 저장 완료 뒤 desired 값이 더 최신이면 마지막 상태가 확정될 때까지 한 번 더 동기화합니다.
5016      if (!failed && !isSameCodes(state.desiredTagCodes, state.persistedTagCodes)) {
5017        syncMemberTags(state)
5018      }
5019    })
5020  }
5021
5022  /**
5023   * 사용자 조작 결과를 화면에 먼저 반영한 뒤 비동기 저장을 시작합니다.
5024   * memberTagMap, anchor chip, option 선택 상태가 동일한 desired 값을 바라보게 합니다.
5025   *
5026   * @param {Object} state 현재 combobox 상태
5027   * @param {string[]} tagCodes 변경할 태그 코드 목록
5028   */
5029  const applyDesiredTags = function (state, tagCodes) {
5030    state.desiredTagCodes = copyCodes(tagCodes)
5031    memberTagMap[state.memberCode] = getTagsByCodes(state.desiredTagCodes)
5032    renderMember(state.memberCode)
5033    if (activeState === state) {
5034      renderPopover(state)
5035      focusQueryInput(state)
5036    }
5037    syncMemberTags(state)
5038  }
5039
5040  /**
5041   * 태그 정의 하나를 현재 회원에게 선택하거나 해제합니다.
5042   * 개수 제한은 서버 응답으로 검증하고 실제 저장은 applyDesiredTags에 위임합니다.
5043   *
5044   * @param {Object} state 현재 combobox 상태
5045   * @param {{tag_code:string, tag_name:string}} definition 선택·해제할 태그 정의
5046   * @param {boolean=} forceSelected true면 선택, false면 해제, 생략하면 toggle
5047   */
5048  const toggleDefinition = function (state, definition, forceSelected) {
5049    if (!state.ready || state.isCreating || state.deleteConfirm || state.isDeletingDefinition) return
5050
5051    const tagCodes = copyCodes(state.desiredTagCodes)
5052    const currentIndex = tagCodes.indexOf(definition.tag_code)
5053    const shouldSelect = typeof forceSelected === 'boolean' ? forceSelected : currentIndex === -1
5054
5055    if (shouldSelect && currentIndex === -1) {
5056      tagCodes.push(definition.tag_code)
5057    } else if (!shouldSelect && currentIndex !== -1) {
5058      tagCodes.splice(currentIndex, 1)
5059    } else {
5060      return
5061    }
5062
5063    state.query = ''
5064    state.queryError = ''
5065    applyDesiredTags(state, tagCodes)
5066  }
5067
5068  /**
5069   * 입력값으로 사이트 태그를 생성하고 현재 회원에게 즉시 선택합니다.
5070   * 동일 이름만 사전 검사하며 개수와 길이 제한은 서버 응답으로 검증합니다.
5071   *
5072   * @param {Object} state 현재 combobox 상태
5073   */
5074  const createDefinition = function (state) {
5075    if (activeState !== state || state.isCreating || state.deleteConfirm || state.isDeletingDefinition) return
5076
5077    const tagName = $.trim(state.query)
5078    const errorMessage = validateTagName(tagName)
5079    const duplicated = definitions.some(function (definition) {
5080      return definition.tag_name.toLowerCase() === tagName.toLowerCase()
5081    })
5082
5083    if (errorMessage) {
5084      state.queryError = errorMessage
5085      renderPopover(state)
5086      focusQueryInput(state)
5087      return
5088    }
5089    if (duplicated) return
5090
5091    // 생성 요청 동안 중복 Enter를 막고 필드 input을 disabled 상태로 다시 렌더링합니다.
5092    state.isCreating = true
5093    state.queryError = ''
5094    renderMember(state.memberCode)
5095    renderPopover(state)
5096    $.ajax({
5097      url: '/admin/ajax/member/member_tag.cm',
5098      type: 'POST',
5099      dataType: 'json',
5100      data: {
5101        action: 'create_definition',
5102        tag_name: tagName
5103      }
5104    }).done(function (response) {
5105      if (!response.success) {
5106        state.isCreating = false
5107        state.queryError = response.message || '태그를 생성하지 못했습니다.'
5108        renderMember(state.memberCode)
5109        if (activeState === state) {
5110          renderPopover(state)
5111          focusQueryInput(state)
5112        }
5113        return
5114      }
5115
5116      // 새 정의를 공유 목록에 추가한 뒤 현재 회원의 desired 선택에도 포함합니다.
5117      definitions.push({
5118        tag_code: response.data.tag_code,
5119        tag_name: response.data.tag_name
5120      })
5121      state.isCreating = false
5122      state.query = ''
5123      state.queryError = ''
5124      const tagCodes = copyCodes(state.desiredTagCodes)
5125      tagCodes.push(response.data.tag_code)
5126      applyDesiredTags(state, tagCodes)
5127    }).fail(function () {
5128      state.isCreating = false
5129      state.queryError = getLocalizeString('설명_잠시후다시시도해주세요', '', '잠시 후 다시 시도해주세요.')
5130      renderMember(state.memberCode)
5131      if (activeState === state) {
5132        renderPopover(state)
5133        focusQueryInput(state)
5134      }
5135    })
5136  }
5137
5138  /**
5139   * 현재 keyboard active option을 Enter 동작으로 실행합니다.
5140   * 선택 가능한 option이 없고 입력값만 있으면 생성 동작으로 연결합니다.
5141   *
5142   * @param {Object} state 현재 combobox 상태
5143   */
5144  const activateVisibleItem = function (state) {
5145    if (state.activeIndex < 0 || state.activeIndex >= state.visibleItems.length) {
5146      if ($.trim(state.query)) createDefinition(state)
5147      return
5148    }
5149
5150    const item = state.visibleItems[state.activeIndex]
5151    if (item.type === 'create') {
5152      createDefinition(state)
5153    } else {
5154      toggleDefinition(state, item.definition)
5155    }
5156  }
5157
5158  /**
5159   * 수정 중인 태그 option 자리에 inline input과 오류 영역을 렌더링합니다.
5160   * Enter/blur는 저장, Esc는 취소로 연결하며 이벤트가 상위 option으로 전파되지 않게 합니다.
5161   *
5162   * @param {Object} state 현재 combobox 상태
5163   * @param {JQuery} $optionList option 목록
5164   * @param {{tag_code:string, tag_name:string}} definition 수정할 태그 정의
5165   * @param {string} itemId aria-activedescendant에서 참조할 DOM id
5166   */
5167  const renderRenameOption = function (state, $optionList, definition, itemId) {
5168    const editing = state.editing
5169    const $input = $('<input type="text" class="form-control input-sm _member_tag_rename_input">').val(editing.definition.tag_name)
5170    const $error = $('<div class="member-tag-inline-error" style="display:none;">')
5171    const $wrap = $('<div class="member-tag-rename-wrap" role="option">').attr('id', itemId).append($input).append($error)
5172
5173    editing.$input = $input
5174    editing.$error = $error
5175    $input.on('keydown', function (event) {
5176      if (event.which === 13) {
5177        event.preventDefault()
5178        event.stopPropagation()
5179        saveRename(state)
5180      } else if (event.which === 27) {
5181        event.preventDefault()
5182        event.stopPropagation()
5183        cancelRename(state)
5184      }
5185    }).on('blur', function () {
5186      saveRename(state)
5187    })
5188    $optionList.append($wrap)
5189  }
5190
5191  /**
5192   * 일반 태그 option 한 행을 선택 상태, 수정 affordance, 삭제 버튼과 함께 렌더링합니다.
5193   * 태그명 single click은 선택, double click은 수정이므로 짧은 timer로 두 이벤트를 분리합니다.
5194   *
5195   * @param {Object} state 현재 combobox 상태
5196   * @param {JQuery} $optionList option 목록
5197   * @param {{tag_code:string, tag_name:string}} definition 태그 정의
5198   * @param {number} itemIndex visibleItems의 index
5199   */
5200  const renderDefinitionOption = function (state, $optionList, definition, itemIndex) {
5201    const isSelected = state.desiredTagCodes.indexOf(definition.tag_code) !== -1
5202    const item = state.visibleItems[itemIndex]
5203    const $option = $('<div class="_member_tag_option_item member-tag-option-item" role="option">')
5204      .attr('id', item.id)
5205      .attr('aria-selected', isSelected ? 'true' : 'false')
5206      .toggleClass('is-selected text-primary', isSelected)
5207      .toggleClass('is-active', state.activeIndex === itemIndex)
5208    const $name = $('<span class="_member_tag_option_name member-tag-option-name">').text('#' + definition.tag_name)
5209    const $deleteButton = $('<button type="button" class="_member_tag_option_delete member-tag-option-delete">')
5210      .attr('aria-label', '#' + definition.tag_name + ' 태그 삭제')
5211      .append('<i class="btm bt-trash" aria-hidden="true"></i>')
5212    let clickTimer = null
5213
5214    $option.append('<span class="member-tag-option-check" aria-hidden="true">✓</span>').append($name).append($deleteButton)
5215    // mousedown 기본 동작을 막아 option 조작 중에도 anchor input focus를 유지합니다.
5216    $option.on('mousedown', function (event) {
5217      event.preventDefault()
5218    }).on('mouseenter', function () {
5219      if (state.editing || state.deleteConfirm) return
5220
5221      state.activeIndex = itemIndex
5222      updateActiveOption(state)
5223    }).on('click', function (event) {
5224      if (state.editing) return
5225      if ($(event.target).closest('._member_tag_option_name').length > 0) return
5226
5227      toggleDefinition(state, definition)
5228    })
5229    // single click 실행을 잠시 늦추고 dblclick이 들어오면 timer를 취소해 깜빡임을 방지합니다.
5230    $name.on('click', function (event) {
5231      event.preventDefault()
5232      event.stopPropagation()
5233      if (state.editing) return
5234      if (event.detail > 1) {
5235        clearTimeout(clickTimer)
5236        return
5237      }
5238
5239      clearTimeout(clickTimer)
5240      clickTimer = setTimeout(function () {
5241        toggleDefinition(state, definition)
5242      }, 260)
5243    }).on('dblclick', function (event) {
5244      event.preventDefault()
5245      event.stopPropagation()
5246      clearTimeout(clickTimer)
5247      startRename(state, definition)
5248    })
5249    // 삭제 버튼 이벤트는 option 선택·수정 이벤트로 절대 전파하지 않습니다.
5250    $deleteButton.on('mousedown', function (event) {
5251      event.preventDefault()
5252      event.stopPropagation()
5253      clearTimeout(clickTimer)
5254    }).on('focus', function () {
5255      state.activeIndex = itemIndex
5256      updateActiveOption(state)
5257    }).on('click', function (event) {
5258      event.preventDefault()
5259      event.stopPropagation()
5260      clearTimeout(clickTimer)
5261      openDeleteConfirm(state, definition, $deleteButton)
5262    })
5263    $optionList.append($option)
5264  }
5265
5266  /**
5267   * 입력값과 편집 상태를 기준으로 생성 option 및 태그 option 목록 전체를 다시 그립니다.
5268   * visibleItems는 키보드 이동 순서와 동일하게 구성하고 렌더 후 popover 위치를 재계산합니다.
5269   *
5270   * @param {Object} state 현재 combobox 상태
5271   */
5272  const renderPopover = function (state) {
5273    if (activeState !== state) return
5274
5275    const $optionList = state.$popover.find('._member_tag_option_list').empty()
5276    const $queryError = state.$popover.find('._member_tag_query_error')
5277    const query = $.trim(state.query)
5278    const lowerQuery = query.toLowerCase()
5279
5280    $queryError.text(state.queryError).toggle(!!state.queryError)
5281    state.visibleItems = []
5282
5283    if (!state.ready) {
5284      $optionList.append('<div class="member-tag-option-empty">태그를 불러오는 중입니다.</div>')
5285      positionPopover(state)
5286      return
5287    }
5288
5289    // 입력값은 별도 검색 폼 없이 anchor input에서 바로 option 필터로 사용합니다.
5290    const filteredDefinitions = definitions.filter(function (definition) {
5291      return !lowerQuery || definition.tag_name.toLowerCase().indexOf(lowerQuery) !== -1
5292    })
5293    const hasExactMatch = definitions.some(function (definition) {
5294      return definition.tag_name.toLowerCase() === lowerQuery
5295    })
5296    const canCreate = query && !hasExactMatch
5297
5298    // 정확히 일치하는 정의가 없을 때 목록 최상단에 생성 option을 노출하고 제한은 서버에서 검증합니다.
5299    if (canCreate) {
5300      const createItem = {
5301        type: 'create',
5302        id: state.optionListId + '-create'
5303      }
5304      const createIndex = state.visibleItems.push(createItem) - 1
5305      const $createOption = $('<div class="_member_tag_option_item member-tag-option-item member-tag-create-option text-primary" role="option" aria-selected="false">')
5306        .attr('id', createItem.id)
5307        .toggleClass('is-active', state.activeIndex === createIndex)
5308        .append('<span class="member-tag-option-check" aria-hidden="true"></span>')
5309        .append($('<span class="member-tag-create-label">').text('생성'))
5310        .append($('<span>').text('#' + query))
5311
5312      $createOption.on('mousedown', function (event) {
5313        event.preventDefault()
5314      }).on('mouseenter', function () {
5315        state.activeIndex = createIndex
5316        updateActiveOption(state)
5317      }).on('click', function () {
5318        createDefinition(state)
5319      })
5320      $optionList.append($createOption)
5321    }
5322
5323    // 수정 대상 option만 inline input으로 교체하고 나머지는 일반 option 행으로 유지합니다.
5324    filteredDefinitions.forEach(function (definition) {
5325      const definitionItem = {
5326        type: 'definition',
5327        definition: definition,
5328        id: state.optionListId + '-' + definition.tag_code
5329      }
5330      const itemIndex = state.visibleItems.push(definitionItem) - 1
5331
5332      if (state.editing && state.editing.definition.tag_code === definition.tag_code) {
5333        renderRenameOption(state, $optionList, definition, definitionItem.id)
5334      } else {
5335        renderDefinitionOption(state, $optionList, definition, itemIndex)
5336      }
5337    })
5338
5339    if (state.visibleItems.length === 0) {
5340      $optionList.append('<div class="member-tag-option-empty">일치하는 태그가 없습니다.</div>')
5341      state.activeIndex = -1
5342    } else if (state.activeIndex < 0 || state.activeIndex >= state.visibleItems.length) {
5343      state.activeIndex = 0
5344    }
5345
5346    updateActiveOption(state)
5347    positionPopover(state)
5348  }
5349
5350  /**
5351   * 현재 popover와 전역 resize/scroll listener를 제거하고 anchor를 비활성 표시 상태로 복
5351원합니다.
5352   * 삭제 확인 상태도 함께 정리해 body에 고립된 보조 popover가 남지 않게 합니다.
5353   */
5354  const closePopover = function () {
5355    if (!activeState) return
5356
5357    const state = activeState
5358    closeDeleteConfirm(state, false)
5359    activeState = null
5360    $(window).off('.memberTagManagePopover')
5361    if (state.scrollHandler) {
5362      document.removeEventListener('scroll', state.scrollHandler, true)
5363    }
5364    if (state.$popover) state.$popover.remove()
5365    renderField(state.$anchor, memberTagMap[state.memberCode] || [])
5366  }
5367
5368  /**
5369   * body 직속 fixed popover를 만들고 viewport 위치 갱신 listener를 등록합니다.
5370   * 테이블과 중간 컨테이너의 overflow 영향을 피하기 위해 anchor 내부에 넣지 않습니다.
5371   *
5372   * @param {Object} state 현재 combobox 상태
5373   */
5374  const createPopover = function (state) {
5375    state.$popover = $('<div class="_member_tag_popover member-tag-popover">')
5376      .append('<div class="member-tag-popover-guide">옵션 선택 또는 생성</div>')
5377      .append('<div class="_member_tag_query_error member-tag-query-error" style="display:none;"></div>')
5378      .append($('<div class="_member_tag_option_list member-tag-option-list" role="listbox" aria-multiselectable="true">').attr('id', state.optionListId))
5379      .appendTo('body')
5380
5381    $(window).on('resize.memberTagManagePopover scroll.memberTagManagePopover', function () {
5382      positionPopover(state)
5383    })
5384    state.scrollHandler = function () {
5385      positionPopover(state)
5386    }
5387    document.addEventListener('scroll', state.scrollHandler, true)
5388  }
5389
5390  /**
5391   * 회원태그 필드를 활성화하고 해당 anchor에 combobox 상태와 popover를 연결합니다.
5392   * 데이터가 아직 없으면 popover를 연 상태로 회원 태그와 definitions를 지연 조회합니다.
5393   *
5394   * @param {string} memberCode 회원 코드
5395   * @param {HTMLElement|JQuery=} anchor 클릭 또는 focus된 회원태그 필드
5396   * @returns {Object|null} 활성 상태 또는 열 수 없을 때 null
5397   */
5398  const open = function (memberCode, anchor) {
5399    ensureStyles()
5400    const $anchor = anchor ? $(anchor) : $('._member_tag_button[data-member-code="' + memberCode + '"]').first()
5401    if ($anchor.length === 0 || $anchor.attr('data-editable') === 'N') return null
5402    if (activeState && activeState.$anchor[0] === $anchor[0]) {
5403      focusQueryInput(activeState)
5404      return activeState
5405    }
5406
5407    closePopover()
5408    const hasMemberData = Object.prototype.hasOwnProperty.call(memberTagMap, memberCode)
5409    const initialCodes = getMemberTagCodes(memberCode)
5410    // persisted는 서버 확정값, desired는 optimistic 화면값으로 분리해 실패 원복 기준을 보존합니다.
5411    const state = {
5412      memberCode: memberCode,
5413      $anchor: $anchor,
5414      $popover: null,
5415      optionListId: 'member-tag-options-' + memberCode,
5416      query: '',
5417      queryError: '',
5418      activeIndex: 0,
5419      visibleItems: [],
5420      persistedTagCodes: copyCodes(initialCodes),
5421      desiredTagCodes: copyCodes(initialCodes),
5422      ready: definitionsLoaded && hasMemberData,
5423      isCreating: false,
5424      isSyncing: false,
5425      isDeletingDefinition: false,
5426      pendingDefinitionDelete: null,
5427      deleteConfirm: null,
5428      editing: null
5429    }
5430
5431    activeState = state
5432    createPopover(state)
5433    renderField($anchor, memberTagMap[memberCode] || [])
5434    renderPopover(state)
5435    focusQueryInput(state)
5436
5437    // 목록 최초 클릭처럼 캐시가 없는 경우 현재 회원만 조회하고 활성 상태가 유지될 때만 반영합니다.
5438    if (!state.ready) {
5439      requestTagData([memberCode], true).done(function () {
5440        if (activeState !== state) return
5441
5442        state.ready = true
5443        state.persistedTagCodes = getMemberTagCodes(memberCode)
5444        state.desiredTagCodes = copyCodes(state.persistedTagCodes)
5445        renderMember(memberCode)
5446        renderPopover(state)
5447        focusQueryInput(state)
5448      }).fail(function (message) {
5449        if (activeState === state) closePopover()
5450        showToast(message)
5451      })
5452    }
5453
5454    return state
5455  }
5456
5457  /**
5458   * 위·아래 방향키에 따라 active option을 순환 이동하고 화면 밖 option을 보이도록 스크롤합니다.
5459   *
5460   * @param {Object} state 현재 combobox 상태
5461   * @param {number} delta 1은 아래, -1은 위
5462   */
5463  const moveActiveOption = function (state, delta) {
5464    if (state.visibleItems.length === 0) return
5465
5466    state.activeIndex += delta
5467    if (state.activeIndex < 0) state.activeIndex = state.visibleItems.length - 1
5468    if (state.activeIndex >= state.visibleItems.length) state.activeIndex = 0
5469    updateActiveOption(state)
5470
5471    const item = state.visibleItems[state.activeIndex]
5472    const $item = state.$popover.find('#' + item.id)
5473    if ($item.length > 0 && $item[0].scrollIntoView) {
5474      $item[0].scrollIntoView({ block: 'nearest' })
5475    }
5476  }
5477
5478  /**
5479   * 동적으로 추가되는 목록 행까지 처리할 수 있도록 document 위임 이벤트를 한 번 등록합니다.
5480   * 필드 열기, 입력 필터, 키보드 이동, 바깥 클릭 닫기를 하나의 namespace로 관리합니다.
5481   */
5482  const bindEvents = function () {
5483    // 목록 셀의 필드 바깥 여백도 동일한 combobox anchor를 열어 셀 전체를 click target으로 유지합니다.
5484    $(document).on('click.memberTagManage', 'li.member-tag-cell-editable', function (event) {
5485      if ($(event.target).closest('._member_tag_button').length > 0) return
5486
5487      const $field = $(this).find('._member_tag_button').first()
5488      if ($field.length === 0) return
5489
5490      open(String($field.data('member-code')), $field)
5491    // 필드 click/Enter/Space는 동일한 open 함수로 연결하고 읽기 전용 필드는 제외합니다.
5492    }).on('click.memberTagManage', '._member_tag_button', function (event) {
5493      if ($(this).attr('data-editable') === 'N') return
5494
5495      open(String($(this).data('member-code')), this)
5496    }).on('keydown.memberTagManage', '._member_tag_button', function (event) {
5497      if ($(this).attr('data-editable') === 'N' || $(event.target).is('input,button')) return
5498      if (event.which !== 13 && event.which !== 32) return
5499
5500      event.preventDefault()
5501      open(String($(this).data('member-code')), this)
5502    // 입력할 때마다 option 필터와 생성 option을 갱신하며 서버 요청은 Enter 전까지 보내지 않습니다.
5503    }).on('input.memberTagManage', '._member_tag_query_input', function () {
5504      if (!activeState || activeState.$anchor[0] !== $(this).closest('._member_tag_button')[0]) return
5505
5506      activeState.query = $(this).val()
5507      activeState.queryError = ''
5508      activeState.activeIndex = 0
5509      renderPopover(activeState)
5510    // 방향키/Enter/Esc/빈 입력 Backspace를 Notion형 combobox 키보드 동작에 매핑합니다.
5511    }).on('keydown.memberTagManage', '._member_tag_query_input', function (event) {
5512      const state = activeState
5513      if (!state) return
5514
5515      if (event.which === 40) {
5516        event.preventDefault()
5517        moveActiveOption(state, 1)
5518      } else if (event.which === 38) {
5519        event.preventDefault()
5520        moveActiveOption(state, -1)
5521      } else if (event.which === 13) {
5522        event.preventDefault()
5523        activateVisibleItem(state)
5524      } else if (event.which === 27) {
5525        event.preventDefault()
5526        if (state.deleteConfirm) {
5527          closeDeleteConfirm(state, true)
5528        } else {
5529          closePopover()
5530        }
5531      } else if (event.which === 8 && $(this).val() === '' && state.desiredTagCodes.length > 0) {
5532        event.preventDefault()
5533        const lastTagCode = state.desiredTagCodes[state.desiredTagCodes.length - 1]
5534        const definition = definitions.filter(function (item) {
5535          return item.tag_code === lastTagCode
5536        })[0]
5537        if (definition) toggleDefinition(state, definition, false)
5538      }
5539    // popover와 anchor 밖 click은 닫되 저장 중인 inline 수정은 완료된 뒤 닫도록 보류합니다.
5540    }).on('click.memberTagManageOutside', function (event) {
5541      if (!activeState) return
5542      if (activeState.deleteConfirm) {
5543        if ($(event.target).closest('._member_tag_delete_confirm, ._member_tag_option_delete').length > 0) return
5544
5545        closeDeleteConfirm(activeState, true)
5546        return
5547      }
5548      if ($(event.target).closest('._member_tag_popover, ._member_tag_button').length > 0) return
5549      if (activeState.editing) {
5550        if (activeState.editing.error) return
5551        if (activeState.editing.isSaving) {
5552          activeState.editing.closeAfterSave = true
5553          return
5554        }
5555      }
5556
5557      closePopover()
5558    })
5559  }
5560
5561  /**
5562   * 상세 SSR이 data attribute로 전달한 초기 태그와 definitions를 공유 상태에 적재합니다.
5563   * JSON 파싱에 실패하면 빈 목록으로 안전하게 초기화하고 이후 API 조회가 가능하도록 유지합니다.
5564   */
5565  const hydrateInitialFields = function () {
5566    $('._member_tag_button[data-initial-tags]').each(function () {
5567      const $field = $(this)
5568      const memberCode = String($field.data('member-code'))
5569      let initialTags = []
5570      let initialDefinitions = []
5571
5572      try {
5573        initialTags = JSON.parse($field.attr('data-initial-tags') || '[]')
5574        initialDefinitions = JSON.parse($field.attr('data-initial-definitions') || '[]')
5575      } catch (error) {
5576        initialTags = []
5577        initialDefinitions = []
5578      }
5579      memberTagMap[memberCode] = copyTags(initialTags)
5580      if (initialDefinitions.length > 0 || $field.is('[data-initial-definitions]')) {
5581        definitions = copyTags(initialDefinitions)
5582        definitionsLoaded = true
5583      }
5584      renderField($field, memberTagMap[memberCode])
5585    })
5586  }
5587
5588  // 모듈 로딩 시 공통 스타일과 위임 이벤트를 준비하고 DOM ready 후 상세 SSR 필드를 hydrate합니다.
5589  ensureStyles()
5590  bindEvents()
5591  $(hydrateInitialFields)
5592
5593  return {
5594    loadList: loadList,
5595    loadDefinitions: loadDefinitions,
5596    getDefinitions: getDefinitions,
5597    getMemberSearchCondition: getMemberSearchCondition,
5598    open: open
5599  }
5600}())
5601
5602var MEMBER_DATA = (function () {
5603  let $totalCount
5604  let $SITE_CODE
5605  let $UNIT_CODE
5606  let $KEYWORD
5607  let $MEMBER_GROUP
5608  let $PERMISSIONS
5609  let $BASEURL
5610  let $PAGE
5611  let $JOIN_ORDER = 'desc'
5612  let $IS_JOIN_ORDER_FIXED_DESC = false
5613  let $IS_MEMBER_TAG_ENABLED = false
5614  let $LOADING = false
5615  let $USE_PUSH_STATE = false
5616  let $groups = []
5617  let $memberListRender
5618  const init = function (site_code, unit_code, keyword, member_group, permissions, base_url, current_page, join_order, isJoinOrderFixedDesc, isMemberTagEnabled) {
5619    $SITE_CODE = site_code
5620    $UNIT_CODE = unit_code
5621    $KEYWORD = keyword
5622    $MEMBER_GROUP = member_group
5623    $PERMISSIONS = permissions
5624    $BASEURL = base_url
5625    $PAGE = current_page
5626    $IS_JOIN_ORDER_FIXED_DESC = !!isJoinOrderFixedDesc
5627    $IS_MEMBER_TAG_ENABLED = !!isMemberTagEnabled
5628    $JOIN_ORDER = $IS_JOIN_ORDER_FIXED_DESC ? 'desc' : (join_order || 'desc')
5629    renderMemberTotalCount()
5630    renderGroup()
5631
5632    // `#` 검색으로 진입한 경우에만 정의를 먼저 확보해 첫 목록 요청부터 태그와 일반 검색어를 정확히 분리합니다.
5633    // 일반 목록은 즉시 조회하고, 정의 조회가 실패한 `#` 입력은 기존 회원 검색으로 안전하게 이어갑니다.
5634    if ($IS_MEMBER_TAG_ENABLED && typeof MEMBER_TAG_MANAGE !== 'undefined' && $.trim(String(keyword || '')).charAt(0) === '#') {
5635      MEMBER_TAG_MANAGE.loadDefinitions().always(function () {
5636        renderList(keyword, member_group, permissions, base_url, current_page)
5637      })
5638    } else {
5639      renderList(keyword, member_group, permissions, base_url, current_page)
5640    }
5641  }
5642
5643  /**
5644	 * @param site_code
5645	 * @param unit_code
5646	 * @param keyword
5647	 * @param member_group
5648	 */
5649  var renderList = function (keyword, member_group, permissions, base_url, current_page) {
5650    member_group = member_group || ''  // null/undefined를 빈 문자열로 정규화
5651    keyword = String(keyword || '')
5652    $KEYWORD = keyword
5653    $MEMBER_GROUP = member_group
5654    $BASEURL = base_url
5655    $PAGE = current_page
5656
5657    var permissions = JSON.parse($PERMISSIONS)
5658    $LOADING = true
5659
5660    // 화면에는 사용자가 입력한 원본 문자열을 유지하고 서버에는 태그 코드와 나머지 검색어를 분리해 전달합니다.
5661    // original_keyword는 페이지네이션 URL 복원에만 사용하며, 실제 DB 검색에는 memberSearchCondition.keyword만 사용합니다.
5662    // 일치하는 사이트 태그가 없으면 memberTagCode는 비우고 원본 문자열 전체를 기존 keyword로 반환합니다.
5663    let memberSearchCondition = {
5664      keyword: keyword,
5665      memberTagCode: ''
5666    }
5667    if ($IS_MEMBER_TAG_ENABLED && typeof MEMBER_TAG_MANAGE !== 'undefined') {
5668      memberSearchCondition = MEMBER_TAG_MANAGE.getMemberSearchCondition(keyword)
5669    }
5670
5671    let isEdit = false
5672    let _href = 'javascript:;'
5673    let _onClick = 'alert(LOCALIZE_ADMIN.설명_권한이없습니다())'
5674    if (permissions.admin_member || permissions.manager_setting) {
5675      isEdit = true
5676      _onClick = ''
5677    }
5678
5679    const _key = 'MEMBER_LIST_' + $SITE_CODE
5680    TOKEN.makeToken(5, _key, function (token, tokenKey) {
5681      $memberListRender = $.ajax({
5682        url: '/admin/ajax/member/get_member.cm',
5683        type: 'GET',
5684        data: {
5685          keyword: memberSearchCondition.keyword,
5686          original_keyword: keyword,
5687          member_tag_code: memberSearchCondition.memberTagCode,
5688          page: current_page,
5689          member_group,
5690          join_order: $JOIN_ORDER,
5691          token,
5692          token_key: tokenKey
5693        },
5694        dataType: 'json',
5695        async: true,
5696        cache: false,
5697        success: function (result) {
5698          $('#table-header').css('display', '');
5699          $('#card-head').show()
5700          $('#order_list_loader_sub').hide()
5701
5702          if (result.message) {
5703            alert(result.message)
5704            return
5705          }
5706
5707          $('#total-member-count').text(result.member_count)
5708
5709          const visibleMemberCodes = []
5710          result.members.forEach(function (member) {
5711            if ($IS_MEMBER_TAG_ENABLED) {
5712              visibleMemberCodes.push(member.code)
5713            }
5714            if (isEdit) {
5715              _href = base_url + '&mode=add&idx=' + member.idx
5716            }
5717            let html = '<ul class="content _tbody" id="member_item_' + member.code + '" data-nick="' + member.name + '" data-app="">'
5718            html += '<li class="check">' +
5719							'<div class="checkbox checkbox-styled no-margin">' +
5720							'<label>' +
5721							'<input type="checkbox" value="' + member.code + '"' +
5722							'   data-idx="' + member.idx + '"' +
5723							'   data-code="' + member.code + '"' +
5724							' onclick="TABLE_LIST.checkItem(function(idxs){MEMBER_GROUP.checkMember(idxs);});" />' +
5725							'<span></span>' +
5726							'</label>' +
5727							'</div>' +
5728							'</li>'
5729
5730            // =======================================================
5731            html += '<li class="nick">'
5732
5733            if (member.units) {
5734              if (member.units === 'notfound') {
5735                html += '<span style="margin-right:5px; width: 20px;\n' +
5736									'    height: 20px;\n' +
5737									'    display: inline-block;\n' +
5738									'    background: #fff;\n' +
5739									'    border: 1px solid #ccc;\n' +
5740									'    text-align: center;\n' +
5741									'    border-radius: 50%;"><i class="im-icon im-ico-global" style="display: inline-block;\n' +
5742									'    margin-top: 3px;\n' +
5743									'    font-size: 10px;"></i></span>'
5744              } else {
5745                html += '<img src="' + member.units + '" style="width:20px; margin-right: 5px;">'
5746              }
5747            }
5748
5749            html += '<a data-mixpanel="click_bo_member_list_detail" href="' + _href + '" onclick="' + _onClick + '">'
5750            html += member.name
5751
5752            if (member.is_member_inactive > 0) {
5753              html += '<span class="badge style-primary" style="margin-left: 5px;">' + getLocalizeString('설명_휴면예정', '', '휴면 예정') + '</span>'
5754            }
5755
5756            if (member.is_admin) {
5757              html += '<span class="badge">' + getLocalizeString('설명_소유자', '', '소유자') + '</span>'
5758            } else {
5759              if (result.join_types.join_type_allow_type_list[member.join_type_code] !== 'auto' && !member.is_confirm) {
5760                if (result.join_types.join_type_allow_type_list[member.join_type_code] === 'email') {
5761                  html += '<span class="badge style-primary">' + getLocalizeString('설명_메일미인증', '', '메일 미인증') + '</span>'
5762                } else {
5763                  html += '<span class="badge style-primary">' + getLocalizeString('설명_승인대기', '', '승인 대기') + '</span>'
5764                }
5765              }
5766            }
5767            html += '</a></li>'
5768
5769            // =======================================================
5770
5771            html += '<li class="account"><a data-mixpanel="click_bo_member_list_detail" href="' + _href + '" onclick="' + _onClick + '">' + member.uid + '</a></li>'
5772
5773            // =======================================================
5774
5775            if (member.join_type_code) {
5776              html += '<li class="account">' + result.join_types.join_type_name_list[member.join_type_code] + '</li>'
5777            } else {
5778              html += '<li class="account"></li>'
5779            }
5780
5781            // =======================================================
5782
5783            let _group = ''
5784            html += '<li class="group"><div class="dropdown">'
5785            if (member.groups.has_group) {
5786              _group = member.groups.data.main_group_title
5787              if (member.groups.count > 1) {
5788                _group += '<span class="text-primary"> +' + (member.groups.count - 1) + '</span>'
5789              }
5790            } else {
5791              _group = getLocalizeString('설명_그룹없음', '', '그룹 없음')
5792            }
5793            if (isEdit) {
5794              html += '<button id="dLabel" type="button" onclick="MEMBER_GROUP.setGroupCheck(' + member.idx + ')" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" class="btn-flat no-padding _drop_group_btn_' + member.idx + '">' +
5795								_group +
5796								'</button>' +
5797								'<ul class="dropdown-menu animation-dock check" role="menu" aria-labelledby="dLabel">' +
5798								'<div class="select_group _drop_group_list _drop_group_list_' + member.idx + '"' +
5799								'data-member="' + member.idx + '"'
5800
5801              if (member.groups.has_group && member.groups._group_arr) {
5802                html += 'data-groups="['
5803                html += member.groups._group_arr.join(',')
5804                html += ']"'
5805              } else {
5806                html += 'data-groups=""'
5807              }
5808
5809              if (member.groups.has_group && member.groups._subscribe_group_arr) {
5810                html += 'data-subscribe_groups="'
5811                html += member.groups._subscribe_group_arr.join(',')
5812                html += '"'
5813              } else {
5814                html += 'data-subscribe_groups=""'
5815              }
5816
5817              html += 'data-nogroup="'
5818              if (member.groups.count === 0) {
5819                html += 'Y"'
5820              } else {
5821                html += 'N"'
5822              }
5823              html += '></div>' +
5824								'<div class="footer">' +
5825								'<li class="divider"></li>' +
5826								'<li>' +
5827								'<a href="javascript:;" onclick="MEMBER_GROUP.openGroupForm()">' + getLocalizeString('설명_새그룹', '', '새 그룹') + '</a>' +
5828								'</li>' +
5829								'</div>' +
5830								'</ul>'
5831            } else {
5832              html += '<a href="' + _href + '" onclick="' + _onClick + '">' + _group + '</a>'
5833            }
5834            html += '</div></li>'
5835
5836            if (result.shopping_groups.is_shopping_group) {
5837              html += '<li class="rating"><div class="dropdown">'
5838              if (isEdit) {
5839                html += '<button type="button" data-toggle="dropdown" aria-haspopup="true" onclick="MEMBER_GROUP.setShoppingGroupCheck(' + member.idx + ')" aria-expanded="false" class="btn-flat no-padding _drop_shopping_group_btn_' + member.idx + '">' + member.shopping_groups.title + '</button>' +
5840									'<ul class="dropdown-menu animation-dock check" role="menu">' +
5841									'<div class="select_group _drop_shopping_group_list _drop_shopping_group_list_' + member.idx + '"' +
5842									'data-groups="['
5843                html += member.shopping_groups.idx
5844                html += ']" data-member="' + member.idx + '" data-nogroup="'
5845                if (member.groups.count === 0) {
5846                  html += 'Y'
5847                } else {
5848                  html += 'N'
5849                }
5850                html += '">' +
5851									'</div>' +
5852									'<div class="footer">' +
5853									'<li class="divider"></li>' +
5854									'<li><a href="javascript:;" onclick="MEMBER_GROUP.openGroupForm(\'\', \'' + member.shopping_groups.group_type + '\')">' +
5855									getLocalizeString('설명_새그룹', '', '새 그룹') + '</a></li>' +
5856									'</div>\n' +
5857									'</ul></li>'
5858                html += '</div></li>'
5859              } else {
5860                html += member.shopping_groups.title + '</div></li>'
5861              }
5862            }
5863
5864            // =======================================================
5865
5866            html += '<li class="date_join">' + member.join_time_message + '</li>'
5867
5868            // =======================================================
5869
5870            html += '<li class="date_mileage text-right">' + member.point.message + '</li>'
5871
5872            // =======================================================
5873
5874            html += '<li class="count_post hidden-xs hidden-sm">' + member.post_count + '/ ' +
5875							member.comment_count + '/ ' + member.review_count + '/ ' + member.qna_count + '</li>'
5876
5877            // =======================================================
5878
5879            html += '<li class="purchase text-right hidden-xs hidden-sm">' + member.purchase.message + '</li>'
5880
5881            // =======================================================
5882
5883            if ($IS_MEMBER_TAG_ENABLED) {
5884              // 회원태그 셀은 목록 응답의 태그값을 직접 사용하지 않고 빈 combobox anchor만 만듭니다.
5885              // 목록 전용 class로 전체 태그 노출과 hover/focus 기반 제거 버튼을 상세 화면과 분리합니다.
5886              html += '<li class="member_tag' + (isEdit ? ' member-tag-cell-editable' : '') + '">'
5887              if (isEdit) {
5888                html += '<div class="_member_tag_button member-tag-field member-tag-field-list" data-member-code="' + member.code + '" data-editable="Y" data-display-mode="list" role="combobox" aria-haspopup="listbox" aria-expanded="false" tabindex="0"></div>'
5889              } else {
5890                html += '<div class="_member_tag_button member-tag-field member-tag-field-list member-tag-field-readonly" data-member-code="' + member.code + '" data-editable="N" data-display-mode="list"></div>'
5891              }
5892              html += '</li>'
5893            }
5894
5895            // =======================================================
5896
5897            html += '<li class="memo">'
5898            if (member.is_admin_memo) {
5899              html += '<a href="javascript:;" onclick="adminMember.openAdminMemoForm(' + '\'' + member.code + '\'' + ')" class="btn btn-flat">'
5900            } else {
5901              html += '<a href="javascript:;" onclick="adminMember.openAdminMemoForm(' + '\'' + member.code + '\'' + ')" class="btn btn-flat text-gray-bright">'
5902            }
5903            html +=	'<i class="icon-note"></i>'
5904            if (member.is_admin_memo) {
5905              html += '<div class="admin_memo">' + member.admin_memo + '</div>'
5906            }
5907            html +=	'</a></li>'
5908
5909            // =======================================================
5910
5911            // 나쵸코드 연동되지 않은 경우에만 앱 관련 컬럼 표시
5912            if (typeof isNachocodeIntegration === 'undefined' || !isNachocodeIntegration) {
5913              if (member.is_use_android) {
5914                html += '<li class="app_android hidden-xs hidden-sm"><i class="zmdi zmdi-google-play"></i></li>'
5915              } else {
5916                html += '<li class="app_android hidden-xs hidden-sm"></li>'
5917              }
5918
5919              // =======================================================
5920
5921              if (member.is_use_ios) {
5922                html += '<li class="app_ios hidden-xs hidden-sm"><i class="zmdi zmdi-apple"></i></li>'
5923              } else {
5924                html += '<li class="app_ios hidden-xs hidden-sm"></li>'
5925              }
5926            }
5927
5928            // =======================================================
5929
5930            html += '<li class="action"><div class="dropdown">' +
5931							'<button id="dLabel" type="button" data-toggle="dropdown"' +
5932							'aria-haspopup="true" aria-expanded="false"' +
5933							'class="btn btn-flat">' +
5934							'<i class="zmdi zmdi-more"></i>' +
5935							'</button>' +
5936							'<ul class="dropdown-menu animation-dock right" role="menu" aria-labelledby="dLabel">'
5937
5938            if (member.is_admin !== 'Y' && result.join_types.join_type_allow_type_list[member.join_type_code] !== 'auto' && !member.is_confirm) {
5939              html += '<li>' +
5940								'<a href="javascript:;" onclick="adminMember.openUpdateMemberConfirm(' + '\'' + member.code + '\'' + ', ' + '\'' + 'list' + '\'' + ');">' +
5941								getLocalizeString('버튼_가입승인', '', '가입 승인') +
5942								'</a>' +
5943								'</li>' +
5944								'<li role="separator" class="divider"></li>'
5945            }
5946
5947            const deleteMember = [{
5948              member_code: member.code,
5949              delete_old_auth_log: !!member.delete_old_auth_log
5950            }]
5951
5952            html += '<li>' +
5953							'<a href="javascript:;" data-mixpanel="click_bo_member_list_delete_item" data-button-position="dropdown" onclick=\'adminMember.openDeleteMember("' + JSON.stringify(deleteMember).replace(/"/g, '\\"') + '")\'>' +
5954							getLocalizeString('버튼_삭제', '', '삭제') +
5955							'</a>' +
5956							'</li>' +
5957							'<li>' +
5958							'<a href="javascript:;" onclick="adminMember.openMemberPushForm([' + '\'' + member.code + '\'' + ']);">' +
5959							getLocalizeString('버튼_알림', '', '알림') +
5960							'</a>' +
5961							'</li>' +
5962							'<li>' +
5963							'<a href="javascript:;" onclick="adminMember.openMemberSmsForm([' + '\'' + member.code + '\'' + ']);">' +
5964							'SMS' +
5965							'</a>' +
5966							'</li>' +
5967							'<li>' +
5968							'<a href="javascript:;" onclick="adminMember.openManageMemberPoint(' + '\'' + member.code + '\'' + ');">' +
5969							getLocalizeString('버튼_적립금지급차감', '', '적립금 지급/차감') +
5970							'</a>' +
5971							'</li>' +
5972							'</ul>' +
5973							'</li>'
5974            html += '</ul>'
5975
5976            $('#UI_TABLE').append(html)
5977          })
5978
5979          // FS-3646 IB-ISMS-list : 검색 조건 없이 진입한 경우 빈 표 대신 검색 안내를 노출합니다.
5980          // 목록 초기화는 `.content` 를 비우는 방식이라 안내 요소는 매 응답마다 직접 제거한 뒤 다시 붙입니다.
5981          $('#isms-search-guide').remove()
5982          if (result.search_required) {
5983            $('#UI_TABLE').append(
5984              '<div id="isms-search-guide" style="padding:40px 0;text-align:center;color:#999;">' +
5985              getLocalizeString('설명_개인정보보호를위해검색후조회가능', '', '개인정보 보호를 위해 검색 조건을 입력한 후 조회할 수 있습니다.') +
5986              '</div>'
5987            )
5988          }
5989
5990          // 현재 페이지의 행 DOM 구성이 끝난 뒤 회원 코드를 묶어 태그 API를 한 번만 지연 호출합니다.
5991          // 응답을 받은 MEMBER_TAG_MANAGE가 각 빈 anchor에 회원의 태그 전체를 chip으로 갱신합니다.
5992          if ($IS_MEMBER_TAG_ENABLED && typeof MEMBER_TAG_MANAGE !== 'undefined') {
5993            MEMBER_TAG_MANAGE.loadList(visibleMemberCodes)
5994          }
5995
5996          // 만약 renderGroup 이 늦게 반환되어 있으면 이를 처리합니다.
5997          if (!$groups) {
5998            var interval = setInterval(function () {
5999              if ($groups) {
6000                clearInterval(interval)
6001              }
6002            }, 10)
6003          }
6004
6005          $groups.forEach(function (group) {
6006            MEMBER_GROUP.addDropGroupList(group)
6007          })
6008
6009          TABLE_LIST.init($('#UI_TABLE'))
6010
6011          // paginate
6012          $('#paginate').append(result.paginate)
6013
6014          $LOADING = false
6015
6016          const qs = MEMBER_DATA.getUrlParams()
6017          if (qs && qs.mc) {
6018            $('html, body').animate({ scrollTop: $('#member_item_' + qs.mc).offset().top - 55 }, 0)
6019          }
6020        },
6021        error: function (req, textStatus, error) {
6022          if (req.statusText === 'abort') {
6023            return
6024          }
6025
6026          alert(getLocalizeString('설명_잠시후다시시도해주세요', '', '잠시 후 다시 시도해주세요.'))
6027        }
6028      })
6029    })
6030  }
6031
6032  var renderGroup = function () {
6033    $.ajax({
6034      url: '/admin/ajax/member/get_group.cm',
6035      type: 'GET',
6036      dataType: 'json',
6037      async: true,
6038      cache: false,
6039      success: function (result) {
6040        if (result.groups) {
6041          $groups = result.groups
6042          $groups.forEach(function (group) {
6043            MEMBER_GROUP.addGroupList(group)
6044          })
6045        }
6046
6047        renderGroupCount()
6048      },
6049      error: function () {
6050        alert(getLocalizeString('설명_잠시후다시시도해주세요', '', '잠시 후 다시 시도해주세요.'))
6051      }
6052    })
6053  }
6054
6055  var renderGroupCount = function (changed_member_groups) {
6056    if (!changed_member_groups) {
6057      changed_member_groups = []
6058    }
6059
6060    $groupCountRender = $.ajax({
6061      url: '/admin/ajax/member/get_group_count.cm',
6062      type: 'GET',
6063      data: {
6064        changed_member_groups
6065      },
6066      dataType: 'json',
6067      async: true,
6068      cache: false,
6069      success: function (result) {
6070        if (result.nogroup_cnt) {
6071				  $('#total-no-group-count').text(result.nogroup_cnt)
6072        }
6073        result.groups.forEach(function (group) {
6074          const $selector = $('#group_item_count_' + group.code)
6075          if ($selector.length > 0) {
6076            $selector.text(group.member_cnt)
6077            MEMBER_GROUP.setGroupMemberCount(group)
6078          }
6079        })
6080      },
6081      error: function (req, textStatus, error) {
6082        if (req.statusText === 'abort') {
6083          return
6084        }
6085
6086        alert(getLocalizeString('설명_잠시후다시시도해주세요', '', '잠시 후 다시 시도해주세요.'))
6087      }
6088    })
6089  }
6090
6091  var renderMemberTotalCount = function () {
6092    $totalCountRender = $.ajax({
6093      url: '/admin/ajax/member/get_total_count.cm',
6094      type: 'GET',
6095      dataType: 'json',
6096      async: true,
6097      cache: false,
6098      success: function (result) {
6099        var interval = setInterval(function () {
6100          const $selector = $('#total-left-member-count')
6101          if ($selector.length > 0) {
6102            $totalCount = result.count
6103            $selector.text(result.count)
6104            clearInterval(interval)
6105          }
6106        }, 1)
6107      },
6108      error: function (req, textStatus, error) {
6109        if (req.statusText === 'abort') {
6110          return
6111        }
6112
6113        alert(getLocalizeString('설명_잠시후다시시도해주세요', '', '잠시 후 다시 시도해주세요.'))
6114      }
6115    })
6116  }
6117
6118  /**
6119	 *
6120	 * @param state
6121	 * @param title
6122	 * @param url
6123	 * @param mode
6124	 * @param code
6125	 * @param page
6126	 */
6127  function toggleJoinOrder () {
6128    if ($LOADING || $IS_JOIN_ORDER_FIXED_DESC) return
6129
6130    $JOIN_ORDER = $JOIN_ORDER === 'desc' ? 'asc' : 'desc'
6131
6132    // 정렬 아이콘 토글
6133    $('.date_join .md').toggleClass('md-expand-less md-expand-more')
6134
6135    // 목록 초기화
6136    if ($('#UI_TABLE').find('.content').length > 0) {
6137      TABLE_LIST.cancelCheckItem()
6138    }
6139    $('#card-head').hide()
6140    $('#table-header').css('display', 'none')
6141    $('.content').html('')
6142    TABLE_LIST.init($('#UI_TABLE'))
6143    $('#paginate').html('')
6144    $('#order_list_loader_sub').show()
6145
6146    // 페이지 1로 리셋 후 데이터 리로드
6147    $PAGE = 1
6148    renderList($KEYWORD, $MEMBER_GROUP, $PERMISSIONS, $BASEURL, 1)
6149
6150    // URL 업데이트 (현재 URL에 join_order 파라미터 추가/변경)
6151    if (typeof history.pushState !== 'undefined') {
6152      var url = new URL(window.location.href)
6153      url.searchParams.set('join_order', $JOIN_ORDER)
6154      history.pushState(null, null, url.toString())
6155    }
6156  }
6157
6158  function changeUrl (state, title, url, mode, code, page) {
6159    if (typeof (history.pushState) === 'undefined') {
6160      location.href = url
6161      return
6162    }
6163
6164    // 데이터를 불러올 때 다른 메뉴 이동이 불가능하도록 -> xhr disconnect 되도록 개선
6165    if ($LOADING) {
6166      $memberListRender.abort()
6167    } else {
6168      renderMemberTotalCount()
6169      renderGroupCount()
6170    }
6171
6172    $LOADING = true
6173
6174    if ($("input[name='keyword']").val()) {
6175      $KEYWORD = $("input[name='keyword']").val()
6176
6177      if (mode === 'group') { // 그룹 이동의 경우 clear keyword
6178        $("input[name='keyword']").val('')
6179        $KEYWORD = ''
6180      }
6181    } else {
6182      $KEYWORD = ''
6183    }
6184
6185    if (mode === 'group' || mode === 'search' || mode === 'paginate') {
6186      if ($('#UI_TABLE').find('.content').length > 0) {
6187			  TABLE_LIST.cancelCheckItem()
6188      }
6189      $('#card-head').hide()
6190      $('#table-header').css('display', 'none');
6191
6192      // 기존 데이터 클리어
6193      $('.content').html('')
6194      TABLE_LIST.init($('#UI_TABLE'))
6195      $('#paginate').html('')
6196
6197      $('#order_list_loader_sub').show()
6198
6199      $PAGE = page
6200    }
6201
6202    if (mode === 'group' || mode === 'search') {
6203      $('#group_list').find('.active').removeClass('active')
6204      $('#group_list').find('.checked').removeClass('checked')
6205
6206      if (!code) {
6207        $('#group_item_total').addClass('active checked')
6208      } else {
6209        if (code === 'shopping') {
6210          $('#shopping_group_default').addClass('active checked')
6211        } else {
6212          $('#group_item_' + code).addClass('active checked')
6213        }
6214      }
6215      _member_group = code
6216
6217      renderList($KEYWORD, code, $PERMISSIONS, $BASEURL, $PAGE)
6218    } else if (mode === 'paginate') {
6219      renderList($KEYWORD, code, $PERMISSIONS, $BASEURL, $PAGE)
6220    }
6221
6222    $USE_PUSH_STATE = true
6223    history.pushState(state, title, url)
6224    MEMBER_GROUP.setCurrentMemberCode(code)
6225  }
6226
6227  return {
6228    init: function (site_code, unit_code, keyword, member_group, permissions, base_url, current_page, join_order, isJoinOrderFixedDesc, isMemberTagEnabled) {
6229      return init(site_code, unit_code, keyword, member_group, permissions, base_url, current_page, join_order, isJoinOrderFixedDesc, isMemberTagEnabled)
6230    },
6231    toggleJoinOrder: function () {
6232      return toggleJoinOrder()
6233    },
6234    changeUrl: function (state, title, url, mode, code, page) {
6235      return changeUrl(state, title, url, mode, code, page)
6236    },
6237    getUsePushState: function () {
6238      return typeof (history.pushState) !== 'undefined'
6239    },
6240    renderGroupCount: function (changed_member_groups) {
6241      // MEMBER_GROUP.resetGroupListHtml();
6242      return renderGroupCount(changed_member_groups)
6243    },
6244    getUrlParams: function () {
6245      const params = {}
6246
6247      window.location.search.replace(/[?&]+([^=&]+)=([^&]*)/gi,
6248        function (str, key, value) {
6249          params[key] = value
6250        }
6251      )
6252
6253      return params
6254    }
6255  }
6256}())
6257
6258/** 080 수신 차단 서비스 관련 로직 */
6259const OPTOUT_NUMBER = (function() {
6260  const http = ({ url, data }) => {
6261    return new Promise((resolve, reject) => {
6262      $.ajax({
6263        url,
6264        data,
6265        async: true,
6266        cache: false,
6267        type: 'POST',
6268        dataType: 'json',
6269        success: function(res) {
6270          if (!res.success) {
6271            return reject(res.message);
6272          }
6273
6274          resolve(res.data);
6275        },
6276      });
6277    });
6278  };
6279
6280  const httpUpload = ({ url, file }) => {
6281    return new Promise((resolve, reject) => {
6282      const formData = new FormData();
6283
6284      formData.append('file', file);
6285
6286      $.ajax({
6287        url,
6288        data: formData,
6289        async: true,
6290        cache: false,
6291        type: 'POST',
6292        dataType: 'json',
6293        processData: false,
6294        contentType: false,
6295        success: function(res) {
6296          if (!res.success) {
6297            return reject(res.message);
6298          }
6299
6300          resolve(res.data);
6301        },
6302      });
6303    });
6304  };
6305
6306  const hideModal = () => {
6307    $.cocoaDialog.hide();
6308  }
6309
6310  const modalAlert = ({ title, content, cancelButtonText, submitButtonText }) => {
6311    return http({
6312      url: '/admin/ajax/optout_number/modal/modal_alert.cm',
6313      data: {
6314        title,
6315        content,
6316        cancel_button_text: cancelButtonText,
6317        submit_button_text: submitButtonText,
6318      },
6319    }).then((data) => {
6320      $.cocoaDialog.open({ type: "optout_number_alert", custom_popup: data.html });
6321    }).catch((message) => window.alert(message));
6322  }
6323
6324  const modalPriceTableOptoutNumber = () => {
6325    return http({
6326      url: '/admin/ajax/optout_number/modal/modal_price_table_optout_number.cm',
6327      data: {},
6328    }).then((data) => {
6329      $.cocoaDialog.open({ type: "price_table_optout_number", custom_popup: data.html });
6330    }).catch((message) => window.alert(message));
6331  }
6332
6333  const modalPaymentOptoutNumber = () => {
6334    return http({
6335      url: '/admin/ajax/optout_number/modal/modal_payment_optout_number.cm',
6336      data: {},
6337    }).then((data) => {
6338      $.cocoaDialog.open({ type: "payment_optout_number", custom_popup: data.html, pc_width: 348, width: 348 });
6339    }).catch((message) => window.alert(message));
6340  }
6341
6342  const modalChangePaymentMethodOptoutNumber = () => {
6343    return http({
6344      url: '/admin/ajax/optout_number/modal/modal_change_payment_method_optout_number.cm',
6345      data: {},
6346    }).then((data) => {
6347      $.cocoaDialog.open({ type: "change_payment_method_optout_number", custom_popup: data.html, pc_width: 348, width: 348 });
6348    }).catch((message) => window.alert(message));
6349  }
6350
6351  /** 080 수신차단 서비스 해지 */
6352  const cancelOptoutNumberService = () => {
6353    return http({
6354      url: '/admin/ajax/optout_number/cancel_optout_number_service.cm',
6355      data: {},
6356    });
6357  };
6358
6359  /** 080 수신차단 서비스 해지 취소 */
6360  const revertCancelOptoutNumberService = () => {
6361    return http({
6362      url: '/admin/ajax/optout_number/revert_cancel_optout_number_service.cm',
6363      data: {},
6364    });
6365  };
6366
6367  /** 수신거부 내역 일괄 업로드 */
6368  const bulkUploadOptoutNumber = ({ file }) => {
6369    return httpUpload({
6370      url: '/admin/ajax/optout_number/bulk_upload_optout_target.cm',
6371      file,
6372    });
6373  };
6374
6375  /** 정기 결제 */
6376  const paymentOptoutNumber = function() {
6377    const data = $("#form_payment_optout_number").serializeObject();
6378
6379    return new Promise((resolve, reject) => {
6380      $.ajax({
6381        async: true,
6382        cache: false,
6383        type: 'POST',
6384        dataType: 'json',
6385        url: '/payment/index.cm',
6386        data: data,
6387        success: function(res){
6388          if(res.result !== 'success'){
6389            return reject(res.msg);
6390          }
6391
6392          resolve(res);
6393        },
6394      });
6395    });
6396  }
6397
6398  /** 결제 수단 변경 */
6399  const changePaymentMethod = function() {
6400    const data = $("#form_change_payment_method").serializeObject();
6401
6402    return http({
6403      url: '/admin/ajax/optout_number/change_billing_method.cm',
6404      data: {
6405        billing_code: data.billing_code,
6406      },
6407    });
6408  }
6409
6410  return {
6411    hideModal,
6412    modalAlert,
6413    modalPriceTableOptoutNumber,
6414    modalPaymentOptoutNumber,
6415    modalChangePaymentMethodOptoutNumber,
6416    cancelOptoutNumberService,
6417    revertCancelOptoutNumberService,
6418    bulkUploadOptoutNumber,
6419    paymentOptoutNumber,
6420    changePaymentMethod,
6421  };
6422})();

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.