// TSL 관리자 — Sample data (확장)

// 원본 admin.html REGION_LIST + 맨 앞 '전체' (원본 line 1197~1216)
const REGIONS = ['전체', '충남', '교육연맹', '강원', '경기', '경남', '경북', '광주', '대구', '대전', '부산', '서울', '세종', '울산', '인천', '전남', '전북', '제주', '충북'];

// 원본 admin.html (line 1540, 1789): (name || '').substring(0, 2) + 'O'
const maskName = (n) => {
  if (!n) return '';
  return (n || '').substring(0, 2) + 'O';
};

const MEMBERS = [
  { member_id: 'TSL00001', name: '김지훈', phone: '4821', region: '서울 강남구',   free_coin: 1800, bonus_coin: 680, role: 'master', rejoin_date: '2024.03.12', last_login: '2026.04.23 13:42', suspended_until: null, permanently_banned: false, inappropriate_count: 0, note: '—' },
  { member_id: 'TSL00002', name: '이서연', phone: '1193', region: '경기 성남시',   free_coin: 1200, bonus_coin: 620, role: 'admin',  rejoin_date: '2024.01.08', last_login: '2026.04.23 13:20', suspended_until: null, permanently_banned: false, inappropriate_count: 1, note: '경고 1회' },
  { member_id: 'TSL00003', name: '박민수', phone: '7756', region: '부산 해운대구', free_coin: 200,  bonus_coin: 140, role: 'regular', rejoin_date: '2023.11.24', last_login: '2026.04.21 09:11', suspended_until: '2026.05.01', permanently_banned: false, inappropriate_count: 3, note: '패널티 진행 중' },
  { member_id: 'TSL00004', name: '최유진', phone: '2240', region: '인천 연수구',   free_coin: 4200, bonus_coin: 920, role: 'admin',  rejoin_date: '2024.05.02', last_login: '2026.04.23 13:58', suspended_until: null, permanently_banned: false, inappropriate_count: 0, note: '—' },
  { member_id: 'TSL00005', name: '정하늘', phone: '8834', region: '대구 수성구',   free_coin: 0,    bonus_coin: 0,   role: 'regular', rejoin_date: '2024.02.17', last_login: '2026.03.20 17:05', suspended_until: null, permanently_banned: false, inappropriate_count: 0, note: '—' },
  { member_id: 'TSL00006', name: '강도윤', phone: '5512', region: '서울 마포구',   free_coin: 3000, bonus_coin: 670, role: 'regular', rejoin_date: '2023.09.30', last_login: '2026.04.23 12:58', suspended_until: null, permanently_banned: false, inappropriate_count: 2, note: '—' },
  { member_id: 'TSL00007', name: '윤서아', phone: '0098', region: '경기 수원시',   free_coin: 700,  bonus_coin: 190, role: 'regular', rejoin_date: '2024.04.21', last_login: '2026.04.23 13:51', suspended_until: null, permanently_banned: false, inappropriate_count: 0, note: '—' },
  { member_id: 'TSL00008', name: '임재현', phone: '3371', region: '광주 서구',     free_coin: 0,    bonus_coin: 0,   role: 'regular', rejoin_date: '2024.06.08', last_login: '—',                 suspended_until: null, permanently_banned: false, inappropriate_count: 0, note: '휴면' },
  { member_id: 'TSL00009', name: '한소영', phone: '6645', region: '서울 송파구',   free_coin: 3400, bonus_coin: 810, role: 'admin',  rejoin_date: '2023.12.15', last_login: '2026.04.23 13:59', suspended_until: null, permanently_banned: false, inappropriate_count: 0, note: '—' },
  { member_id: 'TSL00010', name: '오지안', phone: '9927', region: '대전 유성구',   free_coin: 80,   bonus_coin: 40,  role: 'regular', rejoin_date: '2024.07.03', last_login: '2026.04.23 08:12', suspended_until: null, permanently_banned: true,  inappropriate_count: 5, note: '영구정지' },
  { member_id: 'TSL00011', name: '서은호', phone: '4410', region: '서울 종로구',   free_coin: 1500, bonus_coin: 300, role: 'regular', rejoin_date: '2024.08.14', last_login: '2026.04.22 21:11', suspended_until: null, permanently_banned: false, inappropriate_count: 0, note: '—' },
  { member_id: 'TSL00012', name: '조현우', phone: '2219', region: '경남 창원시',   free_coin: 2100, bonus_coin: 450, role: 'regular', rejoin_date: '2024.09.03', last_login: '2026.04.23 10:47', suspended_until: null, permanently_banned: false, inappropriate_count: 1, note: '—' },
  { member_id: 'TSL00013', name: '배수빈', phone: '7702', region: '서울 강서구',   free_coin: 960,  bonus_coin: 220, role: 'regular', rejoin_date: '2024.10.18', last_login: '2026.04.23 11:33', suspended_until: '2026.05.18', permanently_banned: false, inappropriate_count: 4, note: '패널티 진행 중' },
  { member_id: 'TSL00014', name: '송예림', phone: '5580', region: '전북 전주시',   free_coin: 560,  bonus_coin: 120, role: 'regular', rejoin_date: '2024.11.02', last_login: '2026.04.23 13:02', suspended_until: null, permanently_banned: false, inappropriate_count: 0, note: '—' },
];

const ROLE_META = {
  regular: { label: '일반',       tone: 'neutral' },
  admin:   { label: '지역관리자', tone: 'blue'    },
  master:  { label: '총괄관리자', tone: 'purple'  },
};

const TABS = [
  { id: 'members',  label: '회원관리', icon: 'users', sub: [
    { id: 'search',   label: '검색',     roles: ['regular','admin','master'] },
    { id: 'register', label: '등록',     roles: ['regular','admin','master'] },
    { id: 'permissions', label: '권한 관리', roles: ['master'] },
    { id: 'info',     label: '회원정보', roles: ['master'] },
    { id: 'penalty',  label: '패널티',   roles: ['master'] },
    { id: 'lecture',  label: '강의용 계정', roles: ['master'] },
  ]},
  { id: 'activity', label: '활동기록', icon: 'notebook', sub: [
    { id: 'errors',   label: '오류보고',     roles: ['admin','master'] },
    { id: 'chats',    label: '최근대화',     roles: ['regular','admin','master'] },
    { id: 'tokens',   label: '토큰사용내역', roles: ['master'] },
    { id: 'exchange', label: '토큰교환내역', roles: ['master'] },
  ]},
  { id: 'policy',   label: '정책', icon: 'settings', sub: [
    { id: 'security',    label: '보안관리',     roles: ['master'] },
    { id: 'tokenops',    label: '토큰운영',     roles: ['master'] },
    { id: 'categories',  label: '사용통계',     roles: ['master'] },
    { id: 'tags',        label: '태그',         roles: ['master'] },
    { id: 'appdownload', label: '어플 다운로드', roles: ['regular','admin','master'], mobileOnly: true },
  ]},
];

const ROLE_SIM = [
  { id: 'regular', label: '일반' },
  { id: 'admin',   label: '지역관리자' },
  { id: 'master',  label: '총괄관리자' },
];

const ERRORS = [
  { id: 1, time: '2026.04.23 13:58', reporter: maskName('김지훈'), phone: '4821', region: '서울 강남구', content: '채팅 전송 시 500 에러 발생 — 2회 반복',       action: '서버 로그 확인 중', status: 'open' },
  { id: 2, time: '2026.04.23 13:12', reporter: maskName('최유진'), phone: '2240', region: '인천 연수구', content: '토큰 차감이 이중으로 처리됨',                  action: '로그 분석 중',     status: 'progress' },
  { id: 3, time: '2026.04.23 11:40', reporter: maskName('한소영'), phone: '6645', region: '서울 송파구', content: '프로필 이미지 업로드 실패 (413)',              action: '업로드 제한 상향', status: 'resolved' },
  { id: 4, time: '2026.04.23 10:22', reporter: maskName('강도윤'), phone: '5512', region: '서울 마포구', content: '로그인 후 대시보드 접근 불가',                 action: '—',                status: 'open' },
  { id: 5, time: '2026.04.22 22:05', reporter: maskName('이서연'), phone: '1193', region: '경기 성남시', content: '알림이 오지 않습니다',                          action: '푸시 토큰 재발급', status: 'resolved' },
  { id: 6, time: '2026.04.22 18:33', reporter: maskName('오지안'), phone: '9927', region: '대전 유성구', content: '토큰 교환 실패 (타임아웃)',                    action: '재시도 로직 추가', status: 'progress' },
  { id: 7, time: '2026.04.21 14:02', reporter: maskName('박민수'), phone: '7756', region: '부산 해운대구', content: '음성 입력 인식률이 낮음',                    action: '모델 업데이트 예정', status: 'progress' },
];

// Error report detailed conversations — id → { comment, qaPairs: [{q,a,flagged,time}], hasImage }
const ERROR_DETAILS = {
  1: {
    comment: '대화 중간에 갑자기 "500 Internal Server Error" 창이 뜨면서 답변이 안 옵니다. 같은 질문을 두 번 더 시도했는데 계속 같은 오류가 발생해요. 긴급한 업무인데 복구 부탁드립니다.',
    hasImage: true,
    flagged: [1, 3],
    qaPairs: [
      { q: '오늘 오후 회의 안건 정리해줘 (A사 미팅, 신규 프로젝트 KICK-OFF, 3분기 실적 리뷰)', a: '네, 오늘 오후 회의 안건을 정리해 드리겠습니다. 1. A사 미팅 — 파트너십 제안서 검토 2. 신규 프로젝트 KICK-OFF — 범위·일정·리더 지명 3. 3분기 실적 리뷰 — 지역별 KPI 대비 달성률…', time: '13:52' },
      { q: '1번 A사 미팅 부분 더 자세하게', a: 'Error: 500 Internal Server Error — 서버에서 요청을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.', time: '13:55' },
      { q: '위 내용 다시 시도', a: 'Error: 500 Internal Server Error — 서버에서 요청을 처리하지 못했습니다.', time: '13:57' },
      { q: '세션 초기화 후 A사 미팅 의제 정리', a: '알겠습니다. A사와의 파트너십 제안 미팅 의제는 다음과 같습니다…', time: '13:58' },
    ],
  },
  2: {
    comment: '토큰 차감이 이중으로 처리되는 것 같습니다. 한 번 질문했는데 일반토큰 20개가 빠져야 하는데 40개가 빠졌어요. 두 번 확인했습니다.',
    hasImage: false,
    flagged: [2],
    qaPairs: [
      { q: '이번 주 일정 요약', a: '월요일—팀 미팅, 화요일—고객 방문, 수요일—마감, 목요일—디자인 리뷰, 금요일—정리…', time: '13:08' },
      { q: '토큰이 두 번 차감됐습니다. 확인해주세요.', a: '죄송합니다. 현재 요청은 AI 상담 범위를 벗어납니다. 관리자에게 문의해 주세요.', time: '13:12' },
    ],
  },
  3: {
    comment: '프로필 이미지를 바꾸려는데 "파일 크기가 너무 큽니다"라고만 뜹니다. 원본은 2MB 정도인데 계속 실패합니다.',
    hasImage: true,
    flagged: [1],
    qaPairs: [
      { q: '프로필 이미지 업로드가 안 돼요', a: '업로드 크기 제한(413)에 걸렸습니다. 현재 업로드 제한은 1MB로 설정되어 있어 2MB 이미지는 거부됩니다.', time: '11:38' },
      { q: '제한을 올려주세요', a: '업로드 제한을 5MB로 상향 조정했습니다. 다시 시도해 보세요.', time: '11:40' },
    ],
  },
  4: {
    comment: '로그인은 정상적으로 되는데 대시보드로 넘어가지 않고 빈 화면만 나옵니다. 새로고침하면 다시 로그인 화면으로 돌아가요.',
    hasImage: false,
    flagged: [1],
    qaPairs: [
      { q: '로그인 후 대시보드로 넘어가지 않습니다', a: '현재 세션 초기화 문제로 보입니다. 쿠키를 삭제 후 다시 로그인해 주세요.', time: '10:22' },
    ],
  },
  5: {
    comment: '알림이 전혀 오지 않습니다. 앱 설정에서 알림은 켜져 있어요. 대화 상대방 알림도 안 옵니다.',
    hasImage: false, flagged: [1],
    qaPairs: [
      { q: '푸시 알림이 오지 않아요', a: '푸시 토큰을 재발급했습니다. 앱을 재시작한 후 확인해 주세요.', time: '22:05' },
    ],
  },
  6: {
    comment: '친구에게 토큰을 선물하려고 했는데 "요청 시간 초과"라는 메시지만 뜹니다. 3번 시도했는데 모두 실패했어요.',
    hasImage: true, flagged: [1, 2],
    qaPairs: [
      { q: '토큰 선물 기능이 안 됩니다', a: '토큰 교환 서버 타임아웃(30초)이 발생했습니다. 네트워크 상태를 확인 후 다시 시도해 주세요.', time: '18:30' },
      { q: '네트워크는 정상인데 계속 실패합니다', a: '재시도 로직을 개선하여 배포 예정입니다.', time: '18:33' },
    ],
  },
  7: {
    comment: '음성으로 질문했는데 전혀 다른 내용으로 인식됩니다. 한국어 인식률이 너무 낮아요. 특히 사투리는 거의 못 알아듣습니다.',
    hasImage: false, flagged: [1],
    qaPairs: [
      { q: '[음성] 오늘 날씨 어때?', a: '인식된 내용: "오늘 남새 어때". 다시 말씀해 주세요.', time: '14:02' },
    ],
  },
};

const CHATS = [
  { time: '2026.04.23 13:59', region: '서울 송파구',   name: maskName('한소영'), phone: '6645', content: '오늘 저녁 일정 추천해줘 — 강남 근처로 조용한 레스토랑 3곳 이상, 주차 가능한 곳이면 좋겠어', flag: null },
  { time: '2026.04.23 13:58', region: '인천 연수구',   name: maskName('최유진'), phone: '2240', content: '주말 가족 나들이 코스 짜줘. 초등학생 둘, 유모차 필요해',                                         flag: null },
  { time: '2026.04.23 13:51', region: '경기 수원시',   name: maskName('윤서아'), phone: '0098', content: '이 문서 요약해줘',                                                                             flag: null },
  { time: '2026.04.23 13:42', region: '서울 강남구',   name: maskName('김지훈'), phone: '4821', content: 'PT 프로그램 일주일치 추천',                                                                    flag: null },
  { time: '2026.04.23 13:20', region: '경기 성남시',   name: maskName('이서연'), phone: '1193', content: '창업 아이템 아이디어 10개',                                                                    flag: 'nonsense' },
  { time: '2026.04.23 12:58', region: '서울 마포구',   name: maskName('강도윤'), phone: '5512', content: '영어 이메일 교정 부탁해',                                                                       flag: null },
  { time: '2026.04.23 12:01', region: '부산 해운대구', name: maskName('박민수'), phone: '7756', content: '(부적절 표현 포함된 질문 — 마스킹됨)',                                                         flag: 'obscene' },
  { time: '2026.04.23 10:47', region: '경남 창원시',   name: maskName('조현우'), phone: '2219', content: '프로젝트 회고 템플릿 만들어줘',                                                                 flag: null },
  { time: '2026.04.23 09:32', region: '대전 유성구',   name: maskName('오지안'), phone: '9927', content: '(공격적 언사 — 차단됨)',                                                                        flag: 'aggressive' },
  { time: '2026.04.22 22:11', region: '서울 종로구',   name: maskName('서은호'), phone: '4410', content: '맛집 추천 서울 3곳',                                                                            flag: null },
];

const CHAT_FLAG_META = {
  nonsense:   { label: '무의미',   tone: 'neutral' },
  obscene:    { label: '음란성',   tone: 'danger' },
  aggressive: { label: '공격성',   tone: 'danger' },
  etc:        { label: '기타',     tone: 'warn' },
};

const TOKEN_USAGE = [
  { member_id: 'TSL00001', member: maskName('김지훈'), phone: '4821', region: '서울 강남구',   free_used: 680,  bonus_used: 140, bad: 0, err: 0, note: '—' },
  { member_id: 'TSL00002', member: maskName('이서연'), phone: '1193', region: '경기 성남시',   free_used: 420,  bonus_used: 80,  bad: 1, err: 0, note: '—' },
  { member_id: 'TSL00003', member: maskName('박민수'), phone: '7756', region: '부산 해운대구', free_used: 1200, bonus_used: 200, bad: 3, err: 1, note: '패널티 진행 중' },
  { member_id: 'TSL00004', member: maskName('최유진'), phone: '2240', region: '인천 연수구',   free_used: 880,  bonus_used: 220, bad: 0, err: 1, note: '—' },
  { member_id: 'TSL00006', member: maskName('강도윤'), phone: '5512', region: '서울 마포구',   free_used: 660,  bonus_used: 130, bad: 2, err: 0, note: '경고 1회' },
  { member_id: 'TSL00009', member: maskName('한소영'), phone: '6645', region: '서울 송파구',   free_used: 590,  bonus_used: 190, bad: 0, err: 0, note: '—' },
  { member_id: 'TSL00010', member: maskName('오지안'), phone: '9927', region: '대전 유성구',   free_used: 1020, bonus_used: 380, bad: 5, err: 2, note: '영구정지' },
];

const EXCHANGES = [
  { time: '2026.04.23 13:40', region: '서울', from: maskName('김지훈'), fromPhone: '4821', to: maskName('강도윤'), toPhone: '5512', free: 200, bonus: 0,   status: 'done',     reason: '생일 선물' },
  { time: '2026.04.23 12:11', region: '경기', from: maskName('이서연'), fromPhone: '1193', to: maskName('윤서아'), toPhone: '0098', free: 100, bonus: 50,  status: 'done',     reason: '—' },
  { time: '2026.04.23 10:58', region: '인천', from: maskName('최유진'), fromPhone: '2240', to: maskName('한소영'), toPhone: '6645', free: 0,   bonus: 300, status: 'pending',  reason: '수락 대기' },
  { time: '2026.04.23 09:32', region: '부산', from: maskName('박민수'), fromPhone: '7756', to: maskName('오지안'), toPhone: '9927', free: 500, bonus: 0,   status: 'rejected', reason: '수신자 거절' },
  { time: '2026.04.22 21:22', region: '서울', from: maskName('한소영'), fromPhone: '6645', to: maskName('서은호'), toPhone: '4410', free: 150, bonus: 0,   status: 'done',     reason: '—' },
  { time: '2026.04.22 18:03', region: '경남', from: maskName('조현우'), fromPhone: '2219', to: maskName('김지훈'), toPhone: '4821', free: 0,   bonus: 200, status: 'expired',  reason: '기한 만료' },
];

const EXCHANGE_STATUS = {
  // mock(legacy)
  done:             { label: '완료',       tone: 'ok' },
  pending:          { label: '대기',       tone: 'warn' },
  rejected:         { label: '거절',       tone: 'danger' },
  expired:          { label: '만료',       tone: 'neutral' },
  // DB 실제 status 값들 (/admin/transactions)
  accepted:         { label: '수락',       tone: 'ok' },
  request_accepted: { label: '조르기수락', tone: 'ok' },
  pending_request:  { label: '조르기대기', tone: 'warn' },
  auto_rejected:    { label: '자동반려',   tone: 'neutral' },
  monthly_rejected: { label: '월간반려',   tone: 'neutral' },
  admin_credit:     { label: '관리자지급', tone: 'ok' },
  admin_debit:      { label: '관리자차감', tone: 'danger' },
  cancelled:        { label: '취소',       tone: 'neutral' },
  completed:        { label: '완료',       tone: 'ok' },
};

const ERROR_STATUS = {
  open:     { label: '접수',   tone: 'warn' },
  progress: { label: '처리중', tone: 'blue' },
  resolved: { label: '완료',   tone: 'ok' },
};

// 뱃지 팔레트 (스펙):
// 일반=회색, 관리자=파랑, 마스터=보라, 패널티=빨강, 정지=진홍
// 원본 admin.html line 124~131 hex 값 그대로
const TONE_PALETTE = {
  neutral: { bg: '#f1f5f9', fg: '#475569' },   // badge-regular
  blue:    { bg: '#ede9fe', fg: '#6d28d9' },   // badge-admin (원본은 purple 성격)
  purple:  { bg: '#fef3c7', fg: '#92400e' },   // badge-master (원본 warm yellow)
  ok:      { bg: '#ecfdf5', fg: '#065f46' },   // badge-accepted
  warn:    { bg: '#fffbeb', fg: '#92400e' },   // badge-pending
  danger:  { bg: '#fef2f2', fg: '#991b1b' },   // badge-rejected
  crimson: { bg: '#fef2f2', fg: '#dc2626' },   // 영구정지 진한 빨강
};

// Chat messages per row — for row expansion
const CHAT_DETAILS = {
  0: [ // 한소영
    { who: 'user', text: '오늘 저녁 일정 추천해줘', time: '13:55' },
    { who: 'ai',   text: '어떤 분위기 찾으시나요? 조용한 곳 / 활기찬 곳 / 특정 요리 장르를 알려주세요.', time: '13:55' },
    { who: 'user', text: '강남 근처로 조용한 레스토랑 3곳 이상, 주차 가능한 곳이면 좋겠어', time: '13:58' },
    { who: 'ai',   text: '강남역 인근 조용한 분위기·주차 가능 레스토랑 3곳: ① 밍글스 (한식) · 발렛 · 저녁 7시 ② 로컬릿 (이탈리안) · 자체주차 · 금·토 재즈라이브 ③ 조경수 (일식 오마카세) · 발렛 · 10명까지 예약 가능. 예약 대행 원하시면 말씀해 주세요.', time: '13:59' },
  ],
  1: [ // 최유진
    { who: 'user', text: '주말 가족 나들이 코스 짜줘. 초등학생 둘, 유모차 필요해', time: '13:55' },
    { who: 'ai',   text: '토요일 기준 코스 제안드려요: 오전 어린이대공원(무료) · 점심 성수 동화속식당(유모차 OK) · 오후 서울숲 자연체험 · 저녁 왕십리 CGV IMAX 키즈관 추천. 교통·예약 링크도 필요하실까요?', time: '13:56' },
    { who: 'user', text: '네 교통도 정리해줘', time: '13:57' },
    { who: 'ai',   text: '지하철 2·5호선 + 수인분당선으로 하루 안에 이동 가능합니다. 상세 환승 순서 정리해드릴게요…', time: '13:58' },
  ],
};

// ═══════════════════════════════════════════════════════════════
// Live API layer — /api/admin/* (read-only proxy → auth-token-server)
// ═══════════════════════════════════════════════════════════════
async function apiGet(path) {
  const res = await fetch('/api/admin' + path, { credentials: 'same-origin' });
  if (!res.ok) throw new Error('API ' + path + ' → ' + res.status);
  return res.json();
}
// Map DB member → preview MEMBERS shape
function mapMember(m) {
  return {
    member_id: m.member_id,
    name: m.name || '',
    phone: m.phone || '',
    region: m.region || '',
    free_coin: m.free_coin || 0,
    bonus_coin: m.bonus_coin || 0,
    role: m.role || 'regular',
    rejoin_date: m.rejoin_date || '',
    last_login: m.last_login || '',
    suspended_until: m.suspended_until || null,
    permanently_banned: !!m.permanently_banned,
    inappropriate_count: m.inappropriate_count || 0,
    note: m.memo || m.note || '—',
    kakao_id: m.kakao_id || '',
    created_at: m.created_at || '',
    name_phone_key: m.name_phone_key || '',
  };
}
function mapBugReport(b) {
  const dt = b.created_at ? new Date(b.created_at) : null;
  // 원본: YYYY.MM.DD.(요일) + HH:MM:SS 2줄
  const WEEK = ['일','월','화','수','목','금','토'];
  const dtDate = dt ? `${dt.getFullYear()}.${String(dt.getMonth()+1).padStart(2,'0')}.${String(dt.getDate()).padStart(2,'0')}(${WEEK[dt.getDay()]})` : '';
  const dtTime = dt ? `${String(dt.getHours()).padStart(2,'0')}:${String(dt.getMinutes()).padStart(2,'0')}:${String(dt.getSeconds()).padStart(2,'0')}` : '';
  const dateIso = dt ? `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}` : '';
  // API returns member_name_display already masked ("복진" etc.). Keep as-is, append O for consistency.
  const nameRaw = b.member_name_display || b.member_name || '';
  const reporter = nameRaw ? (nameRaw.length >= 2 ? nameRaw.slice(0, 2) + 'O' : nameRaw + 'O') : '—';
  // 원본 API status: 'new' | 'done' | 기타. preview 내부는 'resolved'로 통일해 사용 (체크=done)
  const status = b.status === 'done' ? 'resolved' : (b.action_note ? 'progress' : 'open');
  return {
    id: b.report_id,
    time: dtDate + ' ' + dtTime,  // legacy single-line (sort키)
    dtDate,
    dtTime,
    dateIso,
    reporter,
    phone: b.member_code || '',   // 고유번호 표시(원본 전화 미사용)
    region: b.member_region || '충남', // 원본 admin.html과 동일한 폴백
    content: b.description || '',
    action: b.action_note || '—',
    status,
    error_count: b.error_count || 0,
    has_qa: !!b.has_qa,
  };
}
function mapTransaction(t) {
  const dt = t.created_at ? new Date(t.created_at) : null;
  // 원본: "YYYY. MM. DD.(요일) HH:MM:SS"
  const WEEK = ['일','월','화','수','목','금','토'];
  const pad = n => String(n).padStart(2, '0');
  const timeStr = dt ? `${dt.getFullYear()}. ${pad(dt.getMonth()+1)}. ${pad(dt.getDate())}.(${WEEK[dt.getDay()]}) ${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}` : '';
  const dateIso = dt ? `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}` : '';
  return {
    time: timeStr,
    dateIso,   // 필터용 "YYYY-MM-DD"
    region: t.sender_region || '—',
    from: (t.sender_name || '').slice(0, 2) + 'O',
    fromPhone: t.sender_code || '',   // 고유번호 표시
    to: (t.receiver_name || '').slice(0, 2) + 'O',
    toPhone: t.receiver_code || '',   // 고유번호 표시
    free: t.free_amount || 0,
    bonus: t.bonus_amount || 0,
    status: t.status || 'done',
    reason: t.reason || '—',
  };
}
function mapChatSession(s) {
  const raw = s.updated_at || s.created_at;
  const dt = raw ? new Date(raw) : null;
  const WEEK = ['일','월','화','수','목','금','토'];
  const pad = n => String(n).padStart(2, '0');
  // 오류보고 보고시기와 동일 포맷: YYYY.MM.DD(요일) / HH:MM:SS
  const dtDate = dt ? `${dt.getFullYear()}.${pad(dt.getMonth()+1)}.${pad(dt.getDate())}(${WEEK[dt.getDay()]})` : '';
  const dtTime = dt ? `${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}` : '';
  const dateIso = dt ? `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}` : '';
  const flagMap = { '무의미': 'nonsense', '음란성': 'obscene', '공격성': 'aggressive', '기타': 'etc' };
  const rawFlag = s.inappropriate_type || null;
  return {
    time: dtDate + ' ' + dtTime,   // 정렬키
    dtDate, dtTime, dateIso,
    region: s.member_region || '',
    name: s.member_name || '-',
    phone: s.name_phone_key || '',   // 고유번호 표시
    content: s.title || '(제목 없음)',
    flag: rawFlag ? (flagMap[rawFlag] || null) : null,
    session_id: s.session_id,
    name_phone_key: s.name_phone_key || '',
  };
}
function mapTokenUsage(u) {
  // 원본 admin.html line 2250: (u.name || '').substring(0, 2)  — O 붙이지 않음
  return {
    member_id: u.member_id,
    member: (u.name || '').substring(0, 2),
    phone: u.name_phone_key || '',   // 고유번호 표시
    region: u.region || '',
    free_used: u.free_total || 0,
    bonus_used: u.bonus_total || 0,
    bad: u.inappropriate_count || 0,
    err: u.bug_count || 0,
    note: u.note || '—',
  };
}
const TSL_API = {
  get: apiGet,
  members: () => apiGet('/members').then(arr => arr.map(mapMember)),
  permissionsList: () => apiGet('/members/permissions'),
  permissionsBulk: (updates) => fetch('/api/admin/members/permissions/bulk', {
    method: 'POST',
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ updates }),
  }).then(async r => { const d = await r.json().catch(() => ({})); if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); return d; }),
  chungsilPermissionsList: () => apiGet('/members/chungsil-permissions').then(d => ({
    items: (d && d.items) || [],
    sejong_error: d && d.sejong_error
  })),
  chungsilPermissionsBulk: (updates) => fetch('/api/admin/members/chungsil-permissions/bulk', {
    method: 'POST',
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ updates }),
  }).then(async r => { const d = await r.json().catch(() => ({})); if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); return d; }),
  bugReports: () => apiGet('/bug-reports').then(arr => (Array.isArray(arr) ? arr : []).map(mapBugReport)),
  transactions: () => apiGet('/transactions?limit=100').then(d => {
    const arr = Array.isArray(d) ? d : (d.transactions || d.items || []);
    return arr.map(mapTransaction);
  }),
  chats: () => apiGet('/history/all?limit=50').then(d => {
    const arr = Array.isArray(d) ? d : (d.sessions || d.items || []);
    return arr.map(mapChatSession);
  }),
  tokenUsage: (from, to) => {
    const q = [];
    if (from) q.push('start=' + encodeURIComponent(from));   // ★서버(admin.js /token-usage)는 start/end 를 읽는다(from/to 아님) — 불일치로 날짜필터 미적용 버그 수정 2026-07-15
    if (to) q.push('end=' + encodeURIComponent(to));
    return apiGet('/token-usage' + (q.length ? '?' + q.join('&') : '')).then(d => {
      const arr = Array.isArray(d) ? d : (d.items || d.usage || []);
      return arr.map(mapTokenUsage);
    });
  },
  lectureAccounts: () => apiGet('/lecture-accounts'),
  setLecturePasswords: (updates) => fetch('/api/admin/lecture-accounts/set-passwords', {
    method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ updates }),
  }).then(r => r.ok ? r.json() : r.json().then(e => { throw new Error(e.error || ('HTTP ' + r.status)); })),
  penaltyMonths: () => apiGet('/settings/penalty').then(d => d.penalty_months ?? 3),
  // /api/settings/<name> GET (읽기)
  setting: (name) => fetch('/api/settings/' + name, { credentials: 'same-origin' }).then(r => { if (!r.ok) throw new Error(name + ' ' + r.status); return r.json(); }),
  // /api/settings/<name> POST (쓰기 — 원본 user-management-server의 POST 엔드포인트와 동일 body shape)
  saveSetting: (name, body) => fetch('/api/settings/' + name, {
    method: 'POST', credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  }).then(r => r.ok ? r.json() : r.json().then(e => { throw new Error(e.error || ('save ' + name + ' ' + r.status)); })),
};
function useApiData(fn, deps = []) {
  const [data, setData] = React.useState(null);
  const [error, setError] = React.useState(null);
  // [2026-07-09] fetching: 재조회(refreshKey 변경) 중에도 true — 버튼 클릭 피드백용(기존 data는 유지해 깜빡임 방지).
  const [fetching, setFetching] = React.useState(true);
  React.useEffect(() => {
    let alive = true;
    setFetching(true);
    fn().then(d => { if (alive) { setData(d); setError(null); setFetching(false); } })
        .catch(e => { if (alive) { setError(e.message); setFetching(false); } });
    return () => { alive = false; };
  }, deps);
  return { data, error, loading: data === null && !error, fetching };
}
// 원본 admin.html formatSegText (line 1413~) 그대로 이식
// 탭(\t)으로 구분된 줄을 HTML 표로 변환. 탭 없는 줄은 텍스트.
function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function formatSegText(text) {
  if (!text) return '';
  const lines = String(text).split('\n');
  const hasTab = lines.some(l => l.indexOf('\t') !== -1);
  if (!hasTab) return escapeHtml(text);
  let html = '';
  let tblLines = [];
  function flushTable() {
    if (tblLines.length < 2) { tblLines.forEach(l => { html += escapeHtml(l) + '\n'; }); tblLines = []; return; }
    html += '<table style="width:100%;border-collapse:collapse;font-size:12px;margin:6px 0"><thead><tr>';
    tblLines[0].split('\t').forEach(c => {
      html += '<th style="border:1px solid #d1d5db;padding:5px 8px;background:#f1f5f9;font-weight:600;text-align:left">' + escapeHtml(c.trim()) + '</th>';
    });
    html += '</tr></thead><tbody>';
    for (let r = 1; r < tblLines.length; r++) {
      html += '<tr>';
      tblLines[r].split('\t').forEach(c => {
        html += '<td style="border:1px solid #d1d5db;padding:5px 8px;text-align:left">' + escapeHtml(c.trim()) + '</td>';
      });
      html += '</tr>';
    }
    html += '</tbody></table>';
    tblLines = [];
  }
  for (let i = 0; i < lines.length; i++) {
    if (lines[i].indexOf('\t') !== -1) { tblLines.push(lines[i]); }
    else { if (tblLines.length) flushTable(); if (lines[i].trim()) html += escapeHtml(lines[i]) + '\n'; }
  }
  if (tblLines.length) flushTable();
  return html;
}

// 오늘·N일 전 yyyy-mm-dd 헬퍼
function todayStr() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; }
function daysAgoStr(n) { const d = new Date(); d.setDate(d.getDate() - n); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; }

// 모바일 반응형 훅 (768px 이하)
function useIsMobile() {
  const [isMobile, setIsMobile] = React.useState(typeof window !== 'undefined' ? window.innerWidth <= 768 : false);
  React.useEffect(() => {
    const onResize = () => setIsMobile(window.innerWidth <= 768);
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);
  return isMobile;
}

Object.assign(window, {
  REGIONS, MEMBERS, ROLE_META, TABS, ROLE_SIM,
  ERRORS, ERROR_DETAILS, CHATS, CHAT_DETAILS, CHAT_FLAG_META, TOKEN_USAGE, EXCHANGES,
  EXCHANGE_STATUS, ERROR_STATUS, TONE_PALETTE, maskName,
  TSL_API, useApiData, useIsMobile, todayStr, daysAgoStr, formatSegText,
});
