// ═══════════════════════════════════════════════════════════════
// 회원관리 > 검색 (1-1)
// ═══════════════════════════════════════════════════════════════
const MemberSearchView = () => {
  const toast = useToast();
  const [region, setRegion] = React.useState('전체');
  const [query, setQuery] = React.useState('');
  const [sort, onSort] = useSort('total');
  const [page, setPage] = React.useState(1);
  const [pageSize, setPageSize] = React.useState(10);
  const [localTokens, setLocalTokens] = React.useState({});
  const [modalMember, setModalMember] = React.useState(null);

  const getTokens = (m) => {
    const local = localTokens[m.member_id];
    return { free: local?.free ?? m.free_coin, bonus: local?.bonus ?? m.bonus_coin };
  };

  const { data: liveMembers, error: apiError } = useApiData(TSL_API.members, []);
  const baseMembers = liveMembers || [];
  // 원본: 검색어/지역 둘 다 비어있으면 결과 표시 안 함
  const hasFilter = !!(query.trim() || (region && region !== '전체'));

  const filtered = React.useMemo(() => {
    if (!hasFilter) return [];
    let rows = baseMembers.filter(m => {
      if (region !== '전체' && !m.region.startsWith(region)) return false;
      if (query) {
        const q = query.trim();
        if (!m.name.includes(q) && !maskName(m.name).includes(q) && !(m.name_phone_key || '').includes(q) && !(m.name && q.includes(m.name))) return false;
      }
      return true;
    });
    return sortRows(rows, sort, {
      region: m => m.region, name: m => m.name,
      free: m => getTokens(m).free, bonus: m => getTokens(m).bonus,
      total: m => getTokens(m).free + getTokens(m).bonus,
      role: m => m.role,
    });
  }, [baseMembers, hasFilter, region, query, sort, localTokens]);

  const paged = filtered.slice((page - 1) * pageSize, page * pageSize);

  const applyAdjust = async ({ free, bonus, reason }) => {
    const m = modalMember;
    const id = m.member_id;
    const errors = [];
    try {
      if (free !== 0) {
        const res = await fetch('/admin/coins/adjust', {
          method: 'POST', credentials: 'same-origin',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ member_id: id, type: 'free', amount: free, reason: reason || '관리자 토큰 수량 변경' }),
        });
        if (!res.ok) errors.push('일반토큰: ' + res.status);
      }
      if (bonus !== 0) {
        const res = await fetch('/admin/coins/adjust', {
          method: 'POST', credentials: 'same-origin',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ member_id: id, type: 'bonus', amount: bonus, reason: reason || '관리자 토큰 수량 변경' }),
        });
        if (!res.ok) errors.push('보너스토큰: ' + res.status);
      }
    } catch (e) {
      errors.push(e.message || '네트워크 오류');
    }
    if (errors.length) {
      toast('error', '반영 실패: ' + errors.join(', '));
      return;
    }
    setLocalTokens(prev => {
      const cur = prev[id] || { free: m.free_coin, bonus: m.bonus_coin };
      return { ...prev, [id]: {
        free: Math.max(0, cur.free + free),
        bonus: Math.max(0, cur.bonus + bonus),
      } };
    });
    const sign = (n) => n > 0 ? `+${n}` : n.toString();
    toast('success', `${m.name || m.name_phone_key} · 일반 ${sign(free)} · 보너스 ${sign(bonus)} 반영됨`);
  };

  return (
    <div>
      <SectionHeader title="검색" />
      <Toolbar style={{ marginBottom: 16 }}>
        <Select value={region} onChange={setRegion} options={REGIONS} width={130}
          icon={<Icon name="location" size={14} />} size="lg" />
        <div style={{ flex: 1 }}>
          <TextInput value={query} onChange={setQuery}
            icon={<Icon name="search" size={14} />}
            placeholder="이름 또는 고유번호로 검색..."
            size="lg" width="100%" />
        </div>
      </Toolbar>

      {hasFilter && (
        <div style={{ fontSize: 13, color: 'var(--ink-3)', marginBottom: 10, paddingLeft: 2 }}>
          총 <b style={{ color: 'var(--ink)' }}>{filtered.length}</b>명
        </div>
      )}

      {!hasFilter ? null : filtered.length === 0 ? (
        <EmptyState variant="search" title="검색 결과가 없습니다" desc="다른 지역이나 키워드로 시도해 보세요" />
      ) : (
        <TableShell
          minWidth={1080}
          headers={<>
            <SortHeader label="지역"       col="region" sort={sort} onSort={onSort} width={140} />
            <th style={{ ...tableStyles.headerCell, textAlign: 'center', width: 130 }}>고유번호</th>
            <SortHeader label="일반토큰"   col="free"   sort={sort} onSort={onSort} align="right" width={110} />
            <SortHeader label="보너스토큰" col="bonus"  sort={sort} onSort={onSort} align="right" width={120} />
            <SortHeader label="합계"       col="total"  sort={sort} onSort={onSort} align="right" width={100} />
            <SortHeader label="역할"       col="role"   sort={sort} onSort={onSort} width={90} />
            <th style={{ ...tableStyles.headerCell, textAlign: 'center', width: 160 }}>토큰 수량 관리</th>
          </>}
          footer={<Pagination total={filtered.length} page={page} pageSize={pageSize}
            onPage={setPage} onPageSize={(n) => { setPageSize(n); setPage(1); }} />}
        >
          {paged.map((m, i) => {
            const t = getTokens(m);
            const changed = !!localTokens[m.member_id];
            return (
              <Row key={m.member_id} last={i === paged.length - 1}>
                <td style={tableStyles.cell}>{m.region}</td>
                <td style={{ ...tableStyles.cell, ...tableStyles.mono, color: 'var(--ink-3)', textAlign: 'center' }}>{m.name_phone_key}</td>
                <td style={{ ...tableStyles.cell, textAlign: 'right', ...tableStyles.num }}>
                  <span style={{ fontWeight: 600, color: 'var(--ink)' }}>{t.free.toLocaleString()}</span>
                </td>
                <td style={{ ...tableStyles.cell, textAlign: 'right', ...tableStyles.num }}>
                  <span style={{ fontWeight: 600, color: 'oklch(0.48 0.12 300)' }}>{t.bonus.toLocaleString()}</span>
                </td>
                <td style={{ ...tableStyles.cell, textAlign: 'right', ...tableStyles.num }}>
                  <span style={{
                    fontWeight: 700, color: 'var(--ink)',
                    padding: '3px 9px', background: changed ? 'var(--accent-soft)' : '#f3f5f8',
                    color: changed ? 'var(--accent-ink)' : 'var(--ink)',
                    borderRadius: 6, fontSize: 13,
                  }}>{(t.free + t.bonus).toLocaleString()}</span>
                </td>
                <td style={tableStyles.cell}><RoleBadge role={m.role} /></td>
                <td style={{ ...tableStyles.cell, textAlign: 'right' }}>
                  <Button size="sm" variant="primary"
                    icon={<Icon name="coin" size={12} />}
                    onClick={() => setModalMember({ ...m, free_coin: t.free, bonus_coin: t.bonus })}>
                    토큰 수량 변경
                  </Button>
                </td>
              </Row>
            );
          })}
        </TableShell>
      )}

      <TokenAdjustModal open={!!modalMember} member={modalMember}
        onClose={() => setModalMember(null)} onApply={applyAdjust} />
    </div>
  );
};

// ═══════════════════════════════════════════════════════════════
// 회원관리 > 등록 (1-2)
// ═══════════════════════════════════════════════════════════════
const MemberRegisterView = () => {
  const toast = useToast();
  const isMobile = useIsMobile();
  const [form, setForm] = React.useState({ region: '충남', name: '', phone: '', rejoin: '' });
  const [uploadMode, setUploadMode] = React.useState('append');
  const [submitting, setSubmitting] = React.useState(false);
  const [myRole, setMyRole] = React.useState(null);
  // 엑셀 일괄 업로드
  const fileInputRef = React.useRef(null);
  const [uploading, setUploading] = React.useState(false);
  const [dragActive, setDragActive] = React.useState(false);
  const [uploadResult, setUploadResult] = React.useState(null); // { total, added, skipped, errors? } | null
  React.useEffect(() => {
    fetch('/admin/me', { credentials: 'same-origin' })
      .then(r => r.ok ? r.json() : null)
      .then(d => setMyRole(d && d.role ? d.role : null))
      .catch(() => {});
  }, []);
  const canBulkUpload = myRole === 'master';
  // ── 고유번호 재키(매핑 업로드) — 총괄관리자(master) 전용 ──
  const rekeyFileRef = React.useRef(null);
  const [rekeyFile, setRekeyFile] = React.useState(null);     // File | null
  const [rekeyPos, setRekeyPos] = React.useState('');         // 새 추출자리 "2,4,7,8"
  const [rekeySeed, setRekeySeed] = React.useState('');       // 새 시드(config 갱신 메모)
  const [rekeyBusy, setRekeyBusy] = React.useState(false);
  const [rekeyReport, setRekeyReport] = React.useState(null); // dry-run/apply 결과
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
  const canSubmit = form.name.trim() && /^\d{10,11}$/.test(form.phone);

  const uploadFile = async (file) => {
    if (!file || uploading) return;
    const name = (file.name || '').toLowerCase();
    if (!name.endsWith('.xlsx') && !name.endsWith('.xls')) {
      toast('error', 'xlsx 또는 xls 파일만 업로드 가능합니다');
      return;
    }
    if (file.size > 5 * 1024 * 1024) {
      toast('error', '파일 크기는 5MB 이하여야 합니다');
      return;
    }
    if (uploadMode === 'replace' && !confirm('전체 교체 모드입니다. 기존 회원이 모두 삭제됩니다.\n계속하시겠습니까?')) return;
    setUploading(true);
    setUploadResult(null);
    try {
      const fd = new FormData();
      fd.append('file', file);
      const res = await fetch('/admin/members/upload?mode=' + encodeURIComponent(uploadMode), {
        method: 'POST', credentials: 'same-origin', body: fd,
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
      setUploadResult({
        total: data.total || 0,
        added: data.added || 0,
        skipped: data.skipped || 0,
        errors: Array.isArray(data.errors) ? data.errors : [],
      });
      toast('success', `업로드 완료: 전체 ${data.total}건 · 추가 ${data.added}건 · 스킵 ${data.skipped}건`);
    } catch (e) {
      toast('error', '업로드 실패: ' + (e.message || '네트워크 오류'));
    } finally {
      setUploading(false);
      if (fileInputRef.current) fileInputRef.current.value = '';
    }
  };
  const openFilePicker = () => { if (!uploading) fileInputRef.current?.click(); };
  const onDropFile = (e) => {
    e.preventDefault(); e.stopPropagation();
    setDragActive(false);
    const f = e.dataTransfer?.files?.[0];
    if (f) uploadFile(f);
  };

  const registerMember = async () => {
    if (!canSubmit || submitting) return;
    setSubmitting(true);
    try {
      const res = await fetch('/admin/members', {
        method: 'POST',
        credentials: 'same-origin',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: form.name.trim(),
          phone: form.phone,
          region: form.region,
          rejoin_date: form.rejoin || '',
        }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || '등록 실패');
      toast('success', data.name_phone_key
        ? `회원 등록 완료: ${form.name} (고유번호 ${data.name_phone_key})`
        : `회원 등록 완료: ${form.name}`);
      setForm({ region: '충남', name: '', phone: '', rejoin: '' });
    } catch (e) {
      toast('error', e.message);
    } finally {
      setSubmitting(false);
    }
  };

  // ── 재키 실행 (dryRun=true 기본; apply=true 시 실제 교체) ──
  const runRekey = async (apply) => {
    if (!canBulkUpload) return;            // master 전용(이중방어)
    if (!rekeyFile) { toast('error', '매핑 파일을 선택하세요'); return; }
    if (rekeyBusy) return;
    if (apply) {
      if (!confirm('실제 고유번호 일괄 교체를 적용합니다. 로그인에 직결되는 작업입니다.\n사전 백업 후 진행됩니다. 계속하시겠습니까?')) return;
    }
    setRekeyBusy(true);
    if (!apply) setRekeyReport(null);
    try {
      const fd = new FormData();
      fd.append('file', rekeyFile);
      if (rekeyPos.trim()) fd.append('newPos', rekeyPos.trim());
      if (rekeySeed.trim()) fd.append('newSeed', rekeySeed.trim());
      if (apply) { fd.append('apply', '1'); fd.append('confirm', 'REKEY'); }
      const res = await fetch('/admin/members/rekey', {
        method: 'POST', credentials: 'same-origin', body: fd,
      });
      const data = await res.json().catch(() => ({}));
      setRekeyReport(data);
      if (!res.ok) {
        toast('error', '재키 ' + (apply ? '적용' : '검증') + ' 실패: ' + (data.error || ('HTTP ' + res.status)));
      } else if (apply) {
        toast('success', `재키 적용 완료: ${data.updated || 0}건 변경`);
      } else {
        toast(data.ok ? 'success' : 'error', data.ok
          ? `검증 통과: 변경대상 ${data.checks?.changeCount ?? 0}건`
          : `검증 실패: ${(data.fatal || []).length}건 이슈`);
      }
    } catch (e) {
      toast('error', '재키 요청 오류: ' + (e.message || '네트워크 오류'));
    } finally {
      setRekeyBusy(false);
    }
  };

  return (
    <div>
      <SectionHeader title="회원 등록" />

      <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : (canBulkUpload ? '1.1fr 1fr' : '1fr'), gap: isMobile ? 10 : 16, alignItems: 'stretch' }}>
        {/* 수동 등록 카드 */}
        <Card title="개별 등록" style={{ height: '100%' }}>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: 1 }}>
            <Field label="지역">
              <Select value={form.region} onChange={v => set('region', v)} options={REGIONS.slice(1)} width="100%" />
            </Field>
            <Field label="이름">
              <TextInput value={form.name} onChange={v => set('name', v)} placeholder="홍길동" width="100%" />
            </Field>
            <Field label="전화번호">
              <TextInput value={form.phone} onChange={v => set('phone', v.replace(/\D/g,'').slice(0,11))}
                placeholder="01012345678" icon={<Icon name="phone" size={14} />} width="100%" />
            </Field>
            <Field label="탈퇴후재가입일">
              <DateInput value={form.rejoin} onChange={v => set('rejoin', v)} />
            </Field>
            <div style={{ flex: 1 }} />
            <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
              <Button variant="primary" disabled={!canSubmit || submitting} onClick={registerMember} icon={<Icon name="plus" size={14} />}>{submitting ? '등록 중...' : '등록'}</Button>
            </div>
          </div>
        </Card>

        {/* 엑셀 업로드 카드 — 마스터 전용 */}
        {canBulkUpload && (
        <Card title="엑셀 일괄 업로드" style={{ height: '100%' }}>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: 1 }}>
            <div style={{
              padding: 14, background: 'var(--surface-2)', border: '1px dashed var(--line-strong)',
              borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10,
            }}>
              <div>
                <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>회원 등록 서식.xlsx</div>
                <div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>지역, 이름(앞2글자), 전화번호 추출 4자리, 재가입일(선택사항)</div>
                <div style={{ fontSize: 11.5, color: 'oklch(0.50 0.14 255)', marginTop: 4, fontWeight: 500 }}>
                  ※ 직접 채우지 마세요 — 회원관리 생성기(exe)로 만든 업로드파일을 그대로 올리세요.
                </div>
              </div>
              <Button variant="excel" icon={<Icon name="download" size={14} />} onClick={() => window.location.href="/admin/members/template"}>서식 다운로드</Button>
            </div>

            <div>
              <div style={{ fontSize: 12, color: 'var(--ink-3)', fontWeight: 500, marginBottom: 8 }}>업로드 모드</div>
              <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 8 }}>
                {[
                  { v: 'append',  l: '추가만',   d: '기존 데이터를 보존하고 새 행만 추가' },
                  { v: 'replace', l: '전체 교체', d: '기존 데이터를 삭제 후 파일로 덮어쓰기' },
                ].map(o => (
                  <button key={o.v} onClick={() => setUploadMode(o.v)} style={{
                    padding: '10px 12px', textAlign: 'left', cursor: 'pointer',
                    border: `1px solid ${uploadMode === o.v ? 'var(--accent)' : 'var(--line-strong)'}`,
                    background: uploadMode === o.v ? 'var(--accent-soft)' : '#fff',
                    borderRadius: 10, fontFamily: 'inherit',
                  }}>
                    <div style={{ fontSize: 13, fontWeight: 600, color: uploadMode === o.v ? 'var(--accent-ink)' : 'var(--ink)' }}>{o.l}</div>
                    <div style={{ fontSize: 11.5, color: 'var(--ink-3)', marginTop: 3, lineHeight: 1.45 }}>{o.d}</div>
                  </button>
                ))}
              </div>
              {uploadMode === 'replace' && (
                <div style={{
                  marginTop: 10, padding: '8px 12px', borderRadius: 8,
                  background: 'oklch(0.97 0.04 25)', color: 'oklch(0.42 0.16 25)',
                  fontSize: 11.5, fontWeight: 500, display: 'flex', gap: 6, alignItems: 'flex-start',
                }}>
                  <Icon name="warning" size={14} /> 전체 교체는 기존 회원 정보를 영구 삭제합니다.
                </div>
              )}
            </div>

            <input
              ref={fileInputRef}
              type="file"
              accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
              style={{ display: 'none' }}
              onClick={(e) => e.stopPropagation()}
              onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadFile(f); }}
            />
            <div
              onClick={openFilePicker}
              onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); if (!uploading && !dragActive) setDragActive(true); }}
              onDragLeave={(e) => { e.preventDefault(); e.stopPropagation(); setDragActive(false); }}
              onDrop={onDropFile}
              style={{
                padding: 24,
                border: `1.5px dashed ${dragActive ? 'var(--accent)' : 'var(--line-strong)'}`,
                borderRadius: 12, textAlign: 'center',
                background: dragActive ? 'var(--accent-soft)' : '#fafbfc',
                flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center',
                cursor: uploading ? 'not-allowed' : 'pointer',
                opacity: uploading ? 0.6 : 1,
                transition: 'background 0.12s, border-color 0.12s',
              }}
            >
              <div style={{
                width: 40, height: 40, margin: '0 auto 10px', borderRadius: 10,
                background: '#fff', border: '1px solid var(--line-strong)',
                display: 'grid', placeItems: 'center', color: 'var(--ink-3)',
              }}><Icon name="upload" size={18} /></div>
              <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>
                {uploading ? '업로드 중...' : '클릭하거나 파일을 드롭하세요'}
              </div>
              <div style={{ fontSize: 11.5, color: 'var(--ink-3)', marginTop: 3 }}>.xlsx (최대 5MB)</div>
              <Button
                style={{ marginTop: 12 }}
                disabled={uploading}
                icon={<Icon name="upload" size={13} />}
                onClick={(e) => { e.stopPropagation(); openFilePicker(); }}
              >파일 선택</Button>
            </div>
            {uploadResult && (
              <div style={{
                marginTop: 4, padding: '10px 12px', borderRadius: 8,
                background: uploadResult.errors.length ? 'oklch(0.97 0.04 25)' : 'oklch(0.97 0.04 155)',
                color: uploadResult.errors.length ? 'oklch(0.42 0.16 25)' : 'oklch(0.42 0.14 155)',
                fontSize: 12, fontWeight: 500,
              }}>
                <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
                  <span>전체 <b>{uploadResult.total}</b>건</span>
                  <span>· 추가 <b>{uploadResult.added}</b>건</span>
                  <span>· 스킵 <b>{uploadResult.skipped}</b>건</span>
                  {uploadResult.errors.length > 0 && <span>· 오류 <b>{uploadResult.errors.length}</b>건</span>}
                </div>
                {uploadResult.errors.length > 0 && (
                  <div style={{ marginTop: 6, fontSize: 11, lineHeight: 1.6, maxHeight: 120, overflowY: 'auto' }}>
                    {uploadResult.errors.slice(0, 20).map((err, i) => (
                      <div key={i}>· {typeof err === 'string' ? err : (err.row != null ? `행 ${err.row}: ` : '') + (err.reason || err.message || JSON.stringify(err))}</div>
                    ))}
                    {uploadResult.errors.length > 20 && <div>· (이하 {uploadResult.errors.length - 20}건 생략)</div>}
                  </div>
                )}
              </div>
            )}
          </div>
        </Card>
        )}
      </div>

      {/* ── 고유번호 재키(매핑 업로드) — 총괄관리자(master) 전용 ── */}
      {canBulkUpload && (
      <Card title="고유번호 재키 (매핑 업로드)" style={{ marginTop: isMobile ? 10 : 16 }}>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
          <div style={{
            padding: '10px 12px', borderRadius: 8,
            background: 'oklch(0.97 0.04 25)', color: 'oklch(0.40 0.16 25)',
            fontSize: 12, fontWeight: 500, display: 'flex', gap: 6, alignItems: 'flex-start', lineHeight: 1.5,
          }}>
            <Icon name="warning" size={14} />
            <span>로그인에 직결되는 작업입니다. 회원관리 생성기(exe) <b>재설정모드</b>가 만든
              <b> 옛고유번호↔새고유번호 매핑파일</b>(1열 옛, 2열 새)을 올려 DB 고유번호를 일괄 교체합니다.
              먼저 <b>미리보기(검증)</b>로 매칭/미스/중복을 확인한 뒤에만 적용하세요. 관리자(총괄/지역) 코드는 변경되지 않습니다.</span>
          </div>

          <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 12 }}>
            <Field label="새 추출자리 (선택 · 예: 2,4,7,8)" hint="config 갱신 권고용">
              <TextInput value={rekeyPos} onChange={setRekeyPos} placeholder="2,4,7,8" width="100%" />
            </Field>
            <Field label="새 시드 (선택)" hint="자동변경 안 함 · 운영자 재봉인">
              <TextInput value={rekeySeed} onChange={setRekeySeed} placeholder="(비워두면 변경 없음)" width="100%" />
            </Field>
          </div>

          <input
            ref={rekeyFileRef}
            type="file"
            accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
            style={{ display: 'none' }}
            onChange={(e) => { const f = e.target.files?.[0]; if (f) setRekeyFile(f); }}
          />
          <div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
            <Button variant="default" icon={<Icon name="upload" size={13} />}
              disabled={rekeyBusy}
              onClick={() => rekeyFileRef.current?.click()}>매핑 파일 선택</Button>
            <span style={{ fontSize: 12.5, color: rekeyFile ? 'var(--ink)' : 'var(--ink-4)' }}>
              {rekeyFile ? rekeyFile.name : '선택된 파일 없음 (.xlsx — 1열 옛고유번호, 2열 새고유번호)'}
            </span>
          </div>

          <div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
            <Button variant="primary" disabled={!rekeyFile || rekeyBusy}
              icon={<Icon name="search" size={13} />}
              onClick={() => runRekey(false)}>{rekeyBusy ? '처리 중...' : '미리보기 (검증)'}</Button>
            <Button variant="solidDanger"
              disabled={!rekeyFile || rekeyBusy || !(rekeyReport && rekeyReport.dryRun && rekeyReport.ok)}
              icon={<Icon name="warning" size={13} />}
              onClick={() => runRekey(true)}>실제 적용</Button>
          </div>

          {rekeyReport && (
            <div style={{
              padding: '12px 14px', borderRadius: 10,
              background: (rekeyReport.ok || rekeyReport.applied) ? 'oklch(0.97 0.04 155)' : 'oklch(0.97 0.04 25)',
              color: (rekeyReport.ok || rekeyReport.applied) ? 'oklch(0.36 0.13 155)' : 'oklch(0.40 0.16 25)',
              fontSize: 12.5, lineHeight: 1.6,
            }}>
              <div style={{ fontWeight: 700, marginBottom: 6 }}>
                {rekeyReport.applied ? '적용 결과' : '검증(dry-run) 결과'} — {(rekeyReport.ok || rekeyReport.applied) ? '정상' : '이슈 있음'}
              </div>
              {rekeyReport.checks && (
                <div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 14px', marginBottom: 6 }}>
                  <span>매핑행 <b>{rekeyReport.checks.mappingRows}</b></span>
                  <span>· 변경대상 <b>{rekeyReport.checks.changeCount}</b></span>
                  <span>· 매칭 <b>{rekeyReport.checks.matched}</b></span>
                  <span>· 미스 <b>{rekeyReport.checks.missCount}</b></span>
                  <span>· 미커버 <b>{rekeyReport.checks.uncoveredCount}</b></span>
                  <span>· 옛중복 <b>{rekeyReport.checks.dupOldCount}</b></span>
                  <span>· 새중복 <b>{rekeyReport.checks.dupNewCount}</b></span>
                  <span>· 관리자보존위반 <b>{rekeyReport.checks.adminViolationCount}</b></span>
                </div>
              )}
              {rekeyReport.applied && (
                <div style={{ marginBottom: 6 }}>
                  변경 <b>{rekeyReport.updated}</b>건 · 스킵 <b>{rekeyReport.skippedApply}</b>건 · 이름맵이동 <b>{rekeyReport.nameMapMoved}</b>건
                  {rekeyReport.backup?.dynamoBackupArn && <div style={{ fontSize: 11, marginTop: 2 }}>백업: {rekeyReport.backup.snapshot}</div>}
                </div>
              )}
              {Array.isArray(rekeyReport.fatal) && rekeyReport.fatal.length > 0 && (
                <div style={{ marginTop: 4 }}>
                  {rekeyReport.fatal.map((f, i) => <div key={i}>· {f}</div>)}
                </div>
              )}
              {rekeyReport.error && <div style={{ marginTop: 4 }}>오류: {rekeyReport.error}</div>}
              {rekeyReport.dryRun && rekeyReport.ok && (
                <div style={{ marginTop: 6, fontWeight: 600 }}>검증 통과 — "실제 적용" 버튼이 활성화됩니다.</div>
              )}
            </div>
          )}
        </div>
      </Card>
      )}
    </div>
  );
};

const Field = ({ label, children, hint }) => (
  <div>
    <div style={{ fontSize: 12, color: 'var(--ink-2)', fontWeight: 600, marginBottom: 6 }}>
      {label}
      {hint && <span style={{ fontWeight: 400, color: 'var(--ink-4)', marginLeft: 6 }}>· {hint}</span>}
    </div>
    {children}
  </div>
);

// ═══════════════════════════════════════════════════════════════
// 회원관리 > 회원정보 (1-3, 마스터)
// ═══════════════════════════════════════════════════════════════
const MemberInfoView = () => {
  const isMobile = useIsMobile();
  const toast = useToast();
  const [unlocked, setUnlocked] = React.useState(false);
  const [roleFilter, setRoleFilter] = React.useState('all');
  const [query, setQuery] = React.useState('');
  const [sort, onSort] = useSort('member_id', 'asc');
  const [page, setPage] = React.useState(1);
  const [pageSize, setPageSize] = React.useState(10);
  const [refreshKey, setRefreshKey] = React.useState(0);
  // 내 권한(역할 변경 옵션 게이팅용) — 비master는 '총괄관리자' 부여 불가(백엔드 403 + UI 숨김)
  const [myRole, setMyRole] = React.useState(null);
  React.useEffect(() => {
    fetch('/admin/me', { credentials: 'same-origin' })
      .then(r => r.ok ? r.json() : null)
      .then(d => setMyRole(d && d.role ? d.role : null))
      .catch(() => {});
  }, []);
  // 인라인 편집 상태
  const [editingId, setEditingId] = React.useState(null);
  const [draft, setDraft] = React.useState({});
  const [saving, setSaving] = React.useState(false);
  const [confirmDel, setConfirmDel] = React.useState(null); // {id, name}
  const [deleting, setDeleting] = React.useState(false);

  const performDelete = async () => {
    if (!confirmDel) return;
    setDeleting(true);
    try {
      const res = await fetch('/admin/members/' + encodeURIComponent(confirmDel.id), {
        method: 'DELETE', credentials: 'same-origin',
      });
      if (!res.ok) {
        const t = await res.text().catch(() => '');
        throw new Error(t || ('HTTP ' + res.status));
      }
      toast('success', confirmDel.name + ' 삭제 완료');
      setConfirmDel(null);
      setEditingId(null); setDraft({});
      setRefreshKey(k => k + 1);
    } catch (e) {
      toast('error', '삭제 실패: ' + (e.message || '네트워크 오류'));
    } finally {
      setDeleting(false);
    }
  };
  // 역할 드롭다운 (회원ID 클릭 시 열림) — 테이블 wrapper overflow 클리핑 회피 위해 portal로 렌더
  const [roleDropdownId, setRoleDropdownId] = React.useState(null);
  const [roleDropdownPos, setRoleDropdownPos] = React.useState(null); // { top, left }
  const [roleOverrides, setRoleOverrides] = React.useState({}); // 화면 즉시 반영 + 서버 PUT
  const roleBtnRefs = React.useRef({});
  const openRoleDropdown = (memberId, btnEl) => {
    if (roleDropdownId === memberId) { setRoleDropdownId(null); return; }
    const rect = btnEl.getBoundingClientRect();
    // 아래 공간 부족하면 배지 위로 flip (마지막 행에서 뷰포트 밖으로 넘어가 스크롤해야 하는 문제 방지)
    const optCount = (myRole === 'master') ? 3 : 2;
    const menuH = optCount * 34 + 8;
    const openUp = (window.innerHeight - rect.bottom) < menuH + 12;
    const top = openUp ? Math.max(8, rect.top - menuH - 4) : rect.bottom + 4;
    setRoleDropdownPos({ top, left: rect.left + rect.width / 2 });
    setRoleDropdownId(memberId);
  };
  const changeRole = async (memberId, newRole) => {
    const prev = roleOverrides[memberId];
    setRoleOverrides(p => ({ ...p, [memberId]: newRole }));
    try {
      const res = await fetch('/admin/members/' + encodeURIComponent(memberId) + '/role', {
        method: 'PUT', credentials: 'same-origin',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ role: newRole }),
      });
      if (!res.ok) throw new Error('HTTP ' + res.status);
      setRefreshKey(k => k + 1); // 성공 시 서버 상태 재조회 → m.role 동기화
    } catch (e) {
      console.error('changeRole', e);
      setRoleOverrides(p => ({ ...p, [memberId]: prev }));
    }
  };
  React.useEffect(() => {
    const close = (e) => { if (!e.target.closest?.('[data-role-dropdown]')) setRoleDropdownId(null); };
    const closeOnScroll = () => setRoleDropdownId(null);
    document.addEventListener('click', close);
    window.addEventListener('scroll', closeOnScroll, true);
    window.addEventListener('resize', closeOnScroll);
    return () => {
      document.removeEventListener('click', close);
      window.removeEventListener('scroll', closeOnScroll, true);
      window.removeEventListener('resize', closeOnScroll);
    };
  }, []);

  const { data: liveMembers } = useApiData(TSL_API.members, [refreshKey]);
  const baseMembers = liveMembers || [];

  const filtered = React.useMemo(() => {
    let rows = baseMembers.filter(m => {
      const effRole = roleOverrides[m.member_id] || m.role;
      if (roleFilter !== 'all' && effRole !== roleFilter) return false;
      if (query) {
        const q = query.trim();
        if (!m.name.includes(q) && !maskName(m.name).includes(q) && !(m.name_phone_key || '').includes(q) && !m.member_id.includes(q) && !(m.name && q.includes(m.name))) return false;
      }
      return true;
    });
    return sortRows(rows, sort, {
      member_id: m => m.member_id, region: m => m.region, name: m => m.name,
      free: m => m.free_coin, bonus: m => m.bonus_coin, rejoin: m => m.rejoin_date,
      role: m => roleOverrides[m.member_id] || m.role, bad: m => m.inappropriate_count,
    });
  }, [baseMembers, roleFilter, roleOverrides, query, sort]);

  // 편집 저장 — 변경된 필드만 골라서 해당 엔드포인트 호출
  const performSave = async (member) => {
    const id = member.member_id;
    const originalMemo = member.note === '—' ? '' : (member.note || '');
    const fieldDiff = {};
    if (draft.name !== undefined && draft.name !== member.name) fieldDiff.name = draft.name;
    if (draft.region !== undefined && draft.region !== member.region) fieldDiff.region = draft.region;
    if (draft.rejoin_date !== undefined && draft.rejoin_date !== member.rejoin_date) fieldDiff.rejoin_date = draft.rejoin_date;

    const memoChanged = draft.memo !== undefined && draft.memo !== originalMemo;

    const freeDelta = (draft.free_coin !== undefined && Number(draft.free_coin) !== member.free_coin)
      ? Number(draft.free_coin) - member.free_coin : 0;
    const bonusDelta = (draft.bonus_coin !== undefined && Number(draft.bonus_coin) !== member.bonus_coin)
      ? Number(draft.bonus_coin) - member.bonus_coin : 0;

    if (Object.keys(fieldDiff).length === 0 && !memoChanged && freeDelta === 0 && bonusDelta === 0) {
      setEditingId(null); setDraft({});
      return;
    }

    setSaving(true);
    const errors = [];
    try {
      if (Object.keys(fieldDiff).length > 0) {
        const res = await fetch('/admin/members/' + encodeURIComponent(id) + '/fields', {
          method: 'PUT', credentials: 'same-origin',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(fieldDiff),
        });
        if (!res.ok) errors.push('기본정보: ' + res.status);
      }
      if (memoChanged) {
        const res = await fetch('/admin/members/' + encodeURIComponent(id) + '/memo', {
          method: 'PUT', credentials: 'same-origin',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ memo: draft.memo || '' }),
        });
        if (!res.ok) errors.push('비고: ' + res.status);
      }
      if (freeDelta !== 0) {
        const res = await fetch('/admin/coins/adjust', {
          method: 'POST', credentials: 'same-origin',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ member_id: id, type: 'free', amount: freeDelta, reason: '관리자 인라인 수정' }),
        });
        if (!res.ok) errors.push('일반토큰: ' + res.status);
      }
      if (bonusDelta !== 0) {
        const res = await fetch('/admin/coins/adjust', {
          method: 'POST', credentials: 'same-origin',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ member_id: id, type: 'bonus', amount: bonusDelta, reason: '관리자 인라인 수정' }),
        });
        if (!res.ok) errors.push('보너스토큰: ' + res.status);
      }
    } catch (e) {
      errors.push(e.message || '네트워크 오류');
    }
    setSaving(false);
    if (errors.length) {
      toast('error', '저장 실패: ' + errors.join(', '));
      return;
    }
    toast('success', '저장되었습니다');
    setEditingId(null); setDraft({});
    setRefreshKey(k => k + 1);
  };

  if (!unlocked) {
    return (
      <div>
        <SectionHeader title="전체 회원 정보" />
        <div style={{
          background: '#fff', border: '1px solid var(--line)', borderRadius: 12,
          padding: '60px 20px', textAlign: 'center',
        }}>
          <div style={{ color: '#94a3b8', marginBottom: 16 }}>
            <Icon name="lock" size={48} />
          </div>
          <p style={{ fontSize: 14, color: 'var(--ink-3)', lineHeight: 1.6, marginBottom: 20 }}>
            회원 개인정보 보호를 위해<br/>조회 버튼을 눌러야 정보를 확인할 수 있습니다.
          </p>
          <Button variant="primary" size="lg" onClick={() => setUnlocked(true)}
            icon={<Icon name="eye" size={14} />} style={{ padding: '14px 40px', fontSize: 16 }}>조회하기</Button>
        </div>
      </div>
    );
  }

  const paged = filtered.slice((page - 1) * pageSize, page * pageSize);

  return (
    <div>
      <SectionHeader
        title="회원 정보"
        desc="전체 회원 데이터 · 편집 및 관리"
        actions={<>
          <Button icon={<Icon name="refresh" size={13} />} onClick={() => setRefreshKey(k => k + 1)}>새로고침</Button>
          <Button variant="excel" icon={<Icon name="download" size={13} />} onClick={() => window.location.href='/admin/members/download'}>엑셀 다운로드</Button>
          <Button variant="danger" icon={<Icon name="close" size={13} />} onClick={() => setUnlocked(false)}>닫기</Button>
        </>}
      />

      <Toolbar style={{ marginBottom: 16 }}>
        <div style={{ display: 'flex', gap: 6 }}>
          {[
            { v: 'all',     l: '전체' },
            { v: 'regular', l: '일반' },
            { v: 'admin',   l: '지역관리자' },
            ...(myRole === 'master' ? [{ v: 'master',  l: '총괄관리자' }] : []),
          ].map(o => (
            <Chip key={o.v} active={roleFilter === o.v} onClick={() => setRoleFilter(o.v)}
              tone={ROLE_META[o.v]?.tone}>
              {o.l}
            </Chip>
          ))}
        </div>
        <div style={{ width: 1, height: 24, background: 'var(--line)' }} />
        <div style={{ flex: 1, minWidth: 240 }}>
          <TextInput value={query} onChange={setQuery}
            icon={<Icon name="search" size={14} />} placeholder="이름, 고유번호로 검색..." width="100%" />
        </div>
        <div style={{ fontSize: 13, color: 'var(--ink-3)', whiteSpace: 'nowrap', paddingLeft: 4 }}>
          전체 <b style={{ color: 'var(--ink)' }}>{filtered.length}</b>명
        </div>
      </Toolbar>

      <TableShell
        minWidth={1180}
        headers={<>
          <SortHeader label="지역"    col="region"    sort={sort} onSort={onSort} width={130} />
          <SortHeader label="고유번호" col={null}      width={130} />
          <SortHeader label="일반토큰" col="free"     sort={sort} onSort={onSort} align="right" width={90} />
          <SortHeader label="보너스토큰" col="bonus"  sort={sort} onSort={onSort} align="right" width={100} />
          <SortHeader label="탈퇴후재가입일" col="rejoin"   sort={sort} onSort={onSort} width={130} />
          <SortHeader label="역할"    col="role"     sort={sort} onSort={onSort} width={80} />
          <th style={{ ...tableStyles.headerCell, textAlign: 'center', width: 60 }}>편집</th>
          <SortHeader label="부적절"  col="bad"      sort={sort} onSort={onSort} align="right" width={70} />
          <th style={tableStyles.headerCell}>비고</th>
        </>}
        footer={<Pagination total={filtered.length} page={page} pageSize={pageSize}
          onPage={setPage} onPageSize={(n) => { setPageSize(n); setPage(1); }} />}
      >
        {paged.map((m, i) => {
          const isEditing = editingId === m.member_id;
          const editInputStyle = { width: '100%', padding: '4px 8px', border: '1px solid var(--line-strong)', borderRadius: 6, fontSize: 12, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' };
          const startEdit = () => { setEditingId(m.member_id); setDraft({ region: m.region, name: m.name, free_coin: m.free_coin, bonus_coin: m.bonus_coin, rejoin_date: m.rejoin_date, memo: (m.note === '—' ? '' : m.note) }); };
          const cancelEdit = () => { setEditingId(null); setDraft({}); };
          const saveEdit = () => performSave(m);
          const updateDraft = (k, v) => setDraft(d => ({ ...d, [k]: v }));
          return (
          <Row key={m.member_id} last={i === paged.length - 1} style={isEditing ? { background: 'var(--accent-soft)' } : {}}>
            <td style={tableStyles.cell}>
              {isEditing
                ? <select value={draft.region} onChange={e => updateDraft('region', e.target.value)} style={editInputStyle}>{REGIONS.slice(1).map(r => <option key={r} value={r}>{r}</option>)}</select>
                : m.region}
            </td>
            <td style={{ ...tableStyles.cell, ...tableStyles.mono, color: 'var(--ink-3)', textAlign: 'center' }}>
              {isEditing
                ? <input value={draft.name} onChange={e => updateDraft('name', e.target.value)} style={editInputStyle} />
                : m.name_phone_key}
            </td>
            <td style={{ ...tableStyles.cell, textAlign: 'right', ...tableStyles.num, fontWeight: 600 }}>
              {isEditing
                ? <input type="number" value={draft.free_coin} onChange={e => updateDraft('free_coin', Number(e.target.value))} style={{ ...editInputStyle, textAlign: 'right' }} />
                : m.free_coin.toLocaleString()}
            </td>
            <td style={{ ...tableStyles.cell, textAlign: 'right', ...tableStyles.num, color: 'oklch(0.48 0.12 300)', fontWeight: 600 }}>
              {isEditing
                ? <input type="number" value={draft.bonus_coin} onChange={e => updateDraft('bonus_coin', Number(e.target.value))} style={{ ...editInputStyle, textAlign: 'right' }} />
                : m.bonus_coin.toLocaleString()}
            </td>
            <td style={{ ...tableStyles.cell, ...tableStyles.mono, color: 'var(--ink-3)' }}>
              {isEditing
                ? <input type="date" value={draft.rejoin_date || ''} onChange={e => updateDraft('rejoin_date', e.target.value)} style={editInputStyle} />
                : m.rejoin_date}
            </td>
            <td style={tableStyles.cell} data-role-dropdown>
              <button
                ref={el => { if (el) roleBtnRefs.current[m.member_id] = el; }}
                onClick={(e) => { e.stopPropagation(); openRoleDropdown(m.member_id, e.currentTarget); }}
                style={{ background: 'transparent', border: 'none', padding: 0, cursor: 'pointer' }}>
                <RoleBadge role={roleOverrides[m.member_id] || m.role} />
              </button>
            </td>
            <td style={{ ...tableStyles.cell, textAlign: 'center' }}>
              {isEditing
                ? <div style={{ display: 'inline-flex', gap: 4 }}>
                    <button onClick={saveEdit} disabled={saving} style={{ ...iconBtn, color: 'var(--accent-ink)', borderColor: 'var(--accent)', opacity: saving ? 0.5 : 1, cursor: saving ? 'not-allowed' : 'pointer' }} title="저장"><Icon name="check" size={13} /></button>
                    <button onClick={cancelEdit} disabled={saving} style={iconBtn} title="취소"><Icon name="close" size={13} /></button>
                    <button onClick={() => setConfirmDel({ id: m.member_id, name: m.name || m.name_phone_key })} disabled={saving} style={{ ...iconBtn, color: 'oklch(0.52 0.17 25)', opacity: saving ? 0.5 : 1, cursor: saving ? 'not-allowed' : 'pointer' }} title="삭제"><Icon name="trash" size={13} /></button>
                  </div>
                : <button onClick={startEdit} style={iconBtn}><Icon name="edit" size={13} /></button>}
            </td>
            <td style={{ ...tableStyles.cell, textAlign: 'right', ...tableStyles.num,
              color: m.inappropriate_count > 0 ? '#dc2626' : 'var(--ink-4)',
              fontWeight: m.inappropriate_count > 0 ? 600 : 400,
            }}>{m.inappropriate_count > 0 ? `${m.inappropriate_count}회` : 0}</td>
            <td style={{ ...tableStyles.cell, color: m.note !== '—' ? 'var(--ink-2)' : 'var(--ink-4)', fontSize: 12 }}>
              {isEditing ? (
                <input value={draft.memo} onChange={e => updateDraft('memo', e.target.value)} style={editInputStyle} placeholder="비고" />
              ) : (
                <>
                  {m.permanently_banned && (
                    <div style={{ color: '#dc2626', fontWeight: 700, fontSize: 11, marginBottom: 2 }}>🚫 영구정지</div>
                  )}
                  {!m.permanently_banned && m.suspended_until && new Date() <= new Date(m.suspended_until + 'T23:59:59') && (
                    <div style={{ color: '#dc2626', fontWeight: 700, fontSize: 11, marginBottom: 2 }}>
                      ⛔ {m.suspended_until.replace(/-/g, '. ')}.까지 이용정지
                    </div>
                  )}
                  {m.note}
                </>
              )}
            </td>
          </Row>
          );
        })}
      </TableShell>

      {confirmDel && (
        <div onClick={() => !deleting && setConfirmDel(null)} style={{
          position: 'fixed', inset: 0, background: 'rgba(15,23,42,0.45)',
          display: 'grid', placeItems: 'center', zIndex: 100, padding: 20,
        }}>
          <div onClick={e => e.stopPropagation()} style={{
            background: '#fff', borderRadius: 14, padding: 24, maxWidth: 380, width: '100%',
            boxShadow: '0 20px 60px rgba(15,23,42,0.30)',
          }}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
              <Icon name="warning" size={20} style={{ color: 'oklch(0.52 0.17 25)' }} />
              <div style={{ fontSize: 16, fontWeight: 700, color: 'var(--ink)' }}>회원 삭제</div>
            </div>
            <div style={{ fontSize: 13.5, color: 'var(--ink-2)', lineHeight: 1.6, marginBottom: 20 }}>
              <b>{confirmDel.name}</b> 회원을 정말 삭제하시겠습니까?<br />
              <span style={{ fontSize: 12, color: 'var(--ink-4)' }}>이 작업은 되돌릴 수 없습니다.</span>
            </div>
            <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
              <Button onClick={() => setConfirmDel(null)} disabled={deleting}>취소</Button>
              <Button variant="danger" onClick={performDelete} disabled={deleting}
                icon={<Icon name="trash" size={13} />}>
                {deleting ? '삭제 중…' : '삭제'}
              </Button>
            </div>
          </div>
        </div>
      )}

      {roleDropdownId && roleDropdownPos && ReactDOM.createPortal(
        <div data-role-dropdown style={{
          position: 'fixed', top: roleDropdownPos.top, left: roleDropdownPos.left,
          transform: 'translateX(-50%)',
          background: '#fff', border: '1px solid var(--line-strong)', borderRadius: 10,
          boxShadow: '0 8px 24px rgba(0,0,0,0.15)', zIndex: 9999, padding: 4,
        }}>
          {(() => {
            const cur = (() => {
              const m = (filtered || []).find(x => x.member_id === roleDropdownId);
              return (roleOverrides[roleDropdownId] || (m && m.role));
            })();
            return [
              { v: 'regular', l: '일반' },
              { v: 'admin',   l: '지역관리자' },
              { v: 'master',  l: '총괄관리자' },
            ].filter(o => o.v !== 'master' || myRole === 'master').map(o => {
              const active = cur === o.v;
              return (
                <button key={o.v}
                  onClick={(e) => { e.stopPropagation(); changeRole(roleDropdownId, o.v); setRoleDropdownId(null); }}
                  style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 12px', border: 'none', background: active ? 'var(--accent-soft)' : '#fff', color: active ? 'var(--accent-ink)' : 'var(--ink)', borderRadius: 6, cursor: 'pointer', fontSize: 12, fontFamily: 'inherit', whiteSpace: 'nowrap' }}>
                  {active && <Icon name="check" size={11} />}
                  <span>{o.l}</span>
                </button>
              );
            });
          })()}
        </div>,
        document.body
      )}
    </div>
  );
};

const iconBtn = {
  width: 28, height: 28, borderRadius: 7, border: '1px solid var(--line-strong)',
  background: '#fff', color: 'var(--ink-3)', cursor: 'pointer',
  display: 'inline-grid', placeItems: 'center', fontFamily: 'inherit',
};

// ═══════════════════════════════════════════════════════════════
// 회원관리 > 패널티 (1-4, 마스터)
// ═══════════════════════════════════════════════════════════════
const MemberPenaltyView = () => {
  const isMobile = useIsMobile();
  const [sort, onSort] = useSort('end', 'asc');
  const [penaltyMonths, setPenaltyMonths] = React.useState(3);

  React.useEffect(() => {
    TSL_API.penaltyMonths().then(m => { if (typeof m === 'number' && m > 0) setPenaltyMonths(m); }).catch(() => {});
  }, []);

  const today = new Date();
  const parseDate = (s) => s ? new Date(s.replace(/\./g, '-')) : null;
  const daysBetween = (a, b) => Math.ceil((b - a) / (1000 * 60 * 60 * 24));

  const { data: liveMembers } = useApiData(TSL_API.members, []);
  const baseMembers = liveMembers || [];
  // 원본 admin.html 로직: 재가입일 + penalty_months 가 현재보다 미래이면 패널티 적용 중
  const rows = baseMembers
    .filter(m => {
      if (m.permanently_banned) return true;
      if (m.suspended_until) return true;
      if (!m.rejoin_date) return false;
      const rd = new Date(m.rejoin_date);
      if (isNaN(rd.getTime())) return false;
      const end = new Date(rd);
      end.setMonth(end.getMonth() + penaltyMonths);
      return today < end;
    })
    .map(m => {
      const permanent = !!m.permanently_banned;
      let endDate = m.suspended_until || null;
      if (!permanent && !endDate && m.rejoin_date) {
        const rd = new Date(m.rejoin_date);
        if (!isNaN(rd.getTime())) {
          const e = new Date(rd);
          e.setMonth(e.getMonth() + penaltyMonths);
          endDate = e.toISOString().slice(0, 10);
        }
      }
      let remaining = null;
      if (!permanent && endDate) {
        const ed = parseDate(endDate);
        if (ed) remaining = daysBetween(today, ed);
      }
      return { ...m, endDate, remaining, permanent };
    });

  const sorted = sortRows(rows, sort, {
    region: r => r.region, name: r => r.name, rejoin: r => r.rejoin_date,
    end: r => r.endDate || '9999', remaining: r => r.remaining ?? 99999,
  });

  const stats = {
    total: rows.length,
    permanent: rows.filter(r => r.permanent).length,
    temp: rows.filter(r => !r.permanent).length,
    endingSoon: rows.filter(r => !r.permanent && r.remaining !== null && r.remaining <= 7).length,
  };

  return (
    <div>
      <SectionHeader title="패널티 회원" desc="현재 제재가 적용 중인 회원입니다" />

      <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, 1fr)', gap: isMobile ? 10 : 12, marginBottom: 16 }}>
        <StatCard label="패널티 전체" value={stats.total} unit="명" tone="danger" />
        <StatCard label="일시정지" value={stats.temp} unit="명" tone="warn" />
        <StatCard label="영구정지" value={stats.permanent} unit="명" tone="danger" />
      </div>

      {sorted.length === 0 ? (
        <EmptyState title="패널티 적용 회원이 없습니다" desc="현재 모든 회원이 정상 상태입니다" icon="check" />
      ) : (
        <TableShell
          minWidth={1020}
          headers={<>
            <SortHeader label="지역"     col="region"    sort={sort} onSort={onSort} width={130} />
            <th style={{ ...tableStyles.headerCell, textAlign: 'center', width: 130 }}>고유번호</th>
            <SortHeader label="재가입일" col="rejoin"   sort={sort} onSort={onSort} width={120} />
            <SortHeader label="패널티 종료일" col="end" sort={sort} onSort={onSort} width={130} />
            <SortHeader label="남은 기간" col="remaining" sort={sort} onSort={onSort} align="right" width={130} />
            <th style={{ ...tableStyles.headerCell, width: 100 }}>유형</th>
            <th style={{ ...tableStyles.headerCell, textAlign: 'right', width: 110 }}>작업</th>
          </>}
        >
          {sorted.map((m, i) => (
            <Row key={m.member_id} last={i === sorted.length - 1}>
              <td style={tableStyles.cell}>{m.region}</td>
              <td style={{ ...tableStyles.cell, ...tableStyles.mono, color: 'var(--ink-3)', textAlign: 'center' }}>{m.name_phone_key}</td>
              <td style={{ ...tableStyles.cell, ...tableStyles.mono, color: 'var(--ink-3)' }}>{m.rejoin_date}</td>
              <td style={{ ...tableStyles.cell, ...tableStyles.mono, color: m.permanent ? 'oklch(0.52 0.17 25)' : 'var(--ink-2)' }}>
                {m.permanent ? '—' : m.endDate}
              </td>
              <td style={{ ...tableStyles.cell, textAlign: 'right', ...tableStyles.num }}>
                {m.permanent
                  ? <Pill label="영구" tone="crimson" size="sm" />
                  : <span style={{
                      fontWeight: 700, color: m.remaining <= 7 ? 'oklch(0.52 0.17 25)' : 'var(--ink)',
                    }}>{m.remaining}일</span>
                }
              </td>
              <td style={tableStyles.cell}>
                {m.permanent
                  ? <Pill label="영구정지" tone="crimson" size="sm" />
                  : <Pill label="패널티" tone="danger" size="sm" />
                }
              </td>
              <td style={{ ...tableStyles.cell, textAlign: 'right' }}>
                <Button size="sm" variant="danger">해제</Button>
              </td>
            </Row>
          ))}
        </TableShell>
      )}
    </div>
  );
};

Object.assign(window, { MemberSearchView, MemberRegisterView, MemberInfoView, MemberPenaltyView });


/* ══════════ 강의용 계정 (회원관리 > 강의용 계정, master 전용) ══════════ */
const LectureAccountsView = () => {
  const toast = useToast();
  const [refreshKey, setRefreshKey] = React.useState(0);
  const { data, error, loading } = useApiData(TSL_API.lectureAccounts, [refreshKey]);
  const [edits, setEdits] = React.useState({});   // { username: { free, bonus, pw } }
  const [bulkPw, setBulkPw] = React.useState('');
  const [saving, setSaving] = React.useState(false);
  const [shownPw, setShownPw] = React.useState(null);
  const rows = data || [];
  const inpStyle = { width: 110, padding: '7px 9px', border: '1px solid var(--border)', borderRadius: 8, fontSize: 13, fontFamily: 'inherit', boxSizing: 'border-box' };

  const getEdit = (username) => edits[username] || {};   // ★버그픽스: r.username(문자열) 전달인데 u.username 읽어 edits[undefined]만 보던 것
  const setField = (username, k, v) => setEdits(p => ({ ...p, [username]: { ...(p[username] || {}), [k]: v } }));

  const applyBulkPw = () => {
    if (!bulkPw || bulkPw.length < 4) { toast('error', '일괄 비밀번호를 4자 이상 입력하세요.'); return; }
    setEdits(p => { const n = { ...p }; rows.forEach(r => { n[r.username] = { ...(n[r.username] || {}), pw: bulkPw }; }); return n; });
    toast('success', '모든 계정 비밀번호 칸에 채웠어요. [저장]을 눌러 반영하세요.');
  };

  const save = async () => {
    setSaving(true); setShownPw(null);
    const pwUpdates = []; const coinCalls = [];
    rows.forEach(r => {
      const e = getEdit(r.username);
      if (e.pw && String(e.pw).length >= 4) pwUpdates.push({ username: r.username, password: String(e.pw) });
      const nf = (e.free === undefined || e.free === '') ? r.free_coin : parseInt(e.free, 10);
      const nb = (e.bonus === undefined || e.bonus === '') ? r.bonus_coin : parseInt(e.bonus, 10);
      if (!isNaN(nf) && nf >= 0 && nf !== r.free_coin) coinCalls.push({ member_id: r.member_id, type: 'free', amount: nf - r.free_coin });
      if (!isNaN(nb) && nb >= 0 && nb !== r.bonus_coin) coinCalls.push({ member_id: r.member_id, type: 'bonus', amount: nb - r.bonus_coin });
    });
    if (!pwUpdates.length && !coinCalls.length) { toast('error', '변경사항이 없습니다.'); setSaving(false); return; }
    const errs = [];
    for (const c of coinCalls) {
      try {
        const res = await fetch('/admin/coins/adjust', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...c, reason: '강의용 계정 토큰 조정' }) });
        if (!res.ok) errs.push('토큰(' + c.member_id.slice(0, 6) + ') ' + res.status);
      } catch (e) { errs.push(e.message || '네트워크'); }
    }
    let changedPw = [];
    if (pwUpdates.length) {
      try { const r = await TSL_API.setLecturePasswords(pwUpdates); changedPw = r.changed || []; if (r.failed && r.failed.length) errs.push('비번실패: ' + r.failed.map(f => f.username + '(' + f.reason + ')').join(', ')); }
      catch (e) { errs.push(e.message || '비번 변경 오류'); }
    }
    setSaving(false); setEdits({}); setBulkPw(''); setRefreshKey(k => k + 1);
    if (changedPw.length) setShownPw(pwUpdates.filter(u => changedPw.includes(u.username)));
    if (errs.length) toast('error', '일부 실패: ' + errs.join(' / '));
    else toast('success', '저장 완료' + (changedPw.length ? ' · 비번 ' + changedPw.length + '개' : '') + (coinCalls.length ? ' · 토큰 ' + coinCalls.length + '건' : ''));
  };

  return (
    <div>
      <SectionHeader title="강의용 계정" desc="강의용1~10 · 카카오 없이 아이디/비밀번호로 로그인하는 계정. 비밀번호 일괄/개별 변경 및 토큰 수량 조정 (총괄관리자 전용)." />
      {error && <div style={{ color: '#dc2626', padding: 12 }}>불러오기 실패: {error}</div>}
      {loading ? <div style={{ padding: 24, color: 'var(--ink-3)' }}>불러오는 중…</div> : (
        <div>
          <div style={{ display: 'flex', gap: 8, alignItems: 'center', margin: '8px 0 14px', flexWrap: 'wrap' }}>
            <TextInput value={bulkPw} onChange={setBulkPw} placeholder="일괄 비밀번호 (전체 동일 적용)" width={260} />
            <Button onClick={applyBulkPw}>전체 채우기</Button>
            <div style={{ flex: 1 }} />
            <Button variant="primary" disabled={saving} onClick={save}>{saving ? '저장 중…' : '저장'}</Button>
          </div>
          <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
            <thead><tr style={{ textAlign: 'left', color: 'var(--ink-3)', borderBottom: '1px solid var(--border)' }}>
              <th style={{ padding: '8px 10px' }}>아이디(고유번호)</th>
              <th style={{ padding: '8px 10px', width: 130 }}>일반토큰</th>
              <th style={{ padding: '8px 10px', width: 130 }}>보너스토큰</th>
              <th style={{ padding: '8px 10px', width: 230 }}>새 비밀번호</th>
            </tr></thead>
            <tbody>
              {rows.map(r => { const e = getEdit(r.username); return (
                <tr key={r.username} style={{ borderBottom: '1px solid var(--border)' }}>
                  <td style={{ padding: '8px 10px', fontWeight: 600 }}>{r.username}</td>
                  <td style={{ padding: '8px 10px' }}><TextInput type="number" width={100} value={String(e.free !== undefined ? e.free : r.free_coin)} onChange={v => setField(r.username, 'free', v)} /></td>
                  <td style={{ padding: '8px 10px' }}><TextInput type="number" width={100} value={String(e.bonus !== undefined ? e.bonus : r.bonus_coin)} onChange={v => setField(r.username, 'bonus', v)} /></td>
                  <td style={{ padding: '8px 10px' }}><TextInput width={200} value={e.pw || ''} onChange={v => setField(r.username, 'pw', v)} placeholder="변경 시 입력(빈칸=유지)" /></td>
                </tr>
              ); })}
              {!rows.length && <tr><td colSpan={4} style={{ padding: 24, color: 'var(--ink-3)', textAlign: 'center' }}>강의용 계정이 없습니다.</td></tr>}
            </tbody>
          </table>
          {shownPw && shownPw.length > 0 && (
            <div style={{ marginTop: 14, padding: 12, background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 8, fontSize: 13 }}>
              <b>변경된 비밀번호</b> (이 화면에서만 표시 — 메모해두세요)
              <div style={{ marginTop: 6, fontFamily: 'monospace', lineHeight: 1.7 }}>{shownPw.map(u => <div key={u.username}>{u.username} : {u.password}</div>)}</div>
            </div>
          )}
        </div>
      )}
    </div>
  );
};
