/** @jsxRuntime classic */
// 관리 화면 (PRD 5.3). 머리글의 "관리"를 누르면 열리고, 관리자 비밀번호로 로그인해야 쓸 수 있다.
// 화면 안의 이동은 주소의 # 뒤(#/teams, #/team/<id>, #/bulk … — ADMIN_ROUTES)로 한다.
// 계산은 모두 calc.js(window.TE.calc)로 하고, 저장은 /api/admin/* 로 한다. 저장한 뒤에는 서버에서 다시 읽어 화면을 맞춘다.

const KIND = { dining: '회식비', interview: '면담비' };
const EXCELJS_SRC = 'https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js';
const EXCELJS_SRI = 'sha384-Pqp51FUN2/qzfxZxBCtF0stpc9ONI6MYZpVqmo8m20SoaQCzf+arZvACkLkirlPz';
const FIRST_YEAR = 2026;

// 서버에서 읽어 오는 데이터. 다시 읽는 동안에도 이전 데이터를 보여 준다.
function useLoad(loader, deps) {
  const [st, setSt] = useState({ loading: true, data: null, error: null, at: null });
  const seq = useRef(0);
  const reload = useCallback(async () => {
    const my = ++seq.current;
    setSt(s => ({ ...s, loading: true, error: null }));
    try {
      const data = await loader();
      if (my === seq.current) setSt({ loading: false, data, error: null, at: new Date() });
    } catch (err) {
      if (my === seq.current) setSt(s => ({ ...s, loading: false, error: err.message }));
    }
  }, deps);
  useEffect(() => { reload(); }, [reload]);
  return { ...st, reload };
}

function useStored(key, initial) {
  const [v, setV] = useState(() => { try { return localStorage.getItem(key) ?? initial; } catch { return initial; } });
  const set = x => { setV(x); try { localStorage.setItem(key, x); } catch {} };
  return [v, set];
}

function yearOptions(extra = []) {
  const now = kstToday().year;
  const last = Math.max(now + 1, ...extra);
  const first = Math.min(FIRST_YEAR, ...extra);
  const ys = [];
  for (let y = last; y >= first; y--) ys.push(y);
  return ys;
}

// ---- 앱 ----
function AdminApp() {
  const [auth, setAuth] = useState({ state: 'checking' });
  const check = useCallback(async () => {
    try {
      const s = await api('GET', '/api/auth/session');
      setAuth(s.admin ? { state: 'in' } : { state: 'login', configured: s.configured });
    } catch (err) {
      setAuth({ state: 'error', message: err.message });
    }
  }, []);
  useEffect(() => {
    apiEvents.onUnauthorized = () => setAuth(a => (a.state === 'in' ? { state: 'login', configured: true, expired: true } : a));
    check();
  }, []);

  if (auth.state === 'in') return <AdminShell onLogout={() => setAuth({ state: 'login', configured: true })} />;
  return (
    <>
      <TopBar mode="admin" />
      <main className="page">
        {auth.state === 'checking' && <div className="page-msg">불러오는 중…</div>}
        {auth.state === 'error' && <div className="page-msg"><p>{auth.message}</p><button className="btn" onClick={check}>다시 시도</button></div>}
        {auth.state === 'login' && <LoginView configured={auth.configured} expired={auth.expired} onDone={() => setAuth({ state: 'in' })} />}
      </main>
    </>
  );
}

function LoginView({ configured, expired, onDone }) {
  const [password, setPassword] = useState('');
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState(expired ? '로그인이 끝났습니다. 다시 로그인해 주세요' : '');
  async function submit(e) {
    e.preventDefault();
    if (!password) return;
    setBusy(true);
    setError('');
    try {
      await api('POST', '/api/auth/login', { password });
      onDone();
    } catch (err) {
      setError(err.message);
      setBusy(false);
    }
  }
  return (
    <div className="login">
      <form className="card" onSubmit={submit}>
        <h1>관리자 로그인</h1>
        <p className="muted small" style={{ margin: '0 0 20px' }}>관리 화면은 관리자 비밀번호로 들어갑니다.</p>
        {configured === false ? (
          <p className="err-text">관리자 비밀번호가 아직 설정되지 않았습니다. 저장소 폴더에서 <code>npm run admin:password</code> 로 정해 주세요.</p>
        ) : (
          <div className="stack" style={{ gap: 12 }}>
            <label className="field"><span>비밀번호</span>
              <input className="input" type="password" autoComplete="current-password" autoFocus value={password} onChange={e => setPassword(e.target.value)} />
            </label>
            {error && <div className="err-text" role="alert">{error}</div>}
            <button className="btn primary" disabled={busy || !password}>{busy ? '확인 중…' : '로그인'}</button>
          </div>
        )}
      </form>
    </div>
  );
}

const TABS = [
  { name: 'teams', label: '팀 관리', match: ['teams', 'team'] },
  { name: 'bulk', label: '월별 일괄 입력' },
  { name: 'files', label: '엑셀·백업' },
  { name: 'log', label: '변경 기록' },
  { name: 'settings', label: '설정' },
];

function AdminShell({ onLogout }) {
  const route = useRoute();
  const toast = useToast();
  // 로그아웃하면 팀 조회 화면으로 돌아간다
  async function logout() {
    if (!confirmLeave()) return;
    try { await api('POST', '/api/auth/logout', {}); } catch {}
    dirtyForms.clear();
    onLogout();
    go('/');
  }
  // 팀에 보내는 접속 링크. ?mode=user 로 들어오면 "관리" 전환이 보이지 않는다(기존 사이트와 같은 방식)
  async function copyLink() {
    const ok = await copyText(`${location.origin}/?mode=user`);
    ok ? toast.ok('팀 접속 링크를 복사했습니다') : toast.err('복사하지 못했습니다. 주소창의 주소를 직접 복사해 주세요');
  }
  const active = TABS.find(t => (t.match || [t.name]).includes(route.name)) || TABS[0];
  let view;
  if (route.name === 'team' && route.id) view = <TeamDetail key={route.id} id={route.id} q={route.q} />;
  else if (route.name === 'bulk') view = <BulkView />;
  else if (route.name === 'files') view = <FilesView />;
  else if (route.name === 'log') view = <LogView q={route.q} />;
  else if (route.name === 'settings') view = <SettingsView />;
  else view = <TeamsView />;
  return (
    <>
      <TopBar mode="admin"
        actions={<>
          <button type="button" className="btn ghost icon" onClick={copyLink} title="팀 접속 링크 복사" aria-label="팀 접속 링크 복사"><Icon.Link /></button>
          <button type="button" className="btn ghost icon" onClick={logout} title="로그아웃" aria-label="로그아웃"><Icon.Logout /></button>
        </>}
        nav={
          <nav className="tabs" aria-label="관리 메뉴">
            {TABS.map(t => (
              <button key={t.name} type="button" className="tab" aria-current={t === active ? 'page' : undefined}
                onClick={() => { if (t !== active || route.name !== t.name) { if (confirmLeave()) go(`/${t.name}`); } }}>{t.label}</button>
            ))}
          </nav>
        } />
      <main className="page">{view}</main>
    </>
  );
}

function PageHead({ title, sub, load, children, back }) {
  return (
    <div className="page-head">
      <div>
        {back && <button type="button" className="btn ghost sm" style={{ marginLeft: -8, marginBottom: 2 }} onClick={() => { if (confirmLeave()) go(back.to); }}><Icon.Back />{back.label}</button>}
        <h1>{title}</h1>
        {sub}
      </div>
      <div className="row">
        {children}
        {load && (
          <span className="row" style={{ gap: 4 }}>
            <span className="stamp">{load.at ? `기준 ${kstTime(load.at)}` : ''}</span>
            <button type="button" className="btn ghost icon" title="새로고침" aria-label="새로고침" disabled={load.loading}
              onClick={() => { if (confirmLeave()) load.reload(); }}><Icon.Refresh /></button>
          </span>
        )}
      </div>
    </div>
  );
}

function LoadError({ load }) {
  if (!load.error) return null;
  return (
    <div className="card card-pad" role="alert" style={{ marginBottom: 16 }}>
      <div className="err-text">{load.error}</div>
      <button className="btn sm" style={{ marginTop: 8 }} onClick={load.reload}>다시 불러오기</button>
    </div>
  );
}

// ---- 팀 관리 ----
function TeamsView() {
  const toast = useToast();
  const load = useLoad(() => api('GET', '/api/admin/teams').then(d => d.teams), []);
  const [division, setDivision] = useStored('admin.division', '인');
  const [reorder, setReorder] = useState(false);
  const [order, setOrder] = useState(null);       // 순서를 바꾸는 중의 id 배열
  const [drag, setDrag] = useState({ id: null, over: null });
  const [adding, setAdding] = useState(false);

  const all = load.data || [];
  const teams = all.filter(t => t.division === division);
  const shown = order ? order.map(id => teams.find(t => t.id === id)).filter(Boolean) : teams;
  const counts = { 인: all.filter(t => t.division === '인').length, 물: all.filter(t => t.division === '물').length };

  async function saveOrder(ids) {
    setOrder(ids);
    try {
      await api('PUT', '/api/admin/teams/order', { division, ids });
      toast.ok('순서를 저장했습니다');
      await load.reload();
    } catch (err) {
      toast.err(err.message);
      await load.reload();
    }
    setOrder(null);
  }
  const move = (ids, from, to) => { const a = [...ids]; const [x] = a.splice(from, 1); a.splice(to, 0, x); return a; };
  const ids = shown.map(t => t.id);

  return (
    <>
      <PageHead title="팀 관리" load={load}>
        <button className="btn primary" onClick={() => setAdding(true)}>팀 추가</button>
      </PageHead>
      <LoadError load={load} />
      <div className="spread" style={{ marginBottom: 12 }}>
        <div className="seg" role="group" aria-label="구분">
          {['인', '물'].map(d => <button key={d} type="button" aria-pressed={division === d} onClick={() => { setDivision(d); setReorder(false); }}>({d})서면 {counts[d]}</button>)}
        </div>
        <button className={cx('btn sm', reorder && 'primary')} onClick={() => setReorder(r => !r)}>{reorder ? '순서 바꾸기 끝' : '순서 바꾸기'}</button>
      </div>
      {reorder && <p className="hint" style={{ margin: '-4px 0 10px' }}>끌어서 놓거나 화살표로 옮기면 바로 저장됩니다. 이 순서가 엑셀 시트 순서가 됩니다.</p>}
      <div className="card">
        {load.loading && !load.data ? <div className="card-pad muted">불러오는 중…</div> : shown.length === 0 ? <div className="card-pad muted">팀이 없습니다.</div> : (
          <ul className="list">
            {shown.map((t, i) => (
              <li key={t.id}
                className={cx(!reorder && 'linkrow', drag.id === t.id && 'dragging', drag.over === t.id && drag.id !== t.id && 'drop-target')}
                draggable={reorder && !order}
                onDragStart={e => { e.dataTransfer.effectAllowed = 'move'; setDrag({ id: t.id, over: null }); }}
                onDragOver={e => { if (drag.id) { e.preventDefault(); if (drag.over !== t.id) setDrag(d => ({ ...d, over: t.id })); } }}
                onDrop={e => { e.preventDefault(); const from = ids.indexOf(drag.id); setDrag({ id: null, over: null }); if (from >= 0 && from !== i) saveOrder(move(ids, from, i)); }}
                onDragEnd={() => setDrag({ id: null, over: null })}
                onClick={() => { if (!reorder) go(`/team/${encodeURIComponent(t.id)}`); }}>
                {reorder && <span className="grip" aria-hidden="true"><Icon.Grip width="18" height="18" /></span>}
                <span className="muted small num" style={{ width: 22, textAlign: 'right' }}>{i + 1}</span>
                <div className="grow">
                  <div className="title">{t.name}</div>
                  <div className="small muted" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                    시트 {t.sheet_name}{t.title_name !== t.sheet_name ? ` · 제목 ${t.title_name}` : ''}
                  </div>
                </div>
                <div className="row" style={{ gap: 4, justifyContent: 'flex-end' }}>
                  {!t.has_passcode && <span className="badge warn">비밀번호 미설정</span>}
                  {!t.has_interview && <span className="badge">면담비 없음</span>}
                </div>
                {reorder && (
                  <span className="row" style={{ gap: 2, flexWrap: 'nowrap' }}>
                    <button className="btn ghost icon sm" disabled={i === 0 || !!order} aria-label="위로" onClick={e => { e.stopPropagation(); saveOrder(move(ids, i, i - 1)); }}><Icon.Up /></button>
                    <button className="btn ghost icon sm" disabled={i === shown.length - 1 || !!order} aria-label="아래로" onClick={e => { e.stopPropagation(); saveOrder(move(ids, i, i + 1)); }}><Icon.Down /></button>
                  </span>
                )}
              </li>
            ))}
          </ul>
        )}
      </div>
      {adding && <TeamFormModal division={division} onClose={() => setAdding(false)}
        onSaved={team => { setAdding(false); toast.ok(`${team.name} 팀을 추가했습니다`); go(`/team/${encodeURIComponent(team.id)}`); }} />}
    </>
  );
}

// 팀 추가·수정. team 이 있으면 수정이다.
function TeamFormModal({ team, division, onClose, onSaved }) {
  const editing = !!team;
  const [f, setF] = useState(() => ({
    name: team ? team.name : '', sheet_name: team ? team.sheet_name : '', title_name: team ? team.title_name : '',
    division: team ? team.division : division || '인', has_interview: team ? !!team.has_interview : true,
    passcode: '', clearPasscode: false, writer: '',
  }));
  // 추가할 때는 시트 이름·제목용 이름을 직접 고치기 전까지 표시명을 따라간다
  const [touched, setTouched] = useState({ sheet_name: editing, title_name: editing });
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');
  const set = (k, v) => setF(p => {
    const n = { ...p, [k]: v };
    if (k === 'name') {
      if (!touched.sheet_name) n.sheet_name = v.slice(0, 31);
      if (!touched.title_name) n.title_name = v;
    }
    return n;
  });

  async function submit(e) {
    e.preventDefault();
    setBusy(true);
    setError('');
    const body = { name: f.name, sheet_name: f.sheet_name, title_name: f.title_name, division: f.division, has_interview: f.has_interview, passcode: f.passcode };
    try {
      const r = editing
        ? await api('PUT', `/api/admin/teams/${encodeURIComponent(team.id)}`, { ...body, clearPasscode: f.clearPasscode })
        : await api('POST', '/api/admin/teams', { ...body, writer: f.writer, year: kstToday().year });
      onSaved(r.team, r.unchanged);
    } catch (err) {
      setError(err.message);
      setBusy(false);
    }
  }
  return (
    <Modal title={editing ? '팀 정보 수정' : '팀 추가'} onClose={onClose} busy={busy}
      actions={<><button type="button" className="btn" onClick={onClose} disabled={busy}>취소</button><button className="btn primary" form="team-form" disabled={busy}>{busy ? '저장 중…' : '저장'}</button></>}>
      <form id="team-form" className="stack" style={{ gap: 12 }} onSubmit={submit}>
        <label className="field"><span>표시명 (사이트에 보이는 이름)</span><input className="input" value={f.name} onChange={e => set('name', e.target.value)} maxLength={40} required /></label>
        <div className="grid-form">
          <label className="field"><span>엑셀 시트 이름</span><input className="input" value={f.sheet_name} maxLength={31} required onChange={e => { setTouched(t => ({ ...t, sheet_name: true })); set('sheet_name', e.target.value); }} /></label>
          <label className="field"><span>엑셀 제목용 이름</span><input className="input" value={f.title_name} maxLength={60} required onChange={e => { setTouched(t => ({ ...t, title_name: true })); set('title_name', e.target.value); }} /></label>
        </div>
        <div className="grid-form">
          <div className="field"><span>구분</span>
            <div className="seg" role="group" aria-label="구분">
              {['인', '물'].map(d => <button key={d} type="button" aria-pressed={f.division === d} onClick={() => set('division', d)}>({d})서면</button>)}
            </div>
          </div>
          <div className="field"><span>면담비</span>
            <label className="check" style={{ minHeight: 40 }}><input type="checkbox" checked={f.has_interview} onChange={e => set('has_interview', e.target.checked)} />면담비 항목 사용</label>
          </div>
        </div>
        {editing && team.division !== f.division && <div className="warn-text">구분을 바꾸면 그 구분 목록의 맨 뒤로 옮겨집니다.</div>}
        {editing && team.has_interview && !f.has_interview && <div className="warn-text">면담비를 끄면 면담비 한도·내역이 화면과 엑셀에 나오지 않습니다(저장된 내역은 남습니다).</div>}
        <div className="grid-form">
          <label className="field"><span>{editing ? '새 조회 비밀번호 (바꿀 때만)' : '조회 비밀번호 (숫자 4자리)'}</span>
            <input className="input num" inputMode="numeric" autoComplete="off" maxLength={4} value={f.passcode} disabled={f.clearPasscode}
              placeholder={editing ? (team.has_passcode ? '설정되어 있음' : '미설정') : '비워 두면 미설정'}
              onChange={e => set('passcode', e.target.value.replace(/\D/g, '').slice(0, 4))} />
          </label>
          {editing ? (
            team.has_passcode ? <div className="field"><span>&nbsp;</span><label className="check" style={{ minHeight: 40 }}><input type="checkbox" checked={f.clearPasscode} onChange={e => { set('clearPasscode', e.target.checked); set('passcode', ''); }} />비밀번호 해제</label></div> : <div />
          ) : (
            <label className="field"><span>작성자 ({kstToday().year}년, 선택)</span><input className="input" value={f.writer} maxLength={40} onChange={e => set('writer', e.target.value)} /></label>
          )}
        </div>
        <div className="hint">비밀번호가 미설정이면 팀이 조회 화면에 들어올 수 없습니다.{editing ? ' 작성자는 팀 상세의 연도 정보에서 고칩니다.' : ''}</div>
        {error && <div className="err-text" role="alert">{error}</div>}
      </form>
    </Modal>
  );
}

function DeleteTeamModal({ team, onClose, onDeleted }) {
  const [typed, setTyped] = useState('');
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');
  async function submit(e) {
    e.preventDefault();
    setBusy(true);
    try {
      await api('DELETE', `/api/admin/teams/${encodeURIComponent(team.id)}`, { confirmName: typed });
      onDeleted();
    } catch (err) {
      setError(err.message);
      setBusy(false);
    }
  }
  return (
    <Modal title="팀 삭제" onClose={onClose} busy={busy}
      actions={<><button type="button" className="btn" onClick={onClose} disabled={busy}>취소</button><button className="btn danger solid" form="del-form" disabled={busy || typed !== team.name}>{busy ? '삭제 중…' : '삭제'}</button></>}>
      <form id="del-form" className="stack" style={{ gap: 12 }} onSubmit={submit}>
        <p style={{ margin: 0 }}><strong>{team.name}</strong> 팀과 그 팀의 모든 연도 정보·월 설정·사용 내역이 지워집니다.</p>
        <p className="hint" style={{ margin: 0 }}>지우기 직전 데이터는 변경 기록에 보관되어 내려받을 수 있습니다. 7일 안에는 D1 시점 복구(Time Travel)도 됩니다.</p>
        <label className="field"><span>확인을 위해 팀 이름 <strong>{team.name}</strong> 을(를) 입력하세요</span>
          <input className="input" value={typed} onChange={e => setTyped(e.target.value)} autoComplete="off" />
        </label>
        {error && <div className="err-text" role="alert">{error}</div>}
      </form>
    </Modal>
  );
}

// ---- 팀 상세 ----
// 서버 응답을 calc.js 가 받는 연도별 묶음으로
function groupByYear(data, untilYear) {
  const byYear = new Map();
  const add = y => { if (!byYear.has(y)) byYear.set(y, { teamYear: null, months: [], expenses: [] }); return byYear.get(y); };
  for (const y of data.years) add(y.year).teamYear = y;
  for (const m of data.months) add(m.year).months.push(m);
  for (const e of data.expenses) add(e.year).expenses.push(e);
  add(untilYear);
  return byYear;
}

function TeamDetail({ id, q }) {
  const toast = useToast();
  const today = kstToday();
  const load = useLoad(() => api('GET', `/api/admin/teams/${encodeURIComponent(id)}`), [id]);
  const [year, setYear] = useState(() => Number(q.y) || today.year);
  const [month, setMonth] = useState(() => Number(q.m) || (Number(q.y || today.year) === today.year ? today.month : 1));
  const [modal, setModal] = useState(null);

  useEffect(() => { history.replaceState(null, '', `#/team/${encodeURIComponent(id)}?y=${year}&m=${month}`); }, [id, year, month]);
  // 바뀌는 입력 묶음은 다시 그려지면서 dirtyForms 에서 빠진다
  const pick = (y, m) => {
    if (y === year && m === month) return;
    if (confirmLeave(k => y === year && k.startsWith('year-'))) { setYear(y); setMonth(m); }
  };

  const data = load.data;
  const view = useMemo(() => {
    if (!data) return null;
    const byYear = groupByYear(data, year);
    const res = TE.calc.calcYears({ byYear, untilYear: year, hasInterview: !!data.team.has_interview, rule: data.rule });
    const prevDec = res.get(year - 1) ? res.get(year - 1).months[11] : null;
    return { byYear, calc: res.get(year), prevDec, ty: byYear.get(year).teamYear, monthsRows: byYear.get(year).months, expenses: byYear.get(year).expenses };
  }, [data, year]);

  if (!data) {
    return (
      <>
        <PageHead title="팀 상세" load={load} back={{ to: '/teams', label: '팀 목록' }} />
        {load.error ? <LoadError load={load} /> : <div className="muted">불러오는 중…</div>}
      </>
    );
  }
  const team = data.team;
  const c = view.calc.months[month - 1];
  const row = view.monthsRows.find(m => m.month === month) || null;
  // 전월 값(미입력 달을 미리 채울 때). 1월이면 전년도 12월
  const prevRow = month > 1
    ? view.monthsRows.find(m => m.month === month - 1)
    : (view.byYear.get(year - 1) ? view.byYear.get(year - 1).months.find(m => m.month === 12) : null);
  const saved = () => load.reload();

  return (
    <>
      <PageHead title={team.name} load={load} back={{ to: '/teams', label: '팀 목록' }}
        sub={<div className="row" style={{ marginTop: 6, gap: 6 }}>
          <span className="badge accent">({team.division})서면</span>
          {team.has_passcode ? <span className="badge ok">비밀번호 설정</span> : <span className="badge warn">비밀번호 미설정</span>}
          {!team.has_interview && <span className="badge">면담비 없음</span>}
          <span className="small muted">시트 {team.sheet_name}{team.title_name !== team.sheet_name ? ` · 제목 ${team.title_name}` : ''}</span>
        </div>}>
        <button className="btn sm" onClick={() => setModal('edit')}>정보 수정</button>
        <button className="btn sm danger" onClick={() => setModal('delete')}>삭제</button>
        <button className="btn sm" onClick={() => go(`/log?teamId=${encodeURIComponent(team.id)}`)}>변경 기록</button>
      </PageHead>
      <LoadError load={load} />

      <div className="card card-pad" style={{ marginBottom: 16 }}>
        <div className="row" style={{ marginBottom: 10 }}>
          <select className="input" style={{ width: 'auto' }} value={year} onChange={e => pick(Number(e.target.value), month)} aria-label="연도">
            {yearOptions(data.years.map(y => y.year).concat(data.months.map(m => m.year))).map(y => <option key={y} value={y}>{y}년</option>)}
          </select>
          <span className="hint">점이 있는 달은 인원이 아직 미입력입니다.</span>
        </div>
        <div className="months" role="group" aria-label="월">
          {view.calc.months.map(m => (
            <button key={m.month} type="button" aria-pressed={m.month === month} onClick={() => pick(year, m.month)}>
              {m.month}월{!m.isSet && <span className="dot" aria-label="미입력" />}
            </button>
          ))}
        </div>
      </div>

      <div className="stack">
        <MonthSummary team={team} c={c} year={year} month={month} />
        <MonthSettingsCard key={`m-${year}-${month}-${JSON.stringify(row)}`} team={team} year={year} month={month} row={row} prevRow={prevRow} c={c} rule={data.rule} onSaved={saved} />
        <ExpensesCard key={`e-${year}-${month}`} team={team} year={year} month={month} expenses={view.expenses.filter(e => e.month === month)} onSaved={saved} />
        <YearInfoCard key={`y-${year}-${JSON.stringify(view.ty)}`} team={team} year={year} ty={view.ty} calc={view.calc} onSaved={saved} />
        <YearFlowCard team={team} year={year} calc={view.calc} month={month} onPick={m => pick(year, m)} />
      </div>

      {modal === 'edit' && <TeamFormModal team={team} onClose={() => setModal(null)}
        onSaved={(t, unchanged) => { setModal(null); toast.ok(unchanged ? '바뀐 내용이 없습니다' : '팀 정보를 저장했습니다'); saved(); }} />}
      {modal === 'delete' && <DeleteTeamModal team={team} onClose={() => setModal(null)}
        onDeleted={() => { setModal(null); toast.ok(`${team.name} 팀을 삭제했습니다`); dirtyForms.clear(); go('/teams'); }} />}
    </>
  );
}

function MonthSummary({ team, c, year, month }) {
  return (
    <section className="card card-pad" aria-label="월 요약">
      <div className="spread"><h2>{year}년 {month}월 요약</h2>{!c.isSet && <span className="badge warn">미입력</span>}</div>
      <div className="stats">
        <div className="stat hero"><div className="k">잔여 회식비</div><div className={cx('v', c.balance < 0 && 'neg')}>{won(c.balance)}</div></div>
        <div className="stat"><div className="k">회식비 한도</div><div className="v">{won(c.diningLimit)}</div>
          <div className="s">{c.isSet ? `${fmt(c.memberCount)}명 × ${won(c.perPersonAmount || 0)}` : '인원 미입력'}</div></div>
        <div className="stat"><div className="k">추가금액</div><div className="v">{won(c.extraAmount)}</div>{c.extraReason && <div className="s">{c.extraReason}</div>}</div>
        <div className="stat"><div className="k">전월 이월</div><div className={cx('v', c.carryOver < 0 && 'neg')}>{won(c.carryOver)}</div></div>
        <div className="stat"><div className="k">이번 달 사용액</div><div className="v">{won(c.diningUsed)}</div></div>
      </div>
      {!!team.has_interview && (
        <div className="stats" style={{ marginTop: 10 }}>
          <div className="stat"><div className="k">면담비 한도</div><div className="v">{won(c.interviewLimit)}</div><div className="s">{c.interviewLimitIsOverride ? '직접 수정' : '인원수 자동'}</div></div>
          <div className="stat"><div className="k">면담비 사용</div><div className="v">{won(c.interviewUsed)}</div></div>
          <div className="stat"><div className="k">면담비 남은 금액</div><div className={cx('v', c.interviewRemaining < 0 && 'neg')}>{won(c.interviewRemaining)}</div></div>
          <div className="stat"><div className="k">면담비+회식비</div><div className="v">{won(c.totalUsed)}</div></div>
        </div>
      )}
    </section>
  );
}

function MonthSettingsCard({ team, year, month, row, prevRow, c, rule, onSaved }) {
  const toast = useToast();
  const isSet = !!row && row.member_count != null;
  // 미입력 달은 전월 인원·단가를 미리 채운다(저장해야 확정)
  const initial = useMemo(() => ({
    member_count: toInput(isSet ? row.member_count : prevRow ? prevRow.member_count : null),
    per_person_amount: toInput(row && row.per_person_amount != null ? row.per_person_amount : prevRow ? prevRow.per_person_amount : null),
    extra_reason: (row && row.extra_reason) || '',
    extra_amount: toInput(row ? row.extra_amount : null),
    interview_limit_override: toInput(row ? row.interview_limit_override : null),
  }), []);
  const [f, setF] = useState(initial);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');
  const dirty = !isSet || Object.keys(initial).some(k => f[k] !== initial[k]);
  useDirty(`month-${team.id}-${year}-${month}`, Object.keys(initial).some(k => f[k] !== initial[k]));
  const set = (k, v) => setF(p => ({ ...p, [k]: v }));

  const members = parseIntInput(f.member_count);
  const per = parseIntInput(f.per_person_amount);
  const limit = Number.isFinite(members) && Number.isFinite(per) ? (members || 0) * (per || 0) : null;
  const autoInterview = TE.calc.autoInterviewLimit(Number.isFinite(members) ? members : 0, rule);
  const hasOverride = f.interview_limit_override.trim() !== '';

  async function submit(e) {
    e.preventDefault();
    setBusy(true);
    setError('');
    try {
      const r = await api('PUT', '/api/admin/months', { teamId: team.id, year, month, ...f });
      toast.ok(r.unchanged ? '바뀐 내용이 없습니다' : `${month}월 설정을 저장했습니다`);
      onSaved();
    } catch (err) {
      setError(err.message);
      toast.err(err.message);
    }
    setBusy(false);
  }
  return (
    <section className="card card-pad">
      <div className="spread"><h2>{month}월 설정</h2>
        {!isSet && <span className="badge warn">미입력 · {prevRow && prevRow.member_count != null ? '전월 값을 채워 두었습니다. 저장해야 확정됩니다' : '인원을 입력해 저장하세요'}</span>}
      </div>
      <form onSubmit={submit} className="stack" style={{ gap: 12 }}>
        <div className="grid-form">
          <label className="field"><span>인원</span><NumberInput value={f.member_count} onChange={v => set('member_count', v)} placeholder="명" /></label>
          <label className="field"><span>인당 단가</span><NumberInput value={f.per_person_amount} onChange={v => set('per_person_amount', v)} placeholder="원" /></label>
          <div className="field"><span>회식비 한도</span><div className="input num" style={{ background: 'var(--surface-2)' }}>{limit == null ? '-' : won(limit)}</div></div>
        </div>
        <div className="grid-form">
          <label className="field"><span>추가사유</span><input className="input" value={f.extra_reason} maxLength={100} onChange={e => set('extra_reason', e.target.value)} /></label>
          <label className="field"><span>추가금액 (빼는 금액은 -)</span><NumberInput allowNegative value={f.extra_amount} onChange={v => set('extra_amount', v)} placeholder="원" /></label>
          {!!team.has_interview && (
            <label className="field">
              <span className="spread" style={{ gap: 4 }}>면담비 한도
                {hasOverride ? <span className="badge warn">직접 수정</span> : <span className="badge">자동</span>}
              </span>
              <NumberInput value={f.interview_limit_override} onChange={v => set('interview_limit_override', v)} placeholder={`자동 ${fmt(autoInterview)}`} />
            </label>
          )}
        </div>
        {!!team.has_interview && (
          <div className="hint">
            면담비 한도를 비워 두면 인원수로 자동 계산합니다(지금 인원이면 {won(autoInterview)}).
            {hasOverride && <> <button type="button" className="btn ghost sm" onClick={() => set('interview_limit_override', '')}>자동으로 되돌리기</button></>}
          </div>
        )}
        {error && <div className="err-text" role="alert">{error}</div>}
        <div className="row" style={{ justifyContent: 'flex-end' }}>
          <button className="btn primary" disabled={busy || !dirty}>{busy ? '저장 중…' : isSet ? '저장' : '저장 (확정)'}</button>
        </div>
      </form>
    </section>
  );
}

const sortExpenses = list => [...list].sort((a, b) => (a.date || '').localeCompare(b.date || '') || a.created_at.localeCompare(b.created_at));
const monthOfDate = d => (d ? { y: Number(d.slice(0, 4)), m: Number(d.slice(5, 7)) } : null);
const outOfMonth = (d, year, month) => { const x = monthOfDate(d); return !!x && (x.y !== year || x.m !== month); };

function ExpensesCard({ team, year, month, expenses, onSaved }) {
  const toast = useToast();
  const today = kstToday();
  const defaultDate = today.year === year && today.month === month ? today.date : `${year}-${pad2(month)}-01`;
  const [f, setF] = useState({ date: defaultDate, kind: 'dining', amount: '', memo: '' });
  const [busy, setBusy] = useState(false);
  const [editId, setEditId] = useState(null);
  const amountRef = useRef(null);
  useDirty(`exp-new-${team.id}-${year}-${month}`, f.amount !== '' || f.memo !== '');
  const set = (k, v) => setF(p => ({ ...p, [k]: v }));
  const list = sortExpenses(expenses);
  const sum = kind => list.filter(e => e.kind === kind).reduce((s, e) => s + e.amount, 0);

  async function add(e) {
    e.preventDefault();
    const amount = parseIntInput(f.amount);
    if (!f.date) return toast.err('날짜를 입력해 주세요');
    if (!amount) return toast.err('금액을 입력해 주세요');
    if (outOfMonth(f.date, year, month) && !confirm(`날짜(${f.date})가 ${year}년 ${month}월이 아닙니다. 그래도 ${month}월 내역으로 추가할까요?`)) return;
    setBusy(true);
    try {
      await api('POST', '/api/admin/expenses', { teamId: team.id, year, month, ...f });
      toast.ok(`${KIND[f.kind]} ${won(amount)}을 추가했습니다`);
      setF(p => ({ ...p, amount: '', memo: '' }));
      onSaved();
      setTimeout(() => amountRef.current && amountRef.current.focus(), 0);
    } catch (err) {
      toast.err(err.message);
    }
    setBusy(false);
  }
  async function remove(x) {
    if (!confirm(`${shortDate(x.date)} ${KIND[x.kind]} ${won(x.amount)} 내역을 삭제할까요?`)) return;
    try {
      await api('DELETE', `/api/admin/expenses/${encodeURIComponent(x.id)}`);
      toast.ok('내역을 삭제했습니다');
      onSaved();
    } catch (err) {
      toast.err(err.message);
    }
  }
  return (
    <section className="card card-pad">
      <div className="spread"><h2>{month}월 사용 내역</h2>
        <span className="small muted num">회식비 {won(sum('dining'))}{team.has_interview ? ` · 면담비 ${won(sum('interview'))}` : ''}</span>
      </div>
      <form className="quick" onSubmit={add} style={{ marginBottom: 6 }}>
        <label className="field"><span>날짜</span><input className="input" type="date" value={f.date} onChange={e => set('date', e.target.value)} required /></label>
        <label className="field"><span>구분</span>
          <select className="input" value={f.kind} onChange={e => set('kind', e.target.value)}>
            <option value="dining">회식비</option>
            {!!team.has_interview && <option value="interview">면담비</option>}
          </select>
        </label>
        <label className="field wide"><span>금액</span><NumberInput ref={amountRef} allowNegative value={f.amount} onChange={v => set('amount', v)} placeholder="원" /></label>
        <label className="field wide"><span>메모 (선택, 관리자만 봄)</span><input className="input" value={f.memo} maxLength={200} onChange={e => set('memo', e.target.value)} /></label>
        <button className="btn primary wide" disabled={busy}>{busy ? '추가 중…' : '추가'}</button>
      </form>
      {outOfMonth(f.date, year, month) && <div className="warn-text">날짜가 {year}년 {month}월이 아닙니다. 추가하면 {month}월 내역으로 들어갑니다.</div>}
      <div style={{ marginTop: 12 }}>
        {list.length === 0 ? <div className="muted small" style={{ padding: '8px 4px' }}>이 달의 내역이 없습니다.</div> : list.map(x => (
          editId === x.id
            ? <ExpenseEditRow key={x.id} team={team} year={year} month={month} x={x} onCancel={() => setEditId(null)} onSaved={() => { setEditId(null); onSaved(); }} />
            : (
              <div className="exp" key={x.id}>
                <span className="num">{x.date ? x.date : <span className="muted small" title="엑셀에서 옮겨 온 면담비라 날짜가 없습니다">날짜 없음</span>}{outOfMonth(x.date, year, month) && <span className="badge warn" style={{ marginLeft: 6 }}>다른 달</span>}</span>
                <span className="kind"><span className={cx('badge', x.kind === 'interview' && 'accent')}>{KIND[x.kind]}</span></span>
                <span className={cx('amt', x.amount < 0 && 'neg')}>{won(x.amount)}</span>
                <span className="row" style={{ gap: 2, flexWrap: 'nowrap' }}>
                  <button className="btn ghost sm" onClick={() => setEditId(x.id)}>수정</button>
                  <button className="btn ghost sm danger" onClick={() => remove(x)}>삭제</button>
                </span>
                {x.memo && <span className="memo">메모: {x.memo}</span>}
              </div>
            )
        ))}
      </div>
    </section>
  );
}

function ExpenseEditRow({ team, year, month, x, onCancel, onSaved }) {
  const toast = useToast();
  const initial = { date: x.date || '', kind: x.kind, amount: toInput(x.amount), memo: x.memo || '' };
  const [f, setF] = useState(initial);
  const [busy, setBusy] = useState(false);
  useDirty(`exp-${x.id}`, Object.keys(initial).some(k => f[k] !== initial[k]));
  const set = (k, v) => setF(p => ({ ...p, [k]: v }));
  async function save(e) {
    e.preventDefault();
    if (f.date && outOfMonth(f.date, year, month) && !confirm(`날짜(${f.date})가 ${year}년 ${month}월이 아닙니다. 그래도 저장할까요?`)) return;
    setBusy(true);
    try {
      const r = await api('PUT', `/api/admin/expenses/${encodeURIComponent(x.id)}`, f);
      toast.ok(r.unchanged ? '바뀐 내용이 없습니다' : '내역을 고쳤습니다');
      onSaved();
    } catch (err) {
      toast.err(err.message);
      setBusy(false);
    }
  }
  return (
    <form className="exp-edit" onSubmit={save}>
      <label className="field"><span>날짜</span><input className="input" type="date" value={f.date} onChange={e => set('date', e.target.value)} required={!(x.date == null && f.kind === 'interview')} /></label>
      <label className="field"><span>구분</span>
        <select className="input" value={f.kind} onChange={e => set('kind', e.target.value)}>
          <option value="dining">회식비</option>
          {(!!team.has_interview || x.kind === 'interview') && <option value="interview">면담비</option>}
        </select>
      </label>
      <label className="field wide"><span>금액</span><NumberInput allowNegative value={f.amount} onChange={v => set('amount', v)} /></label>
      <label className="field wide"><span>메모</span><input className="input" value={f.memo} maxLength={200} onChange={e => set('memo', e.target.value)} /></label>
      <span className="row wide" style={{ gap: 6, flexWrap: 'nowrap' }}>
        <button type="button" className="btn" onClick={onCancel} disabled={busy}>취소</button>
        <button className="btn primary" disabled={busy}>{busy ? '저장 중…' : '저장'}</button>
      </span>
    </form>
  );
}

function YearInfoCard({ team, year, ty, calc, onSaved }) {
  const toast = useToast();
  // 1월 이월은 앞 해 데이터가 없을 때(첫 해)만 직접 입력한다. 이미 넣어 둔 값이 있으면 고치거나 비울 수 있다.
  const autoOpening = calc.prevYearClosing;
  const canEditOpening = autoOpening == null || (ty && ty.opening_balance != null);
  const initial = { opening_balance: toInput(ty ? ty.opening_balance : null), writer: (ty && ty.writer) || '', notes: (ty && ty.notes) || '' };
  const [f, setF] = useState(initial);
  const [busy, setBusy] = useState(false);
  const dirty = Object.keys(initial).some(k => f[k] !== initial[k]);
  useDirty(`year-${team.id}-${year}`, dirty);
  const set = (k, v) => setF(p => ({ ...p, [k]: v }));
  async function submit(e) {
    e.preventDefault();
    setBusy(true);
    const body = { teamId: team.id, year, writer: f.writer, notes: f.notes };
    if (canEditOpening) body.opening_balance = f.opening_balance;
    try {
      const r = await api('PUT', '/api/admin/team-years', body);
      toast.ok(r.unchanged ? '바뀐 내용이 없습니다' : `${year}년 연도 정보를 저장했습니다`);
      onSaved();
    } catch (err) {
      toast.err(err.message);
    }
    setBusy(false);
  }
  return (
    <section className="card card-pad">
      <h2>{year}년 연도 정보</h2>
      <form onSubmit={submit} className="stack" style={{ gap: 12 }}>
        <div className="grid-form">
          {canEditOpening ? (
            <label className="field"><span>1월 이월잔액</span>
              <NumberInput allowNegative value={f.opening_balance} onChange={v => set('opening_balance', v)} placeholder={autoOpening == null ? '0' : `자동 ${fmt(autoOpening)}`} />
              <span className="hint">{autoOpening == null ? '첫 해라 직접 입력합니다. 비우면 0원입니다.' : `비우면 전년도 12월 잔액(${won(autoOpening)})을 씁니다.`}</span>
            </label>
          ) : (
            <div className="field"><span>1월 이월잔액</span>
              <div className="input num" style={{ background: 'var(--surface-2)' }}>{won(calc.openingBalance)}</div>
              <span className="hint">전년도 12월 잔액을 자동으로 씁니다.</span>
            </div>
          )}
          <label className="field"><span>작성자</span><input className="input" value={f.writer} maxLength={40} onChange={e => set('writer', e.target.value)} />
            <span className="hint">엑셀 2행: 작성자 : {team.title_name} {f.writer}</span></label>
        </div>
        <label className="field"><span>특이사항 (여러 줄, 엑셀 잔액 아래에 들어감)</span>
          <textarea className="input" value={f.notes} maxLength={2000} onChange={e => set('notes', e.target.value)} />
        </label>
        <div className="row" style={{ justifyContent: 'flex-end' }}><button className="btn primary" disabled={busy || !dirty}>{busy ? '저장 중…' : '저장'}</button></div>
      </form>
    </section>
  );
}

function YearFlowCard({ team, year, calc, month, onPick }) {
  return (
    <section className="card">
      <div className="card-pad" style={{ paddingBottom: 8 }}><h2 style={{ margin: 0 }}>{year}년 월별 흐름</h2></div>
      <div className="table-wrap">
        <table className="tbl num">
          <thead><tr>
            <th>월</th><th className="r">인원</th><th className="r">한도</th><th className="r">추가</th><th className="r">이월</th><th className="r">사용</th><th className="r">잔액</th>
            {!!team.has_interview && <><th className="r hide-sm">면담비 한도</th><th className="r hide-sm">면담비 사용</th></>}
          </tr></thead>
          <tbody>
            {calc.months.map(m => (
              <tr key={m.month} className={cx('linkrow', m.month === month && 'current')} onClick={() => onPick(m.month)}>
                <td>{m.month}월</td>
                <td className="r">{m.isSet ? fmt(m.memberCount) : <span className="badge warn">미입력</span>}</td>
                <td className="r">{fmt(m.diningLimit)}</td>
                <td className="r">{m.extraAmount ? fmt(m.extraAmount) : ''}</td>
                <td className={cx('r', m.carryOver < 0 && 'neg')}>{fmt(m.carryOver)}</td>
                <td className="r">{fmt(m.diningUsed)}</td>
                <td className={cx('r', m.balance < 0 && 'neg')}><strong>{fmt(m.balance)}</strong></td>
                {!!team.has_interview && <><td className="r hide-sm">{fmt(m.interviewLimit)}</td><td className="r hide-sm">{fmt(m.interviewUsed)}</td></>}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </section>
  );
}

// ---- 월별 일괄 입력 ----
function BulkView() {
  const toast = useToast();
  const today = kstToday();
  const [year, setYear] = useState(today.year);
  const [month, setMonth] = useState(today.month);
  const load = useLoad(() => api('GET', `/api/admin/export-data?year=${year}`), [year]);
  const [edits, setEdits] = useState({});     // teamId → { member_count, per_person_amount } (입력칸 글자)
  const [busy, setBusy] = useState(false);
  const data = load.data;

  const savedRow = useMemo(() => {
    const m = new Map();
    if (data) for (const r of data.months) if (r.year === year) m.set(`${r.team_id}/${r.month}`, r);
    return m;
  }, [data, year]);
  const prevOf = id => (month > 1 ? savedRow.get(`${id}/${month - 1}`) : data.months.find(r => r.team_id === id && r.year === year - 1 && r.month === 12));
  const savedInputs = id => { const r = savedRow.get(`${id}/${month}`); return { member_count: toInput(r ? r.member_count : null), per_person_amount: toInput(r ? r.per_person_amount : null) }; };
  const value = (id, k) => (edits[id] && edits[id][k] !== undefined ? edits[id][k] : savedInputs(id)[k]);
  const changedIds = data ? data.teams.map(t => t.id).filter(id => edits[id] && ['member_count', 'per_person_amount'].some(k => value(id, k) !== savedInputs(id)[k])) : [];
  useDirty('bulk', changedIds.length > 0);

  // 입력 중인 값으로 다시 계산한 팀별 결과
  const rows = useMemo(() => {
    if (!data) return [];
    const patched = { ...data, months: data.months.map(r => r) };
    for (const id of Object.keys(edits)) {
      const m = parseIntInput(value(id, 'member_count'));
      const p = parseIntInput(value(id, 'per_person_amount'));
      if (Number.isNaN(m) || Number.isNaN(p)) continue;
      const i = patched.months.findIndex(r => r.team_id === id && r.year === year && r.month === month);
      const base = i >= 0 ? patched.months[i] : { team_id: id, year, month, extra_reason: null, extra_amount: null, interview_limit_override: null };
      const next = { ...base, member_count: m, per_person_amount: p };
      if (i >= 0) patched.months[i] = next; else patched.months.push(next);
    }
    return ['인', '물'].map(d => ({ division: d, teams: TE.excel.prepareTeams(patched, year, d) }));
  }, [data, edits, year, month]);

  const switchTo = (y, m) => { if (confirmLeave()) { dirtyForms.delete('bulk'); setEdits({}); setYear(y); setMonth(m); } };
  const edit = (id, k, v) => setEdits(e => ({ ...e, [id]: { ...(e[id] || {}), [k]: v } }));
  function fillFromPrev() {
    let n = 0;
    const next = { ...edits };
    for (const t of data.teams) {
      if (parseIntInput(value(t.id, 'member_count')) != null) continue;
      const prev = prevOf(t.id);
      if (!prev || prev.member_count == null) continue;
      next[t.id] = { member_count: toInput(prev.member_count), per_person_amount: value(t.id, 'per_person_amount') || toInput(prev.per_person_amount) };
      n++;
    }
    setEdits(next);
    toast.ok(n ? `${n}개 팀의 빈칸을 전월 값으로 채웠습니다. 저장해야 확정됩니다` : '채울 빈칸이 없습니다');
  }
  async function save() {
    const list = changedIds.map(id => ({ teamId: id, member_count: value(id, 'member_count'), per_person_amount: value(id, 'per_person_amount') }));
    const bad = list.find(r => Number.isNaN(parseIntInput(r.member_count)) || Number.isNaN(parseIntInput(r.per_person_amount)));
    if (bad) return toast.err(`${data.teams.find(t => t.id === bad.teamId).name}: 숫자만 입력해 주세요`);
    setBusy(true);
    try {
      const r = await api('PUT', '/api/admin/months/bulk', { year, month, rows: list });
      toast.ok(`${r.changed}개 팀의 ${month}월 인원·단가를 저장했습니다`);
      dirtyForms.delete('bulk');
      setEdits({});
      await load.reload();
    } catch (err) {
      toast.err(err.message);
    }
    setBusy(false);
  }
  const unset = data ? data.teams.filter(t => parseIntInput(value(t.id, 'member_count')) == null).length : 0;

  return (
    <>
      <PageHead title="월별 일괄 입력" load={load}
        sub={<p className="hint" style={{ margin: '4px 0 0' }}>한 달치 인원·단가를 전체 팀에 한 번에 넣습니다. 사용액·잔액은 입력하는 대로 다시 계산됩니다.</p>}>
        <select className="input" style={{ width: 'auto' }} value={year} onChange={e => switchTo(Number(e.target.value), month)} aria-label="연도">
          {yearOptions().map(y => <option key={y} value={y}>{y}년</option>)}
        </select>
        <select className="input" style={{ width: 'auto' }} value={month} onChange={e => switchTo(year, Number(e.target.value))} aria-label="월">
          {Array.from({ length: 12 }, (_, i) => <option key={i + 1} value={i + 1}>{i + 1}월</option>)}
        </select>
      </PageHead>
      <LoadError load={load} />
      {!data ? <div className="muted">{load.loading ? '불러오는 중…' : ''}</div> : (
        <>
          <div className="spread" style={{ marginBottom: 12 }}>
            <span className="small muted">미입력 {unset}개 팀 · 바뀐 팀 {changedIds.length}개</span>
            <div className="row">
              <button className="btn sm" onClick={fillFromPrev}>빈칸을 전월 값으로 채우기</button>
              {changedIds.length > 0 && <button className="btn sm" onClick={() => { setEdits({}); }}>입력 취소</button>}
              <button className="btn primary" disabled={busy || !changedIds.length} onClick={save}>{busy ? '저장 중…' : `저장 (${changedIds.length})`}</button>
            </div>
          </div>
          <div className="card table-wrap">
            <table className="tbl num" style={{ minWidth: 640 }}>
              <thead><tr><th>팀</th><th className="r" style={{ width: 90 }}>인원</th><th className="r" style={{ width: 120 }}>인당 단가</th><th className="r">회식비 한도</th><th className="r">사용액</th><th className="r">잔액</th></tr></thead>
              <tbody>
                {rows.map(g => (
                  <Fragment key={g.division}>
                    <tr className="group"><td colSpan={6}>({g.division})서면 · {g.teams.length}개 팀</td></tr>
                    {g.teams.map(({ team, calc }) => {
                      const c = calc.months[month - 1];
                      const prev = prevOf(team.id);
                      return (
                        <tr key={team.id} className={cx(changedIds.includes(team.id) && 'changed')}>
                          <td style={{ whiteSpace: 'nowrap' }}><a href={`#/team/${encodeURIComponent(team.id)}?y=${year}&m=${month}`} onClick={e => { if (!confirmLeave()) e.preventDefault(); }}>{team.name}</a></td>
                          <td><NumberInput value={value(team.id, 'member_count')} onChange={v => edit(team.id, 'member_count', v)}
                            placeholder={prev && prev.member_count != null ? `전월 ${prev.member_count}` : ''} aria-label={`${team.name} 인원`} /></td>
                          <td><NumberInput value={value(team.id, 'per_person_amount')} onChange={v => edit(team.id, 'per_person_amount', v)}
                            placeholder={prev && prev.per_person_amount != null ? fmt(prev.per_person_amount) : ''} aria-label={`${team.name} 인당 단가`} /></td>
                          <td className="r">{fmt(c.diningLimit)}</td>
                          <td className="r">{fmt(c.diningUsed)}</td>
                          <td className={cx('r', c.balance < 0 && 'neg')}><strong>{fmt(c.balance)}</strong></td>
                        </tr>
                      );
                    })}
                  </Fragment>
                ))}
              </tbody>
            </table>
          </div>
        </>
      )}
    </>
  );
}

// ---- 엑셀·백업 ----
let excelJsPromise = null;
function loadExcelJS() {
  if (window.ExcelJS) return Promise.resolve(window.ExcelJS);
  if (!excelJsPromise) {
    excelJsPromise = new Promise((resolve, reject) => {
      const s = document.createElement('script');
      s.src = EXCELJS_SRC;
      s.integrity = EXCELJS_SRI;
      s.crossOrigin = 'anonymous';
      s.onload = () => resolve(window.ExcelJS);
      s.onerror = () => { excelJsPromise = null; s.remove(); reject(new Error('엑셀 라이브러리를 불러오지 못했습니다')); };
      document.head.appendChild(s);
    });
  }
  return excelJsPromise;
}

function FilesView() {
  const toast = useToast();
  const [year, setYear] = useState(kstToday().year);
  const [busy, setBusy] = useState(null);
  const [last, setLast] = useState('');

  async function excel(division) {
    setBusy(division);
    try {
      const [ExcelJS, data, template] = await Promise.all([
        loadExcelJS(),
        api('GET', `/api/admin/export-data?year=${year}`),
        fetch('/template.xlsx').then(r => { if (!r.ok) throw new Error('작성예시 양식을 불러오지 못했습니다'); return r.arrayBuffer(); }),
      ]);
      const { workbook, teams } = await TE.excel.buildWorkbook(ExcelJS, { template, year, division, data });
      const buffer = await workbook.xlsx.writeBuffer();
      const name = TE.excel.exportFileName(year, division);
      downloadBlob(new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), name);
      setLast(`${name} (팀 ${teams.length}개)`);
      toast.ok(`${name}을 내려받았습니다`);
    } catch (err) {
      toast.err(err.message);
    }
    setBusy(null);
  }
  async function backup() {
    setBusy('backup');
    try {
      const { backup } = await api('GET', '/api/admin/backup');
      const stamp = new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' }).replace(/[-:]/g, '').replace(' ', '-').slice(0, 13);
      const name = `teamexp-backup-${stamp}.json`;
      downloadBlob(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }), name);
      toast.ok(`백업 파일을 내려받았습니다 (팀 ${backup.teams.length}개, 내역 ${backup.expenses.length}건)`);
    } catch (err) {
      toast.err(err.message);
    }
    setBusy(null);
  }
  return (
    <>
      <PageHead title="엑셀·백업" />
      <div className="stack">
        <section className="card card-pad">
          <h2>엑셀 내려받기</h2>
          <p className="hint" style={{ marginTop: -6 }}>관리자 엑셀과 같은 양식(시트 구성·수식·서식)으로 만듭니다. 시트 순서는 팀 관리의 순서입니다. 메모는 들어가지 않습니다.</p>
          <div className="row">
            <select className="input" style={{ width: 'auto' }} value={year} onChange={e => setYear(Number(e.target.value))} aria-label="연도">
              {yearOptions().map(y => <option key={y} value={y}>{y}년</option>)}
            </select>
            <button className="btn primary" disabled={!!busy} onClick={() => excel('인')}>{busy === '인' ? '만드는 중…' : '(인)서면 내려받기'}</button>
            <button className="btn primary" disabled={!!busy} onClick={() => excel('물')}>{busy === '물' ? '만드는 중…' : '(물)서면 내려받기'}</button>
          </div>
          {last && <p className="small muted" style={{ marginBottom: 0 }}>마지막: {last}</p>}
        </section>
        <section className="card card-pad">
          <h2>전체 데이터 백업 (JSON)</h2>
          <p className="hint" style={{ marginTop: -6 }}>모든 팀·연도 정보·월 설정·사용 내역(메모 포함)과 면담비 규칙이 들어갑니다. 비밀번호는 들어가지 않습니다.
            이 파일로 되살리는 기능은 없고, 최근 7일 안의 시점 복구는 Cloudflare D1 Time Travel 로 합니다.</p>
          <button className="btn" disabled={!!busy} onClick={backup}>{busy === 'backup' ? '만드는 중…' : '백업 내려받기'}</button>
        </section>
      </div>
    </>
  );
}

// ---- 변경 기록 ----
const ACTION = {
  'team.create': '팀 추가', 'team.update': '팀 정보 수정', 'team.delete': '팀 삭제', 'team.reorder': '팀 순서 변경',
  'month.update': '월 설정', 'month.bulk': '월별 일괄 입력', 'year.update': '연도 정보',
  'expense.create': '내역 추가', 'expense.update': '내역 수정', 'expense.delete': '내역 삭제',
  'settings.interview_rule': '면담비 규칙', 'admin.password': '관리자 비밀번호',
};
const FIELD = {
  name: '표시명', sheet_name: '시트 이름', title_name: '제목용 이름', division: '구분', has_interview: '면담비 사용', passcode: '조회 비밀번호',
  member_count: '인원', per_person_amount: '인당 단가', extra_reason: '추가사유', extra_amount: '추가금액', interview_limit_override: '면담비 한도(직접)',
  opening_balance: '1월 이월잔액', writer: '작성자', notes: '특이사항', date: '날짜', kind: '구분', amount: '금액', memo: '메모', tiers: '구간',
};
function showValue(k, v) {
  if (v == null || v === '') return '(빈칸)';
  if (k === 'kind') return KIND[v] || v;
  if (k === 'has_interview') return v ? '사용' : '안 함';
  if (k === 'tiers') return v.map(t => `${t.minMembers}명 이상 ${fmt(t.amount)}`).join(', ');
  if (typeof v === 'number') return fmt(v);
  return String(v);
}
function Diff({ entry }) {
  const before = entry.before_json ? JSON.parse(entry.before_json) : null;
  const after = entry.after_json ? JSON.parse(entry.after_json) : null;
  if (Array.isArray(after)) return <div className="diff"><span>{after.join(' → ')}</span></div>;
  const keys = [...new Set([...Object.keys(before || {}), ...Object.keys(after || {})])]
    .filter(k => !['id', 'created_at', 'year', 'month'].includes(k));
  if (!keys.length || entry.large) return null;
  return (
    <div className="diff">
      {keys.map(k => {
        const label = FIELD[k] || (k.startsWith('writer_') ? `작성자(${k.slice(7)}년)` : k);
        const b = before && k in before ? showValue(k, before[k]) : null;
        const a = after && k in after ? showValue(k, after[k]) : null;
        return (
          <span key={k}>{label}: {b != null && a != null ? <><span className="old">{b}</span> → {a}</> : a != null ? a : <span className="old">{b}</span>}</span>
        );
      })}
    </div>
  );
}

function LogView({ q }) {
  const toast = useToast();
  const teamsLoad = useLoad(() => api('GET', '/api/admin/teams').then(d => d.teams), []);
  const teamId = q.teamId || '';
  const [entries, setEntries] = useState([]);
  const [more, setMore] = useState(false);
  const [busy, setBusy] = useState(false);
  const fetchPage = async before => {
    const p = new URLSearchParams({ limit: '50' });
    if (teamId) p.set('teamId', teamId);
    if (before) p.set('before', String(before));
    return api('GET', `/api/admin/audit?${p}`);
  };
  const load = useLoad(async () => { const r = await fetchPage(null); setEntries(r.entries); setMore(r.hasMore); return r; }, [teamId]);
  async function loadMore() {
    setBusy(true);
    try { const r = await fetchPage(entries[entries.length - 1].id); setEntries(e => [...e, ...r.entries]); setMore(r.hasMore); }
    catch (err) { toast.err(err.message); }
    setBusy(false);
  }
  async function downloadArchive(entry) {
    try {
      const { entry: full } = await api('GET', `/api/admin/audit/${entry.id}`);
      const name = `teamexp-deleted-${(entry.target || 'team').replace(/[\\/:*?"<>|]/g, '_')}-${entry.at.slice(0, 10)}.json`;
      downloadBlob(new Blob([JSON.stringify(JSON.parse(full.before_json), null, 2)], { type: 'application/json' }), name);
    } catch (err) {
      toast.err(err.message);
    }
  }
  return (
    <>
      <PageHead title="변경 기록" load={load}>
        <select className="input" style={{ width: 'auto', maxWidth: 220 }} value={teamId} aria-label="팀"
          onChange={e => go(e.target.value ? `/log?teamId=${encodeURIComponent(e.target.value)}` : '/log')}>
          <option value="">모든 팀</option>
          {(teamsLoad.data || []).map(t => <option key={t.id} value={t.id}>({t.division}) {t.name}</option>)}
          {teamId && teamsLoad.data && !teamsLoad.data.some(t => t.id === teamId) && <option value={teamId}>삭제된 팀</option>}
        </select>
      </PageHead>
      <LoadError load={load} />
      <div className="card">
        {load.loading && !entries.length ? <div className="card-pad muted">불러오는 중…</div> : !entries.length ? <div className="card-pad muted">기록이 없습니다.</div> : (
          <ul className="list">
            {entries.map(en => (
              <li key={en.id} style={{ alignItems: 'flex-start' }}>
                <div className="grow">
                  <div className="row" style={{ gap: 6 }}>
                    <span className={cx('badge', en.action === 'team.delete' ? 'err' : en.action.startsWith('expense') ? 'accent' : '')}>{ACTION[en.action] || en.action}</span>
                    <strong style={{ fontWeight: 600 }}>{en.target}</strong>
                  </div>
                  <Diff entry={en} />
                  {en.action === 'team.delete' && <button className="btn sm" style={{ marginTop: 6 }} onClick={() => downloadArchive(en)}>지우기 전 데이터 내려받기</button>}
                </div>
                <span className="small muted" style={{ whiteSpace: 'nowrap' }}>{kstDateTime(en.at)}</span>
              </li>
            ))}
          </ul>
        )}
      </div>
      {more && <div className="row" style={{ justifyContent: 'center', marginTop: 12 }}><button className="btn" disabled={busy} onClick={loadMore}>{busy ? '불러오는 중…' : '더 보기'}</button></div>}
    </>
  );
}

// ---- 설정 ----
function SettingsView() {
  return (
    <>
      <PageHead title="설정" />
      <div className="stack">
        <PasswordCard />
        <InterviewRuleCard />
      </div>
    </>
  );
}

function PasswordCard() {
  const toast = useToast();
  const [f, setF] = useState({ current: '', next: '', confirm: '' });
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');
  const set = (k, v) => setF(p => ({ ...p, [k]: v }));
  async function submit(e) {
    e.preventDefault();
    setError('');
    if (f.next.length < 8) return setError('새 비밀번호는 8자 이상으로 정해 주세요');
    if (f.next !== f.confirm) return setError('새 비밀번호 확인이 맞지 않습니다');
    setBusy(true);
    try {
      await api('PUT', '/api/admin/password', { current: f.current, next: f.next });
      setF({ current: '', next: '', confirm: '' });
      toast.ok('비밀번호를 바꿨습니다. 다른 기기의 로그인은 끊어졌습니다');
    } catch (err) {
      setError(err.message);
    }
    setBusy(false);
  }
  return (
    <section className="card card-pad">
      <h2>관리자 비밀번호 변경</h2>
      <form onSubmit={submit} className="stack" style={{ gap: 12, maxWidth: 420 }}>
        <label className="field"><span>지금 비밀번호</span><input className="input" type="password" autoComplete="current-password" value={f.current} onChange={e => set('current', e.target.value)} required /></label>
        <label className="field"><span>새 비밀번호 (8자 이상)</span><input className="input" type="password" autoComplete="new-password" value={f.next} onChange={e => set('next', e.target.value)} required /></label>
        <label className="field"><span>새 비밀번호 확인</span><input className="input" type="password" autoComplete="new-password" value={f.confirm} onChange={e => set('confirm', e.target.value)} required /></label>
        <div className="hint">바꾸면 이 기기를 뺀 모든 기기의 관리자 로그인이 바로 끊어집니다.</div>
        {error && <div className="err-text" role="alert">{error}</div>}
        <div><button className="btn primary" disabled={busy}>{busy ? '바꾸는 중…' : '비밀번호 바꾸기'}</button></div>
      </form>
    </section>
  );
}

function InterviewRuleCard() {
  const toast = useToast();
  const load = useLoad(() => api('GET', '/api/admin/settings'), []);
  const [tiers, setTiers] = useState(null);
  const [busy, setBusy] = useState(false);
  const saved = load.data ? load.data.interviewRule.tiers.map(t => ({ minMembers: String(t.minMembers), amount: toInput(t.amount) })) : null;
  const shown = tiers || saved;
  const dirty = !!tiers && JSON.stringify(tiers) !== JSON.stringify(saved);
  useDirty('rule', dirty);
  const edit = (i, k, v) => setTiers((tiers || saved).map((t, j) => (j === i ? { ...t, [k]: v } : t)));
  async function submit(e) {
    e.preventDefault();
    setBusy(true);
    try {
      await api('PUT', '/api/admin/settings', { interviewRule: { tiers: shown.map(t => ({ minMembers: parseIntInput(t.minMembers), amount: parseIntInput(t.amount) })) } });
      toast.ok('면담비 규칙을 저장했습니다');
      setTiers(null);
      dirtyForms.delete('rule');
      await load.reload();
    } catch (err) {
      toast.err(err.message);
    }
    setBusy(false);
  }
  return (
    <section className="card card-pad">
      <h2>면담비 자동 규칙</h2>
      <p className="hint" style={{ marginTop: -6 }}>월 설정에서 면담비 한도를 직접 고치지 않은 달에 씁니다. 인원이 기준 이상인 구간 중 가장 높은 구간의 금액이고, 어느 구간에도 들지 않으면(0명 등) 0원입니다.
        바꾸면 직접 수정하지 않은 <strong>모든 달(지난 달 포함)</strong>의 면담비 한도가 다시 계산됩니다.</p>
      <LoadError load={load} />
      {shown && (
        <form onSubmit={submit} className="stack" style={{ gap: 8, maxWidth: 480 }}>
          {shown.map((t, i) => (
            <div className="row" key={i} style={{ flexWrap: 'nowrap', gap: 6 }}>
              <NumberInput style={{ width: 64 }} value={t.minMembers} onChange={v => edit(i, 'minMembers', v)} aria-label={`${i + 1}번째 구간 인원`} />
              <span className="small" style={{ whiteSpace: 'nowrap' }}>명 이상</span>
              <NumberInput style={{ minWidth: 0, flex: 1 }} value={t.amount} onChange={v => edit(i, 'amount', v)} aria-label={`${i + 1}번째 구간 금액`} />
              <span className="small">원</span>
              <button type="button" className="btn ghost sm danger" disabled={shown.length <= 1} onClick={() => setTiers(shown.filter((_, j) => j !== i))}>빼기</button>
            </div>
          ))}
          <div className="row">
            <button type="button" className="btn sm" disabled={shown.length >= 5} onClick={() => setTiers([...shown, { minMembers: '', amount: '' }])}>구간 추가</button>
            {dirty && <button type="button" className="btn sm" onClick={() => setTiers(null)}>되돌리기</button>}
            <button className="btn primary" disabled={busy || !dirty}>{busy ? '저장 중…' : '저장'}</button>
          </div>
        </form>
      )}
    </section>
  );
}
