/* 管理 — 利用者・取引先(依頼者規定)・協力業者・工程と期限・選択肢
 *
 * タブ構成は設計図(まるろうOS 管理画面の設計)どおり。いま動くのは
 *   利用者     … 招待・無効化・権限・担当者名簿(0019 rpc_admin_*)
 *   取引先     … 依頼者規定・既定の報告先・担当固定(0020 rpc_admin_list_orgs / update_org)
 *   協力業者   … 得意分野・エリア・状態・口座(同じ RPC。kind='業者')
 *   工程と期限 … 13ルートの工程・標準日数(default_days)を編集(0032・管-10/DB-3)。
 *                先頭に SLA しきい値5列の編集(0056・盤面-14)。
 *                route_templates/route_steps はテナント非依存の共有マスタなので、
 *                変更は全社に効く。効くのは「これから進む工程」「これから作るトラック」
 *                だけ(0003 のトリガ・0025 track.create が都度読む形。既に走っている
 *                トラックの期限は変わらない)ことを画面にも出す。
 * 「選択肢」は管-12で TODO種別・fact_key 対応を編集・保存できる
 * (0026)。保存しても TODO詳細の表示にはまだ反映されない(seed 側の繋ぎ替えは別作業)。
 *
 * 判断は全部サーバ側(DB の _admin_assert・列の白名簿・enum 検証・監査ログ)。
 * ここは「差分だけ送って、読み直す」。楽観更新はしない ─ 管理操作で画面とDBが
 * 食い違うのが一番まずく、件数も少ないので読み直しは一瞬。
 * 削除は無い。意図的(activity_log 等が参照しており、消すと「誰がやったか」が壊れる)。
 */
const ADMIN_TABS = [
  { id: 'users',   label: '利用者' },
  { id: 'orgs',    label: '取引先' },
  { id: 'vendors', label: '協力業者' },
  { id: 'routes',  label: '工程と期限' },
  { id: 'options', label: '選択肢' },
];
/* ㉝: 保存失敗は toast ではなく赤帯+理由+「もう一度」に揃える。 */
function adminSaveFail(reason, onRetry) {
  if (typeof window.showSaveFail === 'function') {
    window.showSaveFail({ reason: reason || 'この変更はサーバに残っていません。', onRetry });
    return;
  }
}
const SETTLEMENTS   = ['自費精算', '保険精算', '工事内包', '自社負担', '未定'];   // settlement_type と同じ語
const APPROACHES    = ['P1', 'P2', 'P3'];                                        // approach_type
const RHYTHMS       = ['密に', '節目ごと', '随時'];
const VENDOR_STATUS = ['未アプローチ', '使用中', '使用不可'];
const BILLING_PATTERNS = ['月次窓口', '月次案件', '都度精算'];   // default_billing_pattern と同じ語(決-19)
const WINDOW_PRESETS  = ['自社', '依頼者', '管理会社', 'オーナー'];
// 報告先の候補と旧語の読み替えは atoms.jsx(REPORT_ROLES / normReportRoles)に一か所で置く(K-27)。
const REPORT_ROLES    = window.REPORT_ROLES || ['依頼担当者', '居住者', 'オーナー', '建物管理会社', '上下階管理会社'];
const normReportRoles = window.normReportRoles || (l => (Array.isArray(l) ? l : []));
// 一覧 RPC の行をそのまま持つと旧語(依頼者 等)のチップが候補と別に並ぶ。読んだ時点で読み替える
// (元の行と下書きの両方が同じ語になるので、触っていない取引先の差分にはならない)。
const normOrgRows = (list) => (list || []).map(r => (Array.isArray(r.defaultReportTo) ? { ...r, defaultReportTo: normReportRoles(r.defaultReportTo) } : r));
const SKILL_PRESETS   = ['給水・排水', '防水', '内装復旧', '設備', '外壁', '調査(散水)', '調査(機器)'];
const AREA_PRESETS    = ['城東', '城北', '城西', '城南', '多摩', '神奈川', '千葉', '埼玉'];

// 画面(camelCase, 一覧RPCの形) → DB列(rpc_admin_update_org が受ける白名簿)
const ORG_COLS = {
  defaultSettlementSurvey: 'default_settlement_survey', defaultSettlementRepair: 'default_settlement_repair',
  defaultApproach: 'default_approach', defaultBillingPattern: 'default_billing_pattern',
  defaultCommissionRate: 'default_commission_rate', insuranceWindow: 'insurance_window', ownerWindow: 'owner_window',
  defaultReportTo: 'default_report_to', reportRhythm: 'report_rhythm', defaultLeadUserId: 'default_lead_user_id',
  vendorSkills: 'vendor_skills', vendorAreas: 'vendor_areas', vendorCanEmergency: 'vendor_can_emergency',
  vendorCanReport: 'vendor_can_report', vendorStatus: 'vendor_status',
  bankName: 'bank_name', bankBranch: 'bank_branch', bankAccountType: 'bank_account_type',
  bankAccountNo: 'bank_account_no', bankAccountHolder: 'bank_account_holder', notes: 'notes',
  // K18-11(0102/0103): 依頼者規定の 4 項目(自由記述)
  serviceArea: 'service_area', tradeTerms: 'trade_terms',
  firstResponseNotes: 'first_response_notes', clientSystemName: 'client_system_name',
  // K18-32(0114): 社内担当(担当固定 defaultLeadUserId とは別。部品は OrgFields.jsx)
  ownerUserId: 'owner_user_id',
  // K18-33(0115): 契約状態(未締結/締結済・空は不明)と締結日(締結済のときだけ)。確認済みは取引先詳細で付け外す
  contractStatus: 'contract_status', contractSignedOn: 'contract_signed_on',
};
// 値の正規化。'' と null は同じ「空」。配列は中身で比べる。
const normVal = (v) => {
  if (v === '' || v === undefined) return null;
  if (Array.isArray(v)) return v.filter(x => x != null && String(x).trim() !== '');
  return v;
};
const sameVal = (a, b) => JSON.stringify(normVal(a)) === JSON.stringify(normVal(b));

/* 一覧の CSV 書き出し（追-3-3）。サーバ往復なし・画面に出ている rows だけ。 */
const csvEsc = (v) => {
  const s = v == null ? '' : String(v);
  return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const adminOrgsCsv = (kind, rows, userName) => {
  const isVendor = kind === '業者';
  const heads = isVendor
    ? ['業者', '得意分野', '対応エリア', '緊急', '報告書', '状態', '発注']
    : ['取引先', '区分', '案件', '規定精算(調査)', '規定精算(復旧)', '進め方', '請求', '手数料', '担当固定',
       // K18-11: 依頼者規定の 4 項目(自由記述)。読み戻し(IMPORT_ALIASES)と同じ見出し。
       '管理エリア', '取引条件', '初動対応注意点', '管理会社側システム'];
  const lines = [heads.map(csvEsc).join(',')];
  (rows || []).forEach(r => {
    if (isVendor) {
      lines.push([
        r.name,
        (r.vendorSkills || []).join('・'),
        (r.vendorAreas || []).join('・'),
        r.vendorCanEmergency == null ? '' : (r.vendorCanEmergency ? '○' : '×'),
        r.vendorCanReport == null ? '' : (r.vendorCanReport ? '○' : '×'),
        r.vendorStatus || '',
        r.orderCount == null ? '' : r.orderCount,
      ].map(csvEsc).join(','));
    } else {
      const pct = r.defaultCommissionRate == null ? '' : (Math.round(Number(r.defaultCommissionRate) * 1000) / 10) + '%';
      lines.push([
        r.name,
        r.partnerClass || '',
        r.caseCount == null ? '' : r.caseCount,
        r.defaultSettlementSurvey || '',
        r.defaultSettlementRepair || '',
        r.defaultApproach || '',
        r.defaultBillingPattern || '',
        pct,
        r.defaultLeadUserId ? (userName ? userName(r.defaultLeadUserId) : r.defaultLeadUserId) : '',
        r.serviceArea || '', r.tradeTerms || '', r.firstResponseNotes || '', r.clientSystemName || '',
      ].map(csvEsc).join(','));
    }
  });
  return lines.join('\n') + '\n';
};
const downloadCsv = (filename, text) => {
  const blob = new Blob(['\uFEFF' + text], { type: 'text/csv;charset=utf-8' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url; a.download = filename; a.rel = 'noopener';
  document.body.appendChild(a); a.click(); a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
};

/* 一覧の CSV 一括投入（追-3-1）。書き出しと同じ列＋ ORG_COLS のキー名も受ける。
   新規社の作成はしない（名前で既存行にだけ当てる）。空欄は「触らない」。 */
const parseCsvText = (text) => {
  const raw = String(text || '').replace(/^\uFEFF/, '');
  const rows = [];
  let row = [], cell = '', i = 0, inQ = false;
  while (i < raw.length) {
    const ch = raw[i];
    if (inQ) {
      if (ch === '"') {
        if (raw[i + 1] === '"') { cell += '"'; i += 2; continue; }
        inQ = false; i++; continue;
      }
      cell += ch; i++; continue;
    }
    if (ch === '"') { inQ = true; i++; continue; }
    if (ch === ',') { row.push(cell); cell = ''; i++; continue; }
    if (ch === '\n' || ch === '\r') {
      if (ch === '\r' && raw[i + 1] === '\n') i++;
      row.push(cell); cell = '';
      if (row.some(c => String(c).trim() !== '')) rows.push(row);
      row = []; i++; continue;
    }
    cell += ch; i++;
  }
  row.push(cell);
  if (row.some(c => String(c).trim() !== '')) rows.push(row);
  if (!rows.length) return { heads: [], body: [] };
  return { heads: rows[0].map(h => String(h).trim()), body: rows.slice(1) };
};
const ORG_COL_BY_DB = Object.fromEntries(Object.entries(ORG_COLS).map(([k, v]) => [v, k]));
const IMPORT_ALIASES = {
  name: ['name', '名前', '取引先', '業者'],
  defaultSettlementSurvey: ['defaultSettlementSurvey', 'default_settlement_survey', '規定精算(調査)'],
  defaultSettlementRepair: ['defaultSettlementRepair', 'default_settlement_repair', '規定精算(復旧)'],
  defaultApproach: ['defaultApproach', 'default_approach', '進め方'],
  defaultBillingPattern: ['defaultBillingPattern', 'default_billing_pattern', '請求'],
  defaultCommissionRate: ['defaultCommissionRate', 'default_commission_rate', '手数料'],
  defaultLeadUserId: ['defaultLeadUserId', 'default_lead_user_id', '担当固定'],
  vendorSkills: ['vendorSkills', 'vendor_skills', '得意分野'],
  vendorAreas: ['vendorAreas', 'vendor_areas', '対応エリア'],
  vendorCanEmergency: ['vendorCanEmergency', 'vendor_can_emergency', '緊急'],
  vendorCanReport: ['vendorCanReport', 'vendor_can_report', '報告書'],
  vendorStatus: ['vendorStatus', 'vendor_status', '状態'],
  insuranceWindow: ['insuranceWindow', 'insurance_window'],
  ownerWindow: ['ownerWindow', 'owner_window'],
  reportRhythm: ['reportRhythm', 'report_rhythm'],
  defaultReportTo: ['defaultReportTo', 'default_report_to'],
  notes: ['notes', '備考'],
  bankName: ['bankName', 'bank_name'],
  bankBranch: ['bankBranch', 'bank_branch'],
  bankAccountType: ['bankAccountType', 'bank_account_type'],
  bankAccountNo: ['bankAccountNo', 'bank_account_no'],
  bankAccountHolder: ['bankAccountHolder', 'bank_account_holder'],
  // K18-11: 依頼者規定の 4 項目(書き出しの見出しと同じ日本語も受ける)
  serviceArea: ['serviceArea', 'service_area', '管理エリア'],
  tradeTerms: ['tradeTerms', 'trade_terms', '取引条件'],
  firstResponseNotes: ['firstResponseNotes', 'first_response_notes', '初動対応注意点'],
  clientSystemName: ['clientSystemName', 'client_system_name', '管理会社側システム'],
};
const importHeadIndex = (heads) => {
  const idx = {};
  const lower = heads.map(h => String(h).trim());
  for (const [field, aliases] of Object.entries(IMPORT_ALIASES)) {
    for (const a of aliases) {
      const i = lower.indexOf(a);
      if (i >= 0) { idx[field] = i; break; }
    }
  }
  // ORG_COLS のキー／DB列名を直接書いた列も拾う
  lower.forEach((h, i) => {
    if (ORG_COLS[h]) idx[h] = i;
    else if (ORG_COL_BY_DB[h]) idx[ORG_COL_BY_DB[h]] = i;
  });
  return idx;
};
const parseCsvBool = (s) => {
  const t = String(s || '').trim();
  if (!t) return undefined;
  if (/^(1|true|yes|y|○|〇|可|はい)$/i.test(t)) return true;
  if (/^(0|false|no|n|×|✕|不可|いいえ)$/i.test(t)) return false;
  return undefined;
};
const parseCsvList = (s) => {
  const t = String(s || '').trim();
  if (!t) return undefined;
  return t.split(/[・,、]/).map(x => x.trim()).filter(Boolean);
};
const parseCsvPct = (s) => {
  const t = String(s || '').trim();
  if (!t) return undefined;
  const n = Number(String(t).replace(/%/g, '').trim());
  if (!Number.isFinite(n)) return undefined;
  return Math.round(n * 10) / 1000; // 10% → 0.1
};
const cellAt = (row, idx, field) => (idx[field] == null ? '' : (row[idx[field]] ?? ''));
/** rows=画面の一覧。users=担当名→id 用。戻りは保存可能な差分だけ。 */
const adminOrgsCsvPreview = (kind, list, text, users) => {
  const { heads, body } = parseCsvText(text);
  const idx = importHeadIndex(heads);
  const byName = new Map((list || []).map(r => [String(r.name || '').trim(), r]));
  const userByName = new Map((users || []).map(u => [String(u.name || '').trim(), u.id]));
  const ok = [], skip = [], errors = [];
  if (idx.name == null) {
    return { ok, skip, errors: [{ line: 1, message: '名前列（取引先／業者／name）がありません' }] };
  }
  body.forEach((row, n) => {
    const line = n + 2;
    const name = String(cellAt(row, idx, 'name')).trim();
    if (!name) { skip.push({ line, reason: '名前が空' }); return; }
    const cur = byName.get(name);
    if (!cur) { errors.push({ line, name, message: '一覧に無い名前です（新規作成はしません）' }); return; }
    const draft = {};
    const take = (field, parse) => {
      if (idx[field] == null) return;
      const raw = cellAt(row, idx, field);
      if (String(raw).trim() === '') return; // 空欄は触らない
      const v = parse ? parse(raw) : String(raw).trim();
      if (v === undefined) { errors.push({ line, name, message: field + ' の値が読めません: ' + raw }); return; }
      draft[field] = v;
    };
    if (kind === '業者') {
      take('vendorSkills', parseCsvList);
      take('vendorAreas', parseCsvList);
      take('vendorCanEmergency', parseCsvBool);
      take('vendorCanReport', parseCsvBool);
      take('vendorStatus', s => String(s).trim() || undefined);
      take('bankName'); take('bankBranch'); take('bankAccountType');
      take('bankAccountNo'); take('bankAccountHolder'); take('notes');
    } else {
      take('defaultSettlementSurvey'); take('defaultSettlementRepair');
      take('defaultApproach'); take('defaultBillingPattern');
      take('defaultCommissionRate', parseCsvPct);
      take('insuranceWindow'); take('ownerWindow'); take('reportRhythm');
      take('defaultReportTo', parseCsvList); take('notes');
      // K18-11: 依頼者規定の 4 項目(自由記述・語彙の検査なし)
      take('serviceArea'); take('tradeTerms'); take('firstResponseNotes'); take('clientSystemName');
      if (idx.defaultLeadUserId != null) {
        const raw = String(cellAt(row, idx, 'defaultLeadUserId')).trim();
        if (raw) {
          const id = userByName.get(raw) || (/^[0-9a-f-]{36}$/i.test(raw) ? raw : null);
          if (!id) errors.push({ line, name, message: '担当「' + raw + '」が名簿にいません' });
          else draft.defaultLeadUserId = id;
        }
      }
    }
    const changes = {};
    for (const k of Object.keys(draft)) {
      if (!ORG_COLS[k]) continue;
      if (!sameVal(draft[k], cur[k])) changes[ORG_COLS[k]] = normVal(draft[k]);
    }
    if (!Object.keys(changes).length) { skip.push({ line, name, reason: '変更なし' }); return; }
    ok.push({ line, id: cur.id, name, changes });
  });
  return { ok, skip, errors };
};

const TH = ({ children, right }) => (
  <th style={{ textAlign: right ? 'right' : 'left', fontSize: 'var(--text-caption)', fontWeight: 600, letterSpacing: '0.04em',
    color: 'var(--muted-foreground)', padding: '9px 14px', borderBottom: '1px solid var(--border-strong)', whiteSpace: 'nowrap',
    background: 'var(--muted)' }}>{children}</th>);
const TD = ({ children, right, style }) => (
  <td style={{ textAlign: right ? 'right' : 'left', padding: '10px 14px', borderBottom: '1px solid var(--border)',
    whiteSpace: 'nowrap', verticalAlign: 'middle', ...(style || {}) }}>{children}</td>);
const Lbl = ({ children }) => (
  <div style={{ fontSize: 'var(--text-caption)', fontWeight: 600, letterSpacing: '0.04em', color: 'var(--muted-foreground)', marginBottom: 6 }}>{children}</div>);
const Hint = ({ children }) => (
  <div style={{ fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)', lineHeight: 1.7, marginTop: 4 }}>{children}</div>);
const CHIP_TONE = { ok: 'success', warn: 'warning', brand: 'brand', dest: 'destructive' };
const chip = (text, tone) => <AfChip tone={CHIP_TONE[tone] || 'neutral'}>{text}</AfChip>;

// 択一(もう一度押すと空に戻る)。「未設定」を表現できるのが要点。
// label: 表示だけを変えたいとき(進め方の P1 → 「P1 先行工事」など)。値は options のまま送る。
function Seg({ value, options, onChange, name, label }) {
  return (
    <div role="radiogroup" aria-label={name} style={{ display: 'inline-flex', border: '1px solid var(--input)', borderRadius: 'var(--radius-sm)', overflow: 'hidden' }}>
      {options.map((o, i) => {
        const on = value === o;
        return (
          <button key={o} type="button" role="radio" aria-checked={on} onClick={() => onChange(on ? null : o)}
            style={{ all: 'unset', cursor: 'pointer', height: 26, padding: '0 11px', display: 'inline-flex', alignItems: 'center',
              fontFamily: 'var(--font-sans)', fontSize: 'var(--text-body-sm)', fontWeight: on ? 600 : 500,
              background: on ? 'var(--brand-muted)' : 'var(--card)', color: on ? 'var(--brand)' : 'var(--muted-foreground)',
              borderRight: i < options.length - 1 ? '1px solid var(--border)' : 'none' }}>{label ? label(o) : o}</button>);
      })}
    </div>);
}

// 複数選択のタグ。候補を押して足し、自由入力も足せる。
function Tags({ value, presets, onChange, placeholder, name }) {
  const [draft, setDraft] = React.useState('');
  const list = Array.isArray(value) ? value : [];
  const toggle = (x) => onChange(list.includes(x) ? list.filter(y => y !== x) : [...list, x]);
  const add = () => { const v = draft.trim(); if (!v) return; if (!list.includes(v)) onChange([...list, v]); setDraft(''); };
  const all = [...new Set([...(presets || []), ...list])];
  return (
    <div>
      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
        {all.map(x => {
          const on = list.includes(x);
          return (
            <button key={x} type="button" aria-pressed={on} onClick={() => toggle(x)}
              style={{ all: 'unset', cursor: 'pointer', fontFamily: 'var(--font-sans)', display: 'inline-flex', alignItems: 'center', gap: 4,
                fontSize: 'var(--text-body-sm)', fontWeight: on ? 700 : 500, padding: '3px 10px', borderRadius: 'var(--radius-full)',
                border: '1px solid ' + (on ? 'var(--brand)' : 'var(--border)'), background: on ? 'var(--brand-muted)' : 'transparent',
                color: on ? 'var(--brand)' : 'var(--muted-foreground)' }}>{on && <span style={{ fontSize: 'var(--text-caption)' }}>✓</span>}{x}</button>);
        })}
      </div>
      <div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
        <AfInput value={draft} onChange={e => setDraft(e.target.value)} placeholder={placeholder || '追加'} aria-label={name ? name + 'を追加' : undefined}
          onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); add(); } }} style={{ width: 200 }} />
        <AfButton size="sm" onClick={add}>追加</AfButton>
      </div>
    </div>);
}

function TextIn({ value, onChange, presets, placeholder, name, style }) {
  const id = React.useId ? React.useId() : ('dl' + Math.random().toString(36).slice(2));
  return (
    <div>
      <AfInput value={value || ''} onChange={e => onChange(e.target.value)} placeholder={placeholder} list={presets ? id : undefined}
        aria-label={name} style={{ width: '100%', ...(style || {}) }} />
      {presets && <datalist id={id}>{presets.map(p => <option key={p} value={p} />)}</datalist>}
    </div>);
}

// 管-12: TODO種別の編集。adminListTodoTypes / adminUpdateTodoTypes(0026)に接続。
const TODO_TYPE_PREVIEW = [
  ['報告', '@who@から報告書を受け取り、確定版にして@client@へ共有する', ['業者報告書の受領', '確定版の作成', '関係者へ共有']],
  ['社内承認', '自社で内容を確認して決裁し、次の工程に進める', ['内容の確認', '社内決裁', '相手へ通知']],
  ['社外承認', '@who@から承認・発注書をもらい、受注として記録する', ['承認の依頼', '回答・発注書の受領', '受注として記録']],
  ['連絡', '@who@へ連絡し、回答をもらって記録する', ['連絡・催促', '回答の受領', '結果の反映']],
  ['方針確認', '@target@に方針を確認し、決まった内容を記録する', ['案の提示', '相手の回答', '方針の記録']],
  ['見積', '@who@の原価見積をもとに自社見積を作り、@client@へ提出する', ['原価見積の受領', '自社見積の作成', '提出・承認依頼']],
  ['入金精算', '@who@へ請求し、入金を確認して消し込む', ['請求書の発行・送付', '入金の確認', '消込']],
  ['その他', '現地・自社で対応し、結果を記録する', ['段取り・連絡', '実施', '記録']],
].map(([code, leadText, steps], i) => ({ code, label: code, sortOrder: i + 1, leadText, steps: steps.map((text, n) => ({ text, signal: null, action: null, no: n + 1 })) }));

// 67a486e の P1-b 対応表。工程名ではなく、この安定キーを管理対象にする。
const FACT_RULE_PREVIEW = [
  ['survey_reported', '報告'], ['work_reported', '報告'], ['survey_approved', '社内承認'],
  ['approval_internal', '社内承認'], ['approval_external', '社外承認'], ['survey_quoted', '見積'],
  ['work_quoted', '見積'], ['invoice_issued', '入金精算'], ['payment_received', '入金精算'],
  ['insurance_allocated', '入金精算'], ['included_billing', '入金精算'], ['insurance_paid', '入金精算'],
  ['claim_filed', '入金精算'], ['insurance_payment_scheduled', '入金精算'], ['payment_reconciled', '入金精算'],
  ['insurance_prospect', '方針確認'], ['insurance_filed', '連絡'], ['incident_reported', '連絡'],
  ['claim_documents', '連絡'], ['assessment_done', '連絡'], ['insurance_assessed', '連絡'],
].map(([factKey, typeCode]) => ({ factKey, typeCode }));

const cloneTodoTypes = rows => (Array.isArray(rows) ? rows : []).map(x => {
  const row = x && typeof x === 'object' ? x : {};
  const sourceSteps = Array.isArray(row.steps) ? row.steps : (Array.isArray(row.microSteps) ? row.microSteps : []);
  return { ...row, steps: sourceSteps.map((s, i) => {
    const step = s && typeof s === 'object' ? s : {};
    return { ...step, no: step.no || i + 1 };
  }) };
});
const todoTypeShape = rows => rows.map((x, i) => ({ ...x, steps: (x.steps || []).map((s, n) => ({ ...s, no: n + 1 })), sortOrder: x.sortOrder || i + 1 }));
const invalidTodoTypes = rows => !Array.isArray(rows) || rows.length !== TODO_TYPE_PREVIEW.length
  || rows.some(x => !x || typeof x !== 'object' || !String(x.code || '').trim() || !String(x.leadText || '').trim()
    || !Array.isArray(x.steps) || x.steps.length !== 3
    || x.steps.some(s => !s || typeof s !== 'object' || !String(s.text || '').trim()))
  || new Set(rows.map(x => x.code)).size !== rows.length;
const invalidFactRules = (rules, types) => {
  if (!Array.isArray(rules) || !rules.length || !Array.isArray(types)
    || types.some(x => !x || typeof x !== 'object')) return true;
  const codes = new Set(types.map(x => x.code));
  const keys = rules.map(x => x && typeof x === 'object' ? String(x.factKey || '').trim() : '');
  // typeCode は types[].code のどれか必須(空・未知は不可)。
  return rules.some(x => !x || typeof x !== 'object' || !String(x.factKey || '').trim() || !codes.has(x.typeCode))
    || new Set(keys).size !== keys.length;
};

const selectStyle = { height: 28, width: '100%', padding: '0 8px', borderRadius: 'var(--radius-md)', borderWidth: 1, borderStyle: 'solid',
  borderColor: 'var(--input)', background: 'var(--card)', color: 'var(--foreground)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-body-md)' };

const tabBtn = (on) => ({
  all: 'unset', cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-body-sm)',
  fontWeight: on ? 700 : 500, padding: '5px 11px', borderRadius: 'var(--radius-full)', whiteSpace: 'nowrap',
  border: '1px solid ' + (on ? 'var(--brand)' : 'var(--border)'),
  background: on ? 'var(--brand-muted)' : 'var(--card)', color: on ? 'var(--brand)' : 'var(--muted-foreground)',
  transition: 'all var(--duration-fast) var(--easing)',
});

function AdminTodoTypes({ onToast }) {
  const cloud = window.MarurouCloud || null;
  const remote = !!(cloud && cloud.adminListTodoTypes && cloud.adminUpdateTodoTypes);
  const initial = React.useMemo(() => cloneTodoTypes(TODO_TYPE_PREVIEW), []);
  const [types, setTypes] = React.useState(initial);
  const [original, setOriginal] = React.useState(initial);
  const [selected, setSelected] = React.useState(initial[0].code);
  const [factRules, setFactRules] = React.useState(FACT_RULE_PREVIEW);
  const [originalRules, setOriginalRules] = React.useState(FACT_RULE_PREVIEW);
  const [loading, setLoading] = React.useState(false);
  const [saving, setSaving] = React.useState(false);
  const [loadError, setLoadError] = React.useState('');

  React.useEffect(() => {
    if (!remote) return;
    let alive = true;
    setLoading(true);
    cloud.adminListTodoTypes().then(r => {
      if (!alive) return;
      if (!r || !Array.isArray(r.types) || !r.types.length) throw new Error('TODO種別の応答が空です');
      const next = todoTypeShape(cloneTodoTypes(r.types));
      if (invalidTodoTypes(next)) throw new Error('TODO種別の応答形式が正しくありません');
      const rules = Array.isArray(r.factRules) ? r.factRules : [];
      if (invalidFactRules(rules, next)) throw new Error('fact_key対応表の応答形式が正しくありません');
      setTypes(next); setOriginal(cloneTodoTypes(next)); setFactRules(rules); setOriginalRules(rules.map(x => ({ ...x })));
      setSelected(next[0].code); setLoadError('');
    }).catch(e => { if (alive) setLoadError('設定を読めませんでした: ' + (e.message || e)); })
      .finally(() => alive && setLoading(false));
    return () => { alive = false; };
  }, [cloud, remote]);

  const current = types.find(x => x.code === selected) || types[0];
  const originalCurrent = original.find(x => x.code === selected) || original[0];
  const setCurrent = fn => setTypes(list => list.map(x => x.code === current.code ? fn({ ...x, steps: (x.steps || []).map(s => ({ ...s })) }) : x));
  const invalid = invalidTodoTypes(types);
  const invalidRules = invalidFactRules(factRules, types);
  const dirty = JSON.stringify(types) !== JSON.stringify(original) || JSON.stringify(factRules) !== JSON.stringify(originalRules);
  const currentDirty = JSON.stringify(current) !== JSON.stringify(originalCurrent);
  const reset = () => setTypes(list => list.map(x => {
    if (x.code !== selected) return x;
    return cloneTodoTypes(original.filter(y => y.code === selected))[0] || x;
  }));
  const save = async () => {
    if (!remote || loadError || invalid || invalidRules || saving || !dirty) return;
    setSaving(true);
    try {
      const r = await cloud.adminUpdateTodoTypes(todoTypeShape(types), factRules);
      setOriginal(cloneTodoTypes(types));
      setOriginalRules(factRules.map(x => ({ ...x })));
      onToast && onToast(r && r.changed === false ? '変更はありません' : 'TODO種別を保存しました');
    } catch (e) {
      adminSaveFail('TODO種別を保存できませんでした: ' + (e.message || e), save);
      onToast && onToast('TODO種別を保存できませんでした');
    }
    finally { setSaving(false); }
  };

  return <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
    <AfCard>
      <div style={{ fontWeight: 700, fontFamily: 'var(--font-sans)', marginBottom: 6 }}>TODO種別マスタ（管-12）</div>
      <Hint>現行の8種について、リード文と3工程の表示文言を編集できます。ここで保存しても、TODO詳細の表示にはまだ反映されません。</Hint>
      {!remote && <AfAlert variant="info" title="接続がありません">管理用の読み書き口(adminListTodoTypes)が読めないため、固定プレビューのみ表示しています。保存はできません。</AfAlert>}
      {remote && loading && <Hint>設定を読み込み中…</Hint>}
      {loadError && <AfAlert variant="error" title="読み込みに失敗">{loadError}。固定プレビューを表示しています。このまま保存はできません。</AfAlert>}
    </AfCard>
    <div style={{ display: 'grid', gridTemplateColumns: 'minmax(150px, 0.35fr) minmax(0, 1fr)', gap: 14 }}>
      <AfCard pad={0}>
        <div style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)', fontWeight: 700 }}>種別一覧（{types.length}）</div>
        <div style={{ padding: 6, display: 'flex', flexDirection: 'column', gap: 3 }}>
          {types.map(x => <button key={x.code} type="button" aria-pressed={selected === x.code} onClick={() => setSelected(x.code)}
            style={{ all: 'unset', cursor: 'pointer', padding: '8px 10px', borderRadius: 'var(--radius-sm)', fontSize: 'var(--text-body-md)', fontWeight: selected === x.code ? 700 : 500,
              color: selected === x.code ? 'var(--brand)' : 'var(--foreground)', background: selected === x.code ? 'var(--brand-muted)' : 'transparent' }}>{x.label || x.code}</button>)}
        </div>
      </AfCard>
      {current && <AfCard>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
          <div style={{ fontWeight: 700, fontSize: 'var(--text-display-sm)' }}>{current.label || current.code}</div>
          {dirty && chip('未保存', 'warn')}
        </div>
        <Lbl>リード文</Lbl>
        <textarea aria-label="リード文" value={current.leadText || ''} onChange={e => setCurrent(x => ({ ...x, leadText: e.target.value }))}
          style={{ width: '100%', minHeight: 58, boxSizing: 'border-box', resize: 'vertical', padding: 8, border: '1px solid var(--input)', borderRadius: 'var(--radius-sm)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-body-sm)', background: 'var(--card)', color: 'var(--foreground)' }} />
        <div style={{ marginTop: 14 }}><Lbl>3工程の表示文言</Lbl>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{(current.steps || []).slice(0, 3).map((s, i) => <div key={i}>
            <TextIn name={'工程' + (i + 1) + 'の文言'} value={s.text} onChange={v => setCurrent(x => ({ ...x, steps: x.steps.map((z, j) => j === i ? { ...z, text: v } : z) }))} />
          </div>)}</div>
        </div>
        {invalid && <AfAlert variant="notice" title="保存できません">リード文と3工程の表示文言を、3件とも入力してください。</AfAlert>}
        <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
          <AfButton onClick={reset} disabled={!currentDirty}>この種別を初期値に戻す</AfButton>
          <AfButton variant="primary" onClick={save} disabled={!remote || !!loadError || loading || saving || invalid || invalidRules || !dirty}>{saving ? '保存中…' : '保存'}</AfButton>
        </div>
      </AfCard>}
    </div>
    <AfCard>
      <div style={{ fontWeight: 700, marginBottom: 4 }}>工程 → TODO種別（fact_key）</div>
      <Hint>工程名ではなく fact_key で分類します。種別は必ず8種のどれかを選びます（未選択のままでは保存できません）。変更は種別文言と一緒に保存されます。</Hint>
      {invalidRules && <AfAlert variant="notice" title="保存できません">空の fact_key、重複 fact_key、または8種にない typeCode があります。種別は必ず選んでください。</AfAlert>}
      <div style={{ overflowX: 'auto', marginTop: 10 }}>
        <table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 'var(--text-body-sm)' }}>
          <thead><tr><TH>fact_key（安定キー）</TH><TH>現在の種別</TH></tr></thead>
          <tbody>{factRules.map((r, i) => {
            const unknown = r.typeCode && !types.some(x => x.code === r.typeCode);
            return <tr key={String(r.factKey) + ':' + i}>
              <TD><span className="mono">{r.factKey || '（空）'}</span></TD>
              <TD><select aria-label={(r.factKey || '空のfact_key') + 'の種別'} value={r.typeCode || ''}
                onChange={e => setFactRules(list => list.map((x, j) => j === i ? { ...x, typeCode: e.target.value } : x))} style={{ ...selectStyle, width: 150 }}>
                {unknown && <option value={r.typeCode}>{r.typeCode}（未知）</option>}
                {!r.typeCode && <option value="" disabled>選んでください</option>}
                {types.map(x => <option key={x.code} value={x.code}>{x.label || x.code}</option>)}
              </select></TD>
            </tr>;
          })}</tbody>
        </table>
      </div>
      <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
        <AfButton onClick={() => setFactRules(originalRules.map(x => ({ ...x })))} disabled={JSON.stringify(factRules) === JSON.stringify(originalRules)}>対応表を初期値に戻す</AfButton>
      </div>
    </AfCard>
  </div>;
}

/* ───────────────────────── 利用者 ───────────────────────── */
function AdminUsers({ onToast, onOpenNeedsAssignee }) {
  const cloud = window.MarurouCloud || null;
  const [rows, setRows] = React.useState(null);   // null=読込中 / []=空
  const [err, setErr] = React.useState('');
  const [busyId, setBusyId] = React.useState(null);
  const [selected, setSelected] = React.useState(() => new Set());
  const [bulkBusy, setBulkBusy] = React.useState(false);
  const [inviteEmail, setInviteEmail] = React.useState('');
  const [inviteName, setInviteName] = React.useState('');
  const [inviteBusy, setInviteBusy] = React.useState(false);
  // 決-28/0070: 無効化時の後任(任意)。単行・一括の両方で同じ候補を使う。
  const [deactivateId, setDeactivateId] = React.useState(null);
  const [successorId, setSuccessorId] = React.useState('');
  const [bulkSuccessorId, setBulkSuccessorId] = React.useState('');

  const load = React.useCallback(async () => {
    if (!cloud) { setErr('接続の設定が読めていません(開発モードでは管理は使えません)'); setRows([]); return; }
    try { setRows(await cloud.adminListUsers()); setErr(''); setSelected(new Set());
          setDeactivateId(null); setSuccessorId(''); }
    catch (e) { setErr(e && e.status === 403 ? 'この画面は管理者だけが使えます' : '一覧を読めませんでした: ' + (e.message || e)); setRows([]); }
  }, [cloud]);
  React.useEffect(() => { load(); }, [load]);

  const me = window.MStore && window.MStore.me ? window.MStore.me() : null;

  const successorCandidates = (excludeId) =>
    (rows || []).filter(u => u.isActive && u.isAssignable && u.id !== excludeId);

  const toastDeactivate = (u, r) => {
    if (r && r.changed === false) { onToast && onToast('変更はありません'); return; }
    const n = Number(r && r.cases || 0) + Number(r && r.steps || 0) + Number(r && r.todos || 0);
    onToast && onToast((u.displayName || u.email) + ' を無効化しました'
      + (n > 0 ? '（' + n + '件を引き継ぎました）' : ''));
  };

  const run = async (id, fn, doneMsg) => {
    setBusyId(id);
    try { const r = await fn(); if (r && r.changed === false) onToast && onToast('変更はありません');
          else onToast && onToast(doneMsg); await load(); }
    catch (e) { onToast && onToast('できませんでした: ' + (e.message || e)); }
    finally { setBusyId(null); }
  };

  // 単行の無効化(0070)。後任は任意 ─ 空なら従来どおり未アサインのまま。
  const confirmDeactivate = async () => {
    if (!cloud || !deactivateId || !rows) return;
    const u = rows.find(x => x.id === deactivateId);
    if (!u) return;
    setBusyId(u.id);
    try {
      const sid = successorId || null;
      const r = await cloud.adminSetActive(u.id, false, sid);
      toastDeactivate(u, r);
      await load();
    } catch (e) { onToast && onToast('できませんでした: ' + (e.message || e)); }
    finally { setBusyId(null); }
  };

  // ⑯: 選んだ行へ rpc_admin_set_* を並べて呼ぶ(1本のバッチRPCは無い)。
  const applyBulk = async (kind, value, label) => {
    if (!cloud || !rows) return;
    const targets = rows.filter(u => selected.has(u.id));
    if (!targets.length) { onToast && onToast('行を選んでください'); return; }
    setBulkBusy(true);
    let changed = 0, same = 0, failed = 0, handed = 0;
    const succ = (kind === 'active' && value === false && bulkSuccessorId) ? bulkSuccessorId : null;
    for (const u of targets) {
      if (kind === 'active' && me && me.userId === u.id) { same++; continue; }
      try {
        let r = null;
        if (kind === 'role') r = await cloud.adminSetRole(u.id, value);
        else if (kind === 'assignable') r = await cloud.adminSetAssignable(u.id, value);
        else if (kind === 'active') r = await cloud.adminSetActive(u.id, value, value ? null : succ);
        if (r && r.changed === false) same++;
        else {
          changed++;
          if (kind === 'active' && !value) {
            handed += Number(r && r.cases || 0) + Number(r && r.steps || 0) + Number(r && r.todos || 0);
          }
        }
      } catch (e) { failed++; }
    }
    setBulkBusy(false);
    await load();
    onToast && onToast(
      (label || '一括更新') + ': 変更 ' + changed
      + (same ? '・変更なし ' + same : '')
      + (failed ? '・失敗 ' + failed : '')
      + (handed ? '・引き継ぎ ' + handed + '件' : '')
    );
  };

  const invite = async () => {
    if (!/@/.test(inviteEmail)) { onToast && onToast('メールアドレスを確認してください'); return; }
    setInviteBusy(true);
    try {
      const accessToken = cloud && cloud.freshAccessToken
        ? await cloud.freshAccessToken()
        : (cloud && cloud.accessToken ? cloud.accessToken() : '');
      const res = await fetch('/api/admin/invite', {
        method: 'POST', headers: { 'Content-Type': 'application/json',
          Authorization: 'Bearer ' + accessToken },
        body: JSON.stringify({ email: inviteEmail.trim(), displayName: inviteName.trim() }),
      });
      const body = await res.json().catch(() => ({}));
      if (res.status === 501) { onToast && onToast('招待の送信はまだ設定されていません(SERVICE_ROLE_KEY 未設定)'); }
      else if (!res.ok) { onToast && onToast('招待を送れませんでした: ' + (body.message || res.status)); }
      else { onToast && onToast(inviteEmail + ' に招待を送りました'); setInviteEmail(''); setInviteName(''); await load(); }
    } catch (e) { onToast && onToast('招待を送れませんでした(通信エラー)'); }
    finally { setInviteBusy(false); }
  };

  const sendReset = async (email) => {
    try { await cloud.requestPasswordReset(email); onToast && onToast(email + ' に再設定メールを送りました(登録があれば)'); }
    catch (e) { onToast && onToast('再設定メールを送れませんでした'); }
  };

  const openCounts = window.MStore && window.MStore.counts ? window.MStore.counts() : null;
  const needsN = openCounts && openCounts.needsAssignee != null
    ? Number(openCounts.needsAssignee)
    : null;
  const allIds = (rows || []).map(u => u.id);
  const allSelected = allIds.length > 0 && allIds.every(id => selected.has(id));
  const toggleAll = () => {
    if (allSelected) setSelected(new Set());
    else setSelected(new Set(allIds));
  };
  const toggleOne = (id) => {
    setSelected(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id); else next.add(id);
      return next;
    });
  };
  const selN = selected.size;
  const anyBusy = bulkBusy || busyId != null;

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 }}>
      <AfCard>
        <SectionTitle sub="本人がメールのリンクからパスワードを決めます。こちらでパスワードは扱いません。">招待を送る</SectionTitle>
        <div style={{ fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)', marginBottom: 12, lineHeight: 1.7, marginTop: -4 }}>
          宛先は先に利用者として登録されている必要があります。
        </div>
        <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
          <AfInput value={inviteEmail} onChange={e => setInviteEmail(e.target.value)} placeholder="メールアドレス" style={{ width: 240 }} />
          <AfInput value={inviteName} onChange={e => setInviteName(e.target.value)} placeholder="表示名(新規のとき)" style={{ width: 180 }} />
          <AfButton variant="primary" onClick={invite} disabled={inviteBusy}>{inviteBusy ? '送信中…' : '招待メールを送る'}</AfButton>
        </div>
      </AfCard>

      {onOpenNeedsAssignee && (
        <AfCard>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
            <span style={{ fontWeight: 700, fontSize: 'var(--text-body-md)' }}>未アサインの工程</span>
            <span className="mono" style={{ fontSize: 'var(--text-body-lg)', fontWeight: 700, color: 'var(--warning)' }}>
              {needsN != null ? needsN : '—'}
            </span>
            <span style={{ fontSize: 'var(--text-caption)', color: 'var(--muted-foreground)' }}>
              {needsN != null ? '件（サーバ集計）' : '件数は案件一覧の読込分で確認'}
            </span>
            <AfButton size="sm" style={{ marginLeft: 'auto' }} onClick={onOpenNeedsAssignee}>
              案件一覧で未担当を見る ›
            </AfButton>
          </div>
        </AfCard>
      )}

      <AfCard pad={0} style={{ overflowX: 'auto', overflowY: 'hidden', minWidth: 0, width: '100%' }}>
        <div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
          <span style={{ fontWeight: 700, fontFamily: 'var(--font-sans)', fontSize: 'var(--text-display-sm)' }}>利用者</span>
          <span style={{ fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)' }}>
            {rows ? rows.length + '人' : '読み込み中…'}</span>
          <span style={{ marginLeft: 'auto', fontSize: 'var(--text-caption)', color: 'var(--foreground-subtle)' }}>
            削除はありません ─ 退職者は「無効化」(記録を壊さないため)</span>
        </div>
        {selN > 0 && (
          <div style={{ padding: '10px 16px', borderBottom: '1px solid var(--border)', background: 'var(--muted)',
            display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
            <span style={{ fontSize: 'var(--text-body-sm)', fontWeight: 700 }}>{selN}人を選択中</span>
            <AfButton size="sm" disabled={anyBusy} onClick={() => applyBulk('role', 'member', '権限→一般')}>一般にする</AfButton>
            <AfButton size="sm" disabled={anyBusy} onClick={() => applyBulk('role', 'admin', '権限→管理者')}>管理者にする</AfButton>
            <AfButton size="sm" disabled={anyBusy} onClick={() => applyBulk('assignable', true, '担当候補ON')}>担当候補にする</AfButton>
            <AfButton size="sm" disabled={anyBusy} onClick={() => applyBulk('assignable', false, '担当候補OFF')}>担当候補から外す</AfButton>
            <AfButton size="sm" disabled={anyBusy} onClick={() => applyBulk('active', true, '有効化')}>有効に戻す</AfButton>
            <label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 'var(--text-caption)' }}>
              <span>無効化の後任</span>
              <select aria-label="一括無効化の後任" value={bulkSuccessorId} disabled={anyBusy}
                onChange={e => setBulkSuccessorId(e.target.value)}
                style={{ ...selectStyle, width: 'auto', height: 26, minWidth: 140 }}>
                <option value="">なし(未アサインのまま)</option>
                {(rows || []).filter(u => u.isActive && u.isAssignable).map(u => (
                  <option key={u.id} value={u.id}>{u.displayName || u.email}</option>
                ))}
              </select>
            </label>
            <AfButton size="sm" disabled={anyBusy} onClick={() => applyBulk('active', false, '無効化')}>無効化</AfButton>
            <button type="button" onClick={() => setSelected(new Set())} disabled={anyBusy}
              style={{ all: 'unset', cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-caption)',
                fontWeight: 600, color: 'var(--muted-foreground)', marginLeft: 'auto' }}>選択を解除</button>
            {bulkBusy && <span style={{ fontSize: 'var(--text-caption)', color: 'var(--brand)' }}>更新中…</span>}
          </div>
        )}
        {deactivateId && rows && (
          <div style={{ padding: '10px 16px', borderBottom: '1px solid var(--border)', background: 'var(--muted)',
            display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
            <span style={{ fontSize: 'var(--text-body-sm)', fontWeight: 700 }}>
              {(rows.find(u => u.id === deactivateId) || {}).displayName || '利用者'} を無効化
            </span>
            <label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 'var(--text-caption)' }}>
              <span>後任を選ぶ</span>
              <select aria-label="後任を選ぶ" value={successorId} disabled={anyBusy}
                onChange={e => setSuccessorId(e.target.value)}
                style={{ ...selectStyle, width: 'auto', height: 26, minWidth: 160 }}>
                <option value="">なし(未アサインのまま)</option>
                {successorCandidates(deactivateId).map(u => (
                  <option key={u.id} value={u.id}>{u.displayName || u.email}</option>
                ))}
              </select>
            </label>
            <AfButton size="sm" variant="primary" disabled={anyBusy} onClick={confirmDeactivate}>無効化する</AfButton>
            <AfButton size="sm" disabled={anyBusy} onClick={() => { setDeactivateId(null); setSuccessorId(''); }}>やめる</AfButton>
            <span style={{ fontSize: 'var(--text-caption)', color: 'var(--muted-foreground)' }}>
              後任は担当者名簿に載っている人だけ選べます。選ばなければ担当は空きのままです。
            </span>
          </div>
        )}
        {err && <div style={{ padding: 16 }}><AfAlert variant="error">{err}</AfAlert></div>}
        {rows === null && !err && <div style={{ padding: 18, fontSize: 'var(--text-body-md)', color: 'var(--foreground-subtle)' }}>読み込み中…</div>}
        {rows && rows.length === 0 && !err && <div style={{ padding: 18, fontSize: 'var(--text-body-md)', color: 'var(--foreground-subtle)' }}>利用者がいません。</div>}
        {rows && rows.length > 0 && (
          <div style={{ overflowX: 'auto', width: '100%' }}>
            <table style={{ borderCollapse: 'collapse', width: '100%', minWidth: 800, fontSize: 'var(--text-body-md)' }}>
              <thead><tr>
                <TH>
                  <input type="checkbox" checked={allSelected} onChange={toggleAll}
                    disabled={anyBusy} style={{ accentColor: 'var(--brand)' }} title="すべて選択" />
                </TH>
                {['名前', 'メール', '状態', '権限', '担当者候補', '操作'].map(h => <TH key={h}>{h}</TH>)}
              </tr></thead>
              <tbody>
                {rows.map(u => {
                  const self = me && me.userId === u.id;
                  const busy = busyId === u.id || bulkBusy;
                  const on = selected.has(u.id);
                  return (
                    <tr key={u.id} style={{ opacity: u.isActive ? 1 : 0.55, background: on ? 'var(--brand-muted)' : 'transparent' }}
                      onMouseEnter={e => { if (u.isActive && !on) e.currentTarget.style.background = 'var(--accent)'; }}
                      onMouseLeave={e => { e.currentTarget.style.background = on ? 'var(--brand-muted)' : 'transparent'; }}>
                      <TD>
                        <input type="checkbox" checked={on} disabled={busy} onChange={() => toggleOne(u.id)}
                          style={{ accentColor: 'var(--brand)' }} />
                      </TD>
                      <TD style={{ fontWeight: 600 }}>
                        {u.displayName || '—'}{self && <span style={{ fontSize: 'var(--text-caption)', color: 'var(--brand)', marginLeft: 6 }}>自分</span>}</TD>
                      <TD style={{ color: 'var(--muted-foreground)' }}><span className="mono">{u.email}</span></TD>
                      <TD>{!u.isActive ? chip('無効', 'dest') : u.linked ? chip('利用中', 'ok')
                          : u.invitedAt ? chip('招待済・未受領', 'warn') : chip('未招待', '')}</TD>
                      <TD>
                        <select value={u.role} disabled={busy}
                          onChange={e => run(u.id, () => cloud.adminSetRole(u.id, e.target.value),
                            (u.displayName || u.email) + ' を ' + (e.target.value === 'admin' ? '管理者' : '一般') + 'にしました')}
                          style={{ ...selectStyle, width: 'auto', height: 26 }}>
                          <option value="member">一般</option>
                          <option value="admin">管理者</option>
                        </select></TD>
                      <TD>
                        <input type="checkbox" checked={!!u.isAssignable} disabled={busy}
                          onChange={e => run(u.id, () => cloud.adminSetAssignable(u.id, e.target.checked),
                            '受付の担当者候補を更新しました')} style={{ accentColor: 'var(--brand)' }} /></TD>
                      <TD><div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
                        {u.isActive ? (
                          <AfButton size="sm" disabled={busy || self}
                            onClick={() => { setDeactivateId(u.id); setSuccessorId(''); }}>
                            無効化</AfButton>
                        ) : (
                          <AfButton size="sm" disabled={busy || self}
                            onClick={() => run(u.id, () => cloud.adminSetActive(u.id, true),
                              (u.displayName || u.email) + ' を有効に戻しました')}>
                            有効に戻す</AfButton>
                        )}
                        <AfButton size="sm" disabled={busy || !u.isActive} onClick={() => sendReset(u.email)}>再設定メール</AfButton>
                      </div></TD>
                    </tr>);
                })}
              </tbody>
            </table>
          </div>)}
      </AfCard>
    </div>
  );
}

/* ───────────────────────── 取引先 / 協力業者 ───────────────────────── */
// kind='依頼元' … 案件を持つ取引先。依頼者規定・報告先・担当固定を編集。
// kind='業者'   … 発注実績のある協力業者。得意分野・エリア・状態・口座を編集。
function AdminOrgs({ kind, onToast }) {
  const cloud = window.MarurouCloud || null;
  const isVendor = kind === '業者';
  const [rows, setRows] = React.useState(null);
  const [err, setErr] = React.useState('');
  const [q, setQ] = React.useState('');
  const [onlyUnset, setOnlyUnset] = React.useState(false);
  const [selId, setSelId] = React.useState(null);
  const [draft, setDraft] = React.useState(null);
  const [saving, setSaving] = React.useState(false);
  const [csvPreview, setCsvPreview] = React.useState(null); // { ok, skip, errors, fileName }
  const [csvSaving, setCsvSaving] = React.useState(false);
  const csvRef = React.useRef(null);
  const apiCsvRef = React.useRef(null);
  const [apiCsvBusy, setApiCsvBusy] = React.useState(false);
  // 決-18: 案件数の多い上位社の規定7列を一覧で埋める(0053 一括更新)
  const [fillMode, setFillMode] = React.useState(false);
  const [fillDrafts, setFillDrafts] = React.useState(null);
  const [fillSaving, setFillSaving] = React.useState(false);

  const load = React.useCallback(async () => {
    if (!cloud) { setErr('接続の設定が読めていません(開発モードでは管理は使えません)'); setRows([]); return; }
    try { setRows(normOrgRows(await cloud.adminListOrgs({ query: q, kind, onlyUnset }))); setErr(''); }
    catch (e) { setErr(e && e.status === 403 ? 'この画面は管理者だけが使えます' : '一覧を読めませんでした: ' + (e.message || e)); setRows([]); }
  }, [cloud, q, kind, onlyUnset]);
  React.useEffect(() => { const t = setTimeout(load, q ? 250 : 0); return () => clearTimeout(t); }, [load]);

  const sel = rows ? rows.find(r => r.id === selId) || null : null;
  const pick = (r) => { setSelId(r.id); setDraft({ ...r, defaultCommissionRate: r.defaultCommissionRate == null ? '' : String(Math.round(Number(r.defaultCommissionRate) * 1000) / 10) }); };
  const set = (k, v) => setDraft(d => ({ ...d, [k]: v }));

  // 差分だけ送る。サーバも同じ値なら changed:false を返す(冪等)。
  const changes = () => {
    if (!sel || !draft) return {};
    const out = {};
    for (const k of Object.keys(ORG_COLS)) {
      let cur = draft[k];
      if (k === 'defaultCommissionRate') cur = cur === '' || cur == null ? null : Math.round(Number(cur) * 10) / 1000;
      if (!sameVal(cur, sel[k])) out[ORG_COLS[k]] = normVal(cur);
    }
    return out;
  };
  const dirty = Object.keys(changes()).length > 0;
  const save = async () => {
    const c = changes();
    if (!Object.keys(c).length) { onToast && onToast('変更はありません'); return; }
    if ('default_commission_rate' in c && c.default_commission_rate != null && (isNaN(c.default_commission_rate) || c.default_commission_rate < 0 || c.default_commission_rate > 100)) {
      onToast && onToast('手数料は 0〜100 の % で入れてください'); return;
    }
    setSaving(true);
    try {
      const r = await cloud.adminUpdateOrg(sel.id, c);
      onToast && onToast(r && r.changed === false ? '変更はありません' : sel.name + ' を保存しました');
      await load();
    } catch (e) {
      adminSaveFail('保存できませんでした: ' + (e.message || e), save);
      onToast && onToast('保存できませんでした');
    }
  };
  React.useEffect(() => { if (sel && draft && sel.id === draft.id && !saving) { /* 一覧を読み直したら下書きの元も更新 */ } }, [rows]);

  const users = (window.MStore && window.MStore.masters) ? (window.MStore.masters().users || []) : [];
  const pct = (v) => v == null ? '—' : (Math.round(Number(v) * 1000) / 10) + '%';
  const userName = (id) => { const u = users.find(x => x.id === id); return u ? u.name : (id ? '(名簿外)' : '—'); };

  // 規定7列だけ(0053)。画面 camelCase → DB 列
  const FILL_COLS = [
    ['defaultSettlementSurvey', 'default_settlement_survey', '精算(調査)'],
    ['defaultSettlementRepair', 'default_settlement_repair', '精算(復旧)'],
    ['defaultApproach', 'default_approach', '進め方'],
    ['defaultBillingPattern', 'default_billing_pattern', '請求'],
    ['defaultReportTo', 'default_report_to', '報告先'],
    ['reportRhythm', 'report_rhythm', 'リズム'],
    ['defaultCommissionRate', 'default_commission_rate', '手数料%'],
  ];
  const isEmptyFill = (k, v) => {
    if (k === 'defaultCommissionRate') return v === '' || v == null;
    if (k === 'defaultReportTo') return !Array.isArray(v) || v.length === 0;
    return v == null || v === '';
  };
  const enterFillMode = async () => {
    if (!cloud || isVendor) return;
    setFillMode(true); setSelId(null); setDraft(null); setCsvPreview(null);
    try {
      const list = normOrgRows(await cloud.adminListOrgs({ kind: '依頼元', onlyUnset: false, limit: 30 }));
      setRows(list); setErr('');
      setFillDrafts((list || []).map(r => ({
        ...r,
        defaultReportTo: [...(r.defaultReportTo || [])],
        defaultCommissionRate: r.defaultCommissionRate == null ? ''
          : String(Math.round(Number(r.defaultCommissionRate) * 1000) / 10),
      })));
    } catch (e) {
      setErr(e && e.status === 403 ? 'この画面は管理者だけが使えます' : '一覧を読めませんでした: ' + (e.message || e));
      setFillMode(false); setFillDrafts(null);
    }
  };
  const exitFillMode = () => { setFillMode(false); setFillDrafts(null); load(); };
  const setFill = (id, k, v) => setFillDrafts(ds => (ds || []).map(r => r.id === id ? { ...r, [k]: v } : r));
  const fillDiffs = () => {
    const ids = [], changes = [];
    (fillDrafts || []).forEach(d => {
      const orig = (rows || []).find(r => r.id === d.id);
      if (!orig) return;
      const out = {};
      FILL_COLS.forEach(([ui, db]) => {
        let cur = d[ui];
        if (ui === 'defaultCommissionRate')
          cur = cur === '' || cur == null ? null : Math.round(Number(cur) * 10) / 1000;
        const prev = orig[ui];
        if (!sameVal(cur, prev)) out[db] = normVal(cur);
      });
      if (Object.keys(out).length) { ids.push(d.id); changes.push(out); }
    });
    return { ids, changes };
  };
  const saveFillBatch = async () => {
    const { ids, changes } = fillDiffs();
    if (!ids.length) { onToast && onToast('変更はありません'); return; }
    for (const c of changes) {
      if ('default_commission_rate' in c && c.default_commission_rate != null
          && (isNaN(c.default_commission_rate) || c.default_commission_rate < 0 || c.default_commission_rate > 1)) {
        onToast && onToast('手数料は 0〜100 の % で入れてください'); return;
      }
    }
    setFillSaving(true);
    try {
      let usedBatch = false;
      if (typeof cloud.adminUpdateOrgs === 'function') {
        try {
          await cloud.adminUpdateOrgs(ids, changes);
          usedBatch = true;
        } catch (_) { usedBatch = false; }
      }
      if (!usedBatch) {
        for (let i = 0; i < ids.length; i++) await cloud.adminUpdateOrg(ids[i], changes[i]);
      }
      onToast && onToast(ids.length + '社の規定を保存しました');
      await enterFillMode();
    } catch (e) {
      adminSaveFail('一括保存できませんでした: ' + (e.message || e), saveFillBatch);
      onToast && onToast('一括保存できませんでした');
    } finally { setFillSaving(false); }
  };

  const onPickCsv = (file) => {
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => {
      const preview = adminOrgsCsvPreview(kind, rows || [], String(reader.result || ''), users);
      setCsvPreview({ ...preview, fileName: file.name });
    };
    reader.onerror = () => onToast && onToast('CSVを読めませんでした');
    reader.readAsText(file, 'UTF-8');
  };
  const saveCsvBatch = async () => {
    if (!csvPreview || !csvPreview.ok.length || !cloud) return;
    setCsvSaving(true);
    let okN = 0, failN = 0;
    try {
      for (const item of csvPreview.ok) {
        try {
          await cloud.adminUpdateOrg(item.id, item.changes);
          okN++;
        } catch (e) {
          failN++;
          adminSaveFail(item.name + ' を保存できませんでした: ' + (e.message || e));
        }
      }
      await load();
      setCsvPreview(null);
      onToast && onToast(
        failN
          ? (okN + '社を保存、' + failN + '社は失敗しました')
          : (okN + '社の規定を一括保存しました')
      );
    } finally { setCsvSaving(false); }
  };

  // 追-3-4: サーバ側 CSV(規定7列・rpc_admin_update_orgs)。依頼元タブのみ。
  const accessToken = async () => {
    if (cloud && cloud.freshAccessToken) return cloud.freshAccessToken();
    if (cloud && cloud.accessToken) return cloud.accessToken();
    return '';
  };
  const exportOrgsCsvApi = async () => {
    setApiCsvBusy(true);
    try {
      const token = await accessToken();
      const res = await fetch('/api/admin/orgs-csv', {
        method: 'GET',
        headers: { Authorization: 'Bearer ' + token },
      });
      if (!res.ok) {
        const body = await res.json().catch(() => ({}));
        onToast && onToast('CSVを書き出せませんでした: ' + (body.message || res.status));
        return;
      }
      const blob = await res.blob();
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      const cd = res.headers.get('Content-Disposition') || '';
      const m = /filename="([^"]+)"/.exec(cd);
      a.href = url; a.download = (m && m[1]) || 'orgs-defaults.csv'; a.rel = 'noopener';
      document.body.appendChild(a); a.click(); a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 1000);
      onToast && onToast('規定値CSVを書き出しました');
    } catch (e) {
      onToast && onToast('CSVを書き出せませんでした(通信エラー)');
    } finally { setApiCsvBusy(false); }
  };
  const importOrgsCsvApi = (file) => {
    if (!file) return;
    setApiCsvBusy(true);
    (async () => {
      try {
        const token = await accessToken();
        const fd = new FormData();
        fd.append('file', file, file.name || 'orgs.csv');
        const res = await fetch('/api/admin/orgs-csv', {
          method: 'POST',
          headers: { Authorization: 'Bearer ' + token },
          body: fd,
        });
        const body = await res.json().catch(() => ({}));
        if (!res.ok) {
          const errN = (body.errors && body.errors.length) || 0;
          onToast && onToast('取り込み失敗: ' + (body.message || res.status)
            + (errN ? '（行エラー ' + errN + '件）' : ''));
          return;
        }
        await load();
        const errN = (body.errors && body.errors.length) || 0;
        onToast && onToast(
          (body.updated || 0) + '社を取り込みました'
          + (errN ? '（スキップ/エラー ' + errN + '件）' : '')
        );
      } catch (e) {
        onToast && onToast('取り込みできませんでした(通信エラー)');
      } finally { setApiCsvBusy(false); }
    })();
  };

  return (
    <div style={{ display: 'grid', gridTemplateColumns: sel ? 'minmax(0, 1fr) minmax(280px, 400px)' : 'minmax(0, 1fr)', gap: 14, alignItems: 'start', minWidth: 0 }}>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
          <AfInput value={q} onChange={e => setQ(e.target.value)} placeholder={isVendor ? '業者名で絞り込む' : '取引先名で絞り込む'} aria-label="絞り込み" style={{ width: 240 }} disabled={fillMode} />
          {!isVendor && !fillMode && (
            <label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)', cursor: 'pointer' }}>
              <input type="checkbox" checked={onlyUnset} onChange={e => setOnlyUnset(e.target.checked)} style={{ accentColor: 'var(--brand)' }} />規定が未設定だけ</label>)}
          <span style={{ marginLeft: 'auto', fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)' }}>
            {rows ? rows.length + '社' + (rows.length >= 200 ? '(上限。名前で絞ってください)' : '') : '読み込み中…'}</span>
          {!isVendor && (
            fillMode
              ? <React.Fragment>
                  <AfButton size="sm" disabled={fillSaving} onClick={exitFillMode}>一覧に戻る</AfButton>
                  <AfButton size="sm" variant="primary" disabled={fillSaving || !fillDrafts} onClick={saveFillBatch}>
                    {fillSaving ? '保存中…' : 'まとめて保存'}
                  </AfButton>
                </React.Fragment>
              : <AfButton size="sm" disabled={!cloud} onClick={enterFillMode}>規定をまとめて埋める</AfButton>
          )}
          {!fillMode && <AfButton size="sm" disabled={!rows || !rows.length} onClick={() => {
            const name = isVendor ? '協力業者' : '取引先';
            downloadCsv(name + '.csv', adminOrgsCsv(kind, rows, userName));
            onToast && onToast(name + ' ' + rows.length + '社を書き出しました');
          }}>書き出し</AfButton>}
          {!fillMode && <input ref={csvRef} type="file" accept=".csv,text/csv" aria-label="CSV一括投入"
            style={{ display: 'none' }}
            onChange={e => { const f = e.target.files && e.target.files[0]; e.target.value = ''; onPickCsv(f); }} />}
          {!fillMode && <AfButton size="sm" disabled={!rows || !rows.length || csvSaving} onClick={() => csvRef.current && csvRef.current.click()}>
            CSV一括投入
          </AfButton>}
          {!isVendor && !fillMode && (
            <React.Fragment>
              <AfButton size="sm" disabled={!cloud || apiCsvBusy} onClick={exportOrgsCsvApi}>
                {apiCsvBusy ? '処理中…' : 'CSVを書き出す'}
              </AfButton>
              <input ref={apiCsvRef} type="file" accept=".csv,text/csv" aria-label="規定値CSV取込"
                style={{ display: 'none' }}
                onChange={e => { const f = e.target.files && e.target.files[0]; e.target.value = ''; importOrgsCsvApi(f); }} />
              <AfButton size="sm" disabled={!cloud || apiCsvBusy} onClick={() => apiCsvRef.current && apiCsvRef.current.click()}>
                CSVを取り込む
              </AfButton>
            </React.Fragment>
          )}
        </div>
        {fillMode && (
          <AfCard>
            <div style={{ fontWeight: 700, fontSize: 'var(--text-body-lg)', marginBottom: 6 }}>規定をまとめて埋める（案件数の多い上位）</div>
            <Hint>池田さんが先に入れる主な取引先向けです。空きは「未」と出ます。請求は「月次窓口 / 月次案件 / 都度精算」。消す導線は無く、値の変更だけです。</Hint>
            {fillDrafts && fillDrafts.length > 0 && (
              <div style={{ overflowX: 'auto', marginTop: 10 }}>
                <table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 'var(--text-body-sm)', minWidth: 960 }}>
                  <thead><tr>
                    <TH>取引先</TH><TH right>案件</TH>
                    {FILL_COLS.map(([, , lab]) => <TH key={lab}>{lab}</TH>)}
                  </tr></thead>
                  <tbody>
                    {fillDrafts.map(d => (
                      <tr key={d.id}>
                        <TD style={{ fontWeight: 600, whiteSpace: 'normal', minWidth: 140 }}>{d.name}</TD>
                        <TD right><span className="mono">{d.caseCount}</span></TD>
                        <TD>
                          <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
                            {isEmptyFill('defaultSettlementSurvey', d.defaultSettlementSurvey) && chip('未', 'warn')}
                            <Seg name={d.name + ' 精算調査'} value={d.defaultSettlementSurvey} options={SETTLEMENTS}
                              onChange={v => setFill(d.id, 'defaultSettlementSurvey', v)} />
                          </div>
                        </TD>
                        <TD>
                          <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
                            {isEmptyFill('defaultSettlementRepair', d.defaultSettlementRepair) && chip('未', 'warn')}
                            <Seg name={d.name + ' 精算復旧'} value={d.defaultSettlementRepair} options={SETTLEMENTS}
                              onChange={v => setFill(d.id, 'defaultSettlementRepair', v)} />
                          </div>
                        </TD>
                        <TD>
                          <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
                            {isEmptyFill('defaultApproach', d.defaultApproach) && chip('未', 'warn')}
                            <Seg name={d.name + ' 進め方'} value={d.defaultApproach} options={APPROACHES} label={afApproachLabel}
                              onChange={v => setFill(d.id, 'defaultApproach', v)} />
                          </div>
                        </TD>
                        <TD>
                          <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
                            {isEmptyFill('defaultBillingPattern', d.defaultBillingPattern) && chip('未', 'warn')}
                            <Seg name={d.name + ' 請求'} value={d.defaultBillingPattern} options={BILLING_PATTERNS}
                              onChange={v => setFill(d.id, 'defaultBillingPattern', v)} />
                          </div>
                        </TD>
                        <TD>
                          <div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 140 }}>
                            {isEmptyFill('defaultReportTo', d.defaultReportTo) && chip('未', 'warn')}
                            <Tags name={d.name + ' 報告先'} value={d.defaultReportTo || []} presets={REPORT_ROLES}
                              placeholder="役割を追加" onChange={v => setFill(d.id, 'defaultReportTo', v)} />
                          </div>
                        </TD>
                        <TD>
                          <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
                            {isEmptyFill('reportRhythm', d.reportRhythm) && chip('未', 'warn')}
                            <Seg name={d.name + ' リズム'} value={d.reportRhythm} options={RHYTHMS}
                              onChange={v => setFill(d.id, 'reportRhythm', v)} />
                          </div>
                        </TD>
                        <TD>
                          <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
                            {isEmptyFill('defaultCommissionRate', d.defaultCommissionRate) && chip('未', 'warn')}
                            <AfInput value={d.defaultCommissionRate} onChange={e => setFill(d.id, 'defaultCommissionRate', e.target.value)}
                              placeholder="例 10" aria-label={d.name + ' 手数料'} inputMode="decimal" style={{ width: 72 }} />
                          </div>
                        </TD>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            )}
          </AfCard>
        )}
        {csvPreview && (
          <AfCard>
            <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
              <div style={{ fontWeight: 700, fontSize: 'var(--text-body-lg)' }}>CSVプレビュー</div>
              <span style={{ fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)' }}>{csvPreview.fileName}</span>
              <button type="button" onClick={() => setCsvPreview(null)} aria-label="プレビューを閉じる"
                style={{ all: 'unset', cursor: 'pointer', marginLeft: 'auto', color: 'var(--muted-foreground)', fontSize: 'var(--text-body-md)' }}>×</button>
            </div>
            <Hint>名前が一致する既存の{isVendor ? '業者' : '取引先'}だけ更新します。新規作成はしません。空欄の列は触りません。</Hint>
            <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 8, fontSize: 'var(--text-body-sm)' }}>
              <span>更新予定 <b className="mono">{csvPreview.ok.length}</b></span>
              <span style={{ color: 'var(--muted-foreground)' }}>スキップ <b className="mono">{csvPreview.skip.length}</b></span>
              <span style={{ color: csvPreview.errors.length ? 'var(--destructive)' : 'var(--muted-foreground)' }}>エラー <b className="mono">{csvPreview.errors.length}</b></span>
            </div>
            {csvPreview.ok.length > 0 && (
              <ul style={{ margin: '10px 0 0', paddingLeft: 18, fontSize: 'var(--text-body-sm)', maxHeight: 160, overflow: 'auto' }}>
                {csvPreview.ok.slice(0, 40).map(x => (
                  <li key={x.id + ':' + x.line}>{x.name}（{Object.keys(x.changes).length}項目）</li>
                ))}
                {csvPreview.ok.length > 40 && <li>…ほか {csvPreview.ok.length - 40} 社</li>}
              </ul>
            )}
            {csvPreview.errors.length > 0 && (
              <ul style={{ margin: '8px 0 0', paddingLeft: 18, fontSize: 'var(--text-body-sm)', color: 'var(--destructive)', maxHeight: 120, overflow: 'auto' }}>
                {csvPreview.errors.slice(0, 20).map((e, i) => (
                  <li key={i}>行{e.line}{e.name ? '・' + e.name : ''}: {e.message}</li>
                ))}
              </ul>
            )}
            <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 6, marginTop: 12 }}>
              <AfButton onClick={() => setCsvPreview(null)} disabled={csvSaving}>やめる</AfButton>
              <AfButton variant="primary" disabled={!csvPreview.ok.length || csvSaving} onClick={saveCsvBatch}>
                {csvSaving ? '保存中…' : (csvPreview.ok.length + '社を一括保存')}
              </AfButton>
            </div>
          </AfCard>
        )}
        {!fillMode && <AfCard pad={0} style={{ overflowX: 'auto', overflowY: 'hidden', minWidth: 0, width: '100%' }}>
          {err && <div style={{ padding: 16 }}><AfAlert variant="error">{err}</AfAlert></div>}
          {rows === null && !err && <div style={{ padding: 18, fontSize: 'var(--text-body-md)', color: 'var(--foreground-subtle)' }}>読み込み中…</div>}
          {rows && rows.length === 0 && !err && <div style={{ padding: 18, fontSize: 'var(--text-body-md)', color: 'var(--foreground-subtle)' }}>該当する{isVendor ? '業者' : '取引先'}がありません。</div>}
          {rows && rows.length > 0 && (
            <div style={{ overflowX: 'auto', width: '100%' }}>
              <table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 'var(--text-body-md)' }}>
                <thead><tr>
                  {isVendor
                    ? <><TH>業者</TH><TH>得意分野</TH><TH>対応エリア</TH><TH>緊急</TH><TH>報告書</TH><TH>状態</TH><TH right>発注</TH></>
                    : <><TH>取引先</TH><TH>区分</TH><TH right>案件</TH><TH>規定精算(調査/復旧)</TH><TH>進め方</TH><TH>請求</TH><TH>手数料</TH><TH>担当固定</TH></>}
                </tr></thead>
                <tbody>
                  {rows.map(r => {
                    const on = r.id === selId;
                    const rowStyle = { cursor: 'pointer', background: on ? 'var(--brand-muted)' : 'transparent' };
                    const hoverOn = (e) => { if (!on) e.currentTarget.style.background = 'var(--accent)'; };
                    const hoverOff = (e) => { e.currentTarget.style.background = on ? 'var(--brand-muted)' : 'transparent'; };
                    return isVendor ? (
                      <tr key={r.id} onClick={() => pick(r)} style={rowStyle} aria-selected={on}
                        onMouseEnter={hoverOn} onMouseLeave={hoverOff}>
                        <TD style={{ fontWeight: 600 }}>{r.name}</TD>
                        <TD>{(r.vendorSkills || []).length ? r.vendorSkills.map(s => <span key={s} style={{ marginRight: 4 }}>{chip(s, '')}</span>) : <span style={{ color: 'var(--foreground-subtle)' }}>—</span>}</TD>
                        <TD>{(r.vendorAreas || []).join('・') || <span style={{ color: 'var(--foreground-subtle)' }}>—</span>}</TD>
                        <TD>{r.vendorCanEmergency == null ? '—' : r.vendorCanEmergency ? '○' : '×'}</TD>
                        <TD>{r.vendorCanReport == null ? '—' : r.vendorCanReport ? '○' : '×'}</TD>
                        <TD>{r.vendorStatus ? chip(r.vendorStatus, r.vendorStatus === '使用中' ? 'ok' : r.vendorStatus === '使用不可' ? 'warn' : '') : chip('未設定', '')}</TD>
                        <TD right><span className="mono">{r.orderCount}</span></TD>
                      </tr>) : (
                      <tr key={r.id} onClick={() => pick(r)} style={rowStyle} aria-selected={on}
                        onMouseEnter={hoverOn} onMouseLeave={hoverOff}>
                        <TD style={{ fontWeight: 600, whiteSpace: 'normal', minWidth: 200 }}>{r.name}</TD>
                        <TD>{r.partnerClass ? chip(r.partnerClass, r.partnerClass === '顧客' ? 'brand' : '') : ''}</TD>
                        <TD right><span className="mono">{r.caseCount}</span></TD>
                        <TD>{r.unset ? chip('未設定', 'warn') : ((r.defaultSettlementSurvey || '—') + ' / ' + (r.defaultSettlementRepair || '—'))}</TD>
                        <TD>{afApproachLabel(r.defaultApproach) || <span style={{ color: 'var(--foreground-subtle)' }}>—</span>}</TD>
                        <TD>{r.defaultBillingPattern || <span style={{ color: 'var(--foreground-subtle)' }}>—</span>}</TD>
                        <TD><span className="mono">{pct(r.defaultCommissionRate)}</span></TD>
                        <TD>{r.defaultLeadUserId ? userName(r.defaultLeadUserId) : <span style={{ color: 'var(--foreground-subtle)' }}>—</span>}</TD>
                      </tr>);
                  })}
                </tbody>
              </table>
            </div>)}
        </AfCard>}
        {!fillMode && !isVendor && <Hint>受付でこの取引先を選ぶと、ここの規定が案件の初期値になり、担当固定があればその人が担当になります。名前・住所は Salesforce からの移行値なので、ここからは変えません。</Hint>}
        {!fillMode && isVendor && <Hint>発注実績は、調査・工事の手配の記録を数えたものです。名前・住所は Salesforce からの移行値なので、ここからは変えません。</Hint>}
      </div>

      {!fillMode && sel && draft && (
        <AfCard style={{ position: 'sticky', top: 8 }}>
          <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 14 }}>
            <div style={{ fontWeight: 700, fontFamily: 'var(--font-sans)', fontSize: 'var(--text-display-sm)', whiteSpace: 'normal' }}>{sel.name}</div>
            <span style={{ fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)', whiteSpace: 'nowrap' }}>
              {isVendor ? '発注 ' + sel.orderCount + '件' : '案件 ' + sel.caseCount + '件'}</span>
            {sel.partnerClass && chip(sel.partnerClass, sel.partnerClass === '顧客' ? 'brand' : '')}
            <button type="button" onClick={() => { setSelId(null); setDraft(null); }} aria-label="閉じる"
              style={{ all: 'unset', cursor: 'pointer', marginLeft: 'auto', color: 'var(--muted-foreground)', fontSize: 'var(--text-body-md)', lineHeight: 1 }}>×</button>
          </div>

          {!isVendor ? (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
              <div><Lbl>規定精算方式（調査）</Lbl><Seg name="規定精算方式（調査）" value={draft.defaultSettlementSurvey} options={SETTLEMENTS} onChange={v => set('defaultSettlementSurvey', v)} /></div>
              <div><Lbl>規定精算方式（復旧工事）</Lbl><Seg name="規定精算方式（復旧工事）" value={draft.defaultSettlementRepair} options={SETTLEMENTS} onChange={v => set('defaultSettlementRepair', v)} /></div>
              <div><Lbl>進め方（保険精算のときの型）</Lbl><Seg name="進め方" value={draft.defaultApproach} options={APPROACHES} label={afApproachLabel} onChange={v => set('defaultApproach', v)} />
                <Hint>P1 先行工事（事故受付後すぐ着手・認定と入金を待たない） / P2 認定後着工 / P3 入金後着工（入金を確かめてから・最も慎重）。受付で「住めない」と答えた案件の復旧工事（保険精算）は、進め方が空なら P1 が自動で入ります。ここで決めた進め方が入った工事は自動では切り替えず、案件詳細に「P1 が目安」と出します。</Hint></div>
              <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
                <div><Lbl>請求パターン</Lbl><Seg name="請求パターン" value={draft.defaultBillingPattern} options={BILLING_PATTERNS} onChange={v => set('defaultBillingPattern', v)} /></div>
                <div><Lbl>手数料率（%）</Lbl><AfInput value={draft.defaultCommissionRate} onChange={e => set('defaultCommissionRate', e.target.value)} placeholder="例 10" aria-label="手数料率" inputMode="decimal" style={{ width: '100%' }} /></div>
                <div><Lbl>保険の窓口</Lbl><TextIn name="保険の窓口" value={draft.insuranceWindow} presets={WINDOW_PRESETS} placeholder="自社 / 依頼者 …" onChange={v => set('insuranceWindow', v)} /></div>
                <div><Lbl>オーナーの窓口</Lbl><TextIn name="オーナーの窓口" value={draft.ownerWindow} presets={WINDOW_PRESETS} placeholder="管理会社 / 自社 …" onChange={v => set('ownerWindow', v)} /></div>
              </div>
              <div><Lbl>既定の報告先</Lbl>
                <Tags name="既定の報告先" value={draft.defaultReportTo} presets={REPORT_ROLES} placeholder="役割や社名を追加" onChange={v => set('defaultReportTo', v)} />
                <Hint>「依頼担当者」はこの取引先自身（受付で取引先名に置き換わります）。候補は現場の言葉（規定値シート）。他の役割名や社名はそのまま報告先に載ります。案件の関係者へ置き換える処理はまだありません。</Hint></div>
              <div><Lbl>報告のリズム</Lbl><Seg name="報告のリズム" value={draft.reportRhythm} options={RHYTHMS} onChange={v => set('reportRhythm', v)} /></div>
              <div><Lbl>担当固定（設定すると、受付でこの人が担当になります）</Lbl>
                <select value={draft.defaultLeadUserId || ''} onChange={e => set('defaultLeadUserId', e.target.value || null)} aria-label="担当固定" style={selectStyle}>
                  <option value="">固定しない（受付で選ぶ）</option>
                  {users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
                </select>
                <Hint>候補は「利用者」タブで担当者候補にした人です。</Hint></div>
              <div><Lbl>社内担当（この取引先を担当する社内メンバー。案件の担当は決めません）</Lbl>
                <OrgOwnerSelect value={draft.ownerUserId} onChange={v => set('ownerUserId', v)} /></div>
              <div><Lbl>契約（単価表・請求条件の根拠。締結日は締結済のときだけ）</Lbl>
                <OrgContractFields status={draft.contractStatus} signedOn={draft.contractSignedOn} onChange={set} /></div>
              {/* K18-11(0102〜0104・0106): 依頼者規定の 4 項目(情報整理 v1.24)。自由記述・語彙の検査なし。
                  案件詳細・業者手配・受付の帯(K18-18)がこの値を読む。 */}
              <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
                <div><Lbl>管理エリア</Lbl><AfInput value={draft.serviceArea || ''} onChange={e => set('serviceArea', e.target.value)} placeholder="例 一都三県 / 関西" aria-label="管理エリア" style={{ width: '100%' }} /></div>
                <div><Lbl>管理会社側システム（報告投稿先）</Lbl><AfInput value={draft.clientSystemName || ''} onChange={e => set('clientSystemName', e.target.value)} placeholder="例 kViewer" aria-label="管理会社側システム" style={{ width: '100%' }} /></div>
              </div>
              <div><Lbl>取引条件（案件画面と業者手配画面に常時表示）</Lbl>
                <textarea value={draft.tradeTerms || ''} onChange={e => set('tradeTerms', e.target.value)} aria-label="取引条件"
                  placeholder="例 専用単価あり / 調査は耐圧テスト必須 / 案件ごとに窓口担当を報告書に明記"
                  style={{ width: '100%', minHeight: 56, padding: 8, borderRadius: 'var(--radius-md)', border: '1px solid var(--input)', fontSize: 'var(--text-body-sm)',
                    fontFamily: 'var(--font-sans)', resize: 'vertical', outline: 'none', color: 'var(--foreground)', background: 'var(--card)', boxSizing: 'border-box' }} /></div>
              <div><Lbl>初動対応注意点（受付・案件登録のときに表示）</Lbl>
                <textarea value={draft.firstResponseNotes || ''} onChange={e => set('firstResponseNotes', e.target.value)} aria-label="初動対応注意点"
                  placeholder="例 受付後すぐに管理会社の担当へ折り返す"
                  style={{ width: '100%', minHeight: 56, padding: 8, borderRadius: 'var(--radius-md)', border: '1px solid var(--input)', fontSize: 'var(--text-body-sm)',
                    fontFamily: 'var(--font-sans)', resize: 'vertical', outline: 'none', color: 'var(--foreground)', background: 'var(--card)', boxSizing: 'border-box' }} /></div>
              <div><Lbl>備考</Lbl>
                <textarea value={draft.notes || ''} onChange={e => set('notes', e.target.value)} aria-label="備考"
                  style={{ width: '100%', minHeight: 56, padding: 8, borderRadius: 'var(--radius-md)', border: '1px solid var(--input)', fontSize: 'var(--text-body-sm)',
                    fontFamily: 'var(--font-sans)', resize: 'vertical', outline: 'none', color: 'var(--foreground)', background: 'var(--card)', boxSizing: 'border-box' }} /></div>
            </div>
          ) : (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
              <div><Lbl>得意分野</Lbl><Tags name="得意分野" value={draft.vendorSkills} presets={SKILL_PRESETS} placeholder="分野を追加" onChange={v => set('vendorSkills', v)} /></div>
              <div><Lbl>対応エリア</Lbl><Tags name="対応エリア" value={draft.vendorAreas} presets={AREA_PRESETS} placeholder="エリアを追加" onChange={v => set('vendorAreas', v)} /></div>
              <div style={{ display: 'flex', gap: 18 }}>
                <label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 'var(--text-body-sm)', cursor: 'pointer' }}>
                  <input type="checkbox" checked={!!draft.vendorCanEmergency} onChange={e => set('vendorCanEmergency', e.target.checked)} style={{ accentColor: 'var(--brand)' }} />緊急駆けつけ可</label>
                <label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 'var(--text-body-sm)', cursor: 'pointer' }}>
                  <input type="checkbox" checked={!!draft.vendorCanReport} onChange={e => set('vendorCanReport', e.target.checked)} style={{ accentColor: 'var(--brand)' }} />報告書を書ける</label>
              </div>
              <div><Lbl>状態</Lbl><Seg name="状態" value={draft.vendorStatus} options={VENDOR_STATUS} onChange={v => set('vendorStatus', v)} /></div>
              <div><Lbl>社内担当</Lbl><OrgOwnerSelect value={draft.ownerUserId} onChange={v => set('ownerUserId', v)} /></div>
              <div><Lbl>契約（締結日は締結済のときだけ）</Lbl>
                <OrgContractFields status={draft.contractStatus} signedOn={draft.contractSignedOn} onChange={set} /></div>
              <div><Lbl>振込先</Lbl>
                <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
                  <AfInput value={draft.bankName || ''} onChange={e => set('bankName', e.target.value)} placeholder="銀行" aria-label="銀行" />
                  <AfInput value={draft.bankBranch || ''} onChange={e => set('bankBranch', e.target.value)} placeholder="支店" aria-label="支店" />
                  <AfInput value={draft.bankAccountType || ''} onChange={e => set('bankAccountType', e.target.value)} placeholder="普通 / 当座" aria-label="口座種別" />
                  <AfInput value={draft.bankAccountNo || ''} onChange={e => set('bankAccountNo', e.target.value)} placeholder="口座番号" aria-label="口座番号" />
                  <AfInput value={draft.bankAccountHolder || ''} onChange={e => set('bankAccountHolder', e.target.value)} placeholder="名義" aria-label="名義" style={{ gridColumn: '1 / -1' }} />
                </div></div>
              <div><Lbl>備考</Lbl>
                <textarea value={draft.notes || ''} onChange={e => set('notes', e.target.value)} aria-label="備考"
                  style={{ width: '100%', minHeight: 56, padding: 8, borderRadius: 'var(--radius-md)', border: '1px solid var(--input)', fontSize: 'var(--text-body-sm)',
                    fontFamily: 'var(--font-sans)', resize: 'vertical', outline: 'none', color: 'var(--foreground)', background: 'var(--card)', boxSizing: 'border-box' }} /></div>
            </div>
          )}

          <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 6, marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
            <AfButton onClick={() => pick(sel)} disabled={!dirty || saving}>取消</AfButton>
            <AfButton variant="primary" onClick={save} disabled={!dirty || saving}>{saving ? '保存中…' : '保存'}</AfButton>
          </div>
        </AfCard>)}
    </div>
  );
}

/* ───────────────────────── 工程と期限 ───────────────────────── */
// 管-10・DB-3: route_templates/route_steps(0032)。13ルート×工程の標準日数(default_days)。
// adminListRouteSteps / adminUpdateRouteStep(1工程ずつ)に接続。
// 盤面-14(0056): 同じタブ先頭に SLA しきい値(tenants の 5 列)。
const SLA_FIELDS = [
  { key: 'delayOverDays', col: 'sla_delay_over_days', label: '滞留(日)', hint: '工程期限を何日過ぎたら滞留にするか。既定1' },
  { key: 'criticalOverDays', col: 'sla_critical_over_days', label: '重大(日)', hint: '滞留のうち何日超過で重大にするか。既定7' },
  { key: 'warnSoonDays', col: 'sla_warn_soon_days', label: '予兆(日前)', hint: '工程期限の何日前から予兆にするか。既定2' },
  { key: 'reportWarnDays', col: 'sla_report_warn_days', label: '報告 予兆(日)', hint: '最終報告から何日で黄。既定5(決-36・0088「週一」)。リズム未設定の取引先にも効きます' },
  { key: 'reportOverDays', col: 'sla_report_over_days', label: '報告 超過(日)', hint: '最終報告から何日で赤。予兆以上。既定7(決-36・0088「週一」)。リズム未設定の取引先にも効きます' },
];

function AdminSlaThresholds({ onToast }) {
  const cloud = window.MarurouCloud || null;
  const remote = !!(cloud && cloud.adminGetSla && cloud.adminSetSla);
  const [sla, setSla] = React.useState(null);
  const [drafts, setDrafts] = React.useState({});
  const [err, setErr] = React.useState('');
  const [busy, setBusy] = React.useState(false);

  const load = React.useCallback(async () => {
    if (!cloud || !cloud.adminGetSla) { setErr('接続の設定が読めていません'); setSla(null); return; }
    try {
      const r = await cloud.adminGetSla();
      setSla(r || null);
      setDrafts({});
      setErr('');
    } catch (e) {
      setErr(e && e.status === 403 ? 'この画面は管理者だけが使えます' : 'しきい値を読めませんでした: ' + (e.message || e));
      setSla(null);
    }
  }, [cloud]);
  React.useEffect(() => { load(); }, [load]);

  const draftFor = (f) => drafts[f.key] !== undefined ? drafts[f.key] : (sla ? String(sla[f.key]) : '');
  const setDraft = (key, v) => setDrafts(d => ({ ...d, [key]: v }));
  const parsed = (f) => {
    const raw = draftFor(f).trim();
    if (!/^[0-9]+$/.test(raw)) return null;
    return Number(raw);
  };
  const dirtyKeys = SLA_FIELDS.filter(f => sla && draftFor(f) !== String(sla[f.key]));
  const invalid = dirtyKeys.some(f => parsed(f) == null)
    || (() => {
      const warn = dirtyKeys.find(f => f.key === 'reportWarnDays')
        ? parsed(SLA_FIELDS.find(f => f.key === 'reportWarnDays'))
        : (sla && sla.reportWarnDays);
      const over = dirtyKeys.find(f => f.key === 'reportOverDays')
        ? parsed(SLA_FIELDS.find(f => f.key === 'reportOverDays'))
        : (sla && sla.reportOverDays);
      return warn != null && over != null && over < warn;
    })();

  const save = async () => {
    if (!dirtyKeys.length || invalid) return;
    const changes = {};
    for (const f of dirtyKeys) {
      const n = parsed(f);
      if (n == null) { onToast && onToast('日数は0以上の整数で入れてください'); return; }
      changes[f.col] = n;
    }
    setBusy(true);
    try {
      const r = await cloud.adminSetSla(changes);
      onToast && onToast(r && r.changed === false ? '変更はありません' : 'SLAしきい値を保存しました');
      await load();
    } catch (e) {
      adminSaveFail('保存できませんでした: ' + (e.message || e), save);
      onToast && onToast('保存できませんでした');
    }
    finally { setBusy(false); }
  };

  return (
    <AfCard pad={0} style={{ gridColumn: '1 / -1' }}>
      <div style={{ padding: 14 }}>
        <div style={{ fontWeight: 700, marginBottom: 6 }}>SLAしきい値(盤面-14)</div>
        <AfAlert variant="notice" title="盤面の赤・黄の基準です">
          ここでの変更は<strong>次に盤面や件数を読み直したとき</strong>から効きます。既に開いている画面の色はすぐには変わりません。
          報告のリズムは<strong>会社の既定が「週一」</strong>です(決-36・0088で実装)。下の報告の2つ(既定 黄5日・赤7日)は、<strong>リズムを決めていない取引先にも効きます</strong>。取引先ごとに決めたいときだけ取引先マスタで上書きしてください: 「密に」=この既定と同じ扱い / 「節目ごと」=日数なし(工程完了なのに報告が無いときだけ黄。赤にはしません) / 「随時」=アラートなし。
        </AfAlert>
        {!remote && <div style={{ marginTop: 10 }}><AfAlert variant="info" title="接続がありません">管理用の読み書き口(adminGetSla)が読めないため、しきい値は編集できません。</AfAlert></div>}
        {err && <div style={{ marginTop: 10 }}><AfAlert variant="error">{err}</AfAlert></div>}
        {sla && (
          <div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 10 }}>
            {SLA_FIELDS.map(f => (
              <label key={f.key} style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 'var(--text-body-sm)' }}>
                <span style={{ fontWeight: 600 }}>{f.label}</span>
                <AfInput value={draftFor(f)} onChange={e => setDraft(f.key, e.target.value)}
                  aria-label={f.label} inputMode="numeric" disabled={busy}
                  style={{ width: '100%', textAlign: 'right' }} />
                <span style={{ color: 'var(--muted-foreground)', fontSize: 'var(--text-caption)' }}>{f.hint}</span>
              </label>
            ))}
          </div>
        )}
        {sla && (
          <div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
            <AfButton size="sm" variant={dirtyKeys.length && !invalid ? 'primary' : undefined}
              disabled={!dirtyKeys.length || invalid || busy} onClick={save}>
              {busy ? '保存中…' : 'しきい値を保存'}</AfButton>
            {invalid && dirtyKeys.length > 0 && (
              <span style={{ fontSize: 'var(--text-caption)', color: 'var(--destructive)' }}>
                日数は0以上の整数、報告超過は予兆以上にしてください</span>)}
          </div>
        )}
      </div>
    </AfCard>
  );
}

/* ⑮: 朝のまとめメールのプレビュー(rpc_digest_preview)。送信はしない。 */
function digestLine(items, emptyLabel) {
  if (!items || !items.length) return emptyLabel;
  return items.map((x, i) => {
    const head = [x.caseNo, x.caseTitle].filter(Boolean).join(' ');
    const rest = [x.label, x.dueOn, x.severity].filter(Boolean).join(' · ');
    return (i + 1) + '. ' + head + (rest ? ' — ' + rest : '');
  }).join('\n');
}
function AdminDigestPreview({ onToast }) {
  const cloud = window.MarurouCloud || null;
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState('');
  const [preview, setPreview] = React.useState(null);

  const load = async () => {
    if (!cloud || !cloud.digestPreview) {
      setErr('接続の設定が読めていません');
      setPreview(null);
      return;
    }
    setBusy(true);
    setErr('');
    try {
      const r = await cloud.digestPreview();
      setPreview(r || null);
      onToast && onToast('今日のまとめを読みました(送信はしていません)');
    } catch (e) {
      setPreview(null);
      setErr(e && e.status === 403
        ? 'この画面は管理者だけが使えます'
        : 'プレビューを読めませんでした: ' + (e.message || e));
    } finally {
      setBusy(false);
    }
  };

  const d = preview && preview.digest;
  const enabled = preview && preview.digestEnabled === true;
  const body = !preview ? '' : [
    '件名: ' + (preview.subject || '—'),
    '日付: ' + (preview.date || '—'),
    '宛先: ' + ((d && (d.displayName || d.email)) || '（担当者名簿に無い／メール未設定）'),
    '送信: ' + (enabled ? '有効' : '無効') + '（digest_enabled・既定オフ。SPF/DKIM後にSQLで切替）',
    '',
    '■ 今日期限',
    digestLine(d && d.dueToday, '（なし）'),
    '',
    '■ 遅れている',
    digestLine(d && d.delayed, '（なし）'),
    '',
    '■ 予兆',
    digestLine(d && d.warn, '（なし）'),
    '',
    '■ 未アサインの工程',
    digestLine(d && d.unassignedSteps, '（なし）'),
  ].join('\n');

  return (
    <AfCard pad={0} style={{ gridColumn: '1 / -1' }}>
      <div style={{ padding: '12px 14px' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: 6 }}>
          <div style={{ fontWeight: 700 }}>朝のまとめメール(⑮)</div>
          {preview && (
            <span style={{
              fontSize: 'var(--text-caption)', fontWeight: 700, padding: '2px 8px', borderRadius: 'var(--radius-full)',
              color: enabled ? 'var(--success)' : 'var(--muted-foreground)',
              background: enabled ? 'var(--success-muted)' : 'var(--muted)',
              border: '1px solid ' + (enabled ? 'var(--success-border)' : 'var(--border)'),
            }}>{enabled ? '送信: 有効' : '送信: 無効'}</span>
          )}
        </div>
        <div style={{ fontSize: 'var(--text-caption)', color: 'var(--muted-foreground)', marginBottom: 10, lineHeight: 1.6 }}>
          「今日のまとめ(自分宛)」は rpc_digest_preview の集計だけを見せます。メールは送りません（Resend 実送信は SPF/DKIM 後）。
        </div>
        <AfButton size="sm" disabled={busy} onClick={load}>{busy ? '読込中…' : '今日のまとめ(自分宛)'}</AfButton>
        {err && <div style={{ marginTop: 8, fontSize: 'var(--text-body-sm)', color: 'var(--destructive)' }}>{err}</div>}
        {preview && (
          <pre style={{
            marginTop: 10, marginBottom: 0, padding: 12, maxHeight: 320, overflow: 'auto',
            fontFamily: 'var(--font-mono)', fontSize: 'var(--text-caption)', lineHeight: 1.55,
            background: 'var(--muted)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)',
            whiteSpace: 'pre-wrap', wordBreak: 'break-word',
          }}>{body}</pre>
        )}
      </div>
    </AfCard>
  );
}

function AdminRoutes({ onToast }) {
  const cloud = window.MarurouCloud || null;
  const remote = !!(cloud && cloud.adminListRouteSteps && cloud.adminUpdateRouteStep);
  const [routes, setRoutes] = React.useState(null);   // null=読込中 / []=空
  const [err, setErr] = React.useState('');
  const [selCode, setSelCode] = React.useState(null);
  const [drafts, setDrafts] = React.useState({});      // stepId -> 入力中の文字列
  const [busyId, setBusyId] = React.useState(null);

  const load = React.useCallback(async () => {
    if (!cloud) { setErr('接続の設定が読めていません(開発モードでは管理は使えません)'); setRoutes([]); return; }
    try {
      const r = await cloud.adminListRouteSteps();
      setRoutes(Array.isArray(r) ? r : []);
      setErr('');
    } catch (e) {
      setErr(e && e.status === 403 ? 'この画面は管理者だけが使えます' : '一覧を読めませんでした: ' + (e.message || e));
      setRoutes([]);
    }
  }, [cloud]);
  React.useEffect(() => { load(); }, [load]);
  React.useEffect(() => {
    if (routes && routes.length && (selCode == null || !routes.some(r => r.routeCode === selCode))) {
      setSelCode(routes[0].routeCode);
    }
  }, [routes]);

  const sel = routes ? routes.find(r => r.routeCode === selCode) || null : null;
  const draftFor = (step) => drafts[step.stepId] !== undefined ? drafts[step.stepId] : String(step.defaultDays);
  const setDraft = (stepId, v) => setDrafts(d => ({ ...d, [stepId]: v }));
  const clearDraft = (stepId) => setDrafts(d => { if (!(stepId in d)) return d; const next = { ...d }; delete next[stepId]; return next; });
  const parsedDays = (step) => {
    const raw = draftFor(step).trim();
    if (!/^[0-9]+$/.test(raw)) return null;
    const n = Number(raw);
    return n >= 1 && n <= 365 ? n : null;
  };
  const isDirty = (step) => draftFor(step) !== String(step.defaultDays);

  const save = async (step) => {
    const n = parsedDays(step);
    if (n == null) { onToast && onToast('標準日数は1〜365の整数で入れてください'); return; }
    setBusyId(step.stepId);
    try {
      const r = await cloud.adminUpdateRouteStep(step.stepId, n);
      onToast && onToast(r && r.changed === false ? '変更はありません' : ((sel ? sel.routeName : '') + '「' + step.stepName + '」の標準日数を保存しました'));
      clearDraft(step.stepId);
      await load();
    } catch (e) {
      adminSaveFail('保存できませんでした: ' + (e.message || e), () => save(step));
      onToast && onToast('保存できませんでした');
    }
    finally { setBusyId(null); }
  };

  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'minmax(160px, 0.3fr) minmax(0, 1fr)', gap: 14, alignItems: 'start', minWidth: 0 }}>
      <AdminSlaThresholds onToast={onToast} />
      <AdminDigestPreview onToast={onToast} />

      <AfCard pad={0} style={{ gridColumn: '1 / -1' }}>
        <div style={{ padding: 14 }}>
          <div style={{ fontWeight: 700, marginBottom: 6 }}>工程と期限(管-10)</div>
          <AfAlert variant="notice" title="この一覧は全社共通のマスタです">
            13ルートの工程は会社に1セットしかありません。ここでの変更は特定の案件やテナントだけでなく、<b>全社の今後の工程</b>に効きます。
            また、<b>効くのは「これから進む工程」「これから作るトラック」だけ</b>です ─ 標準日数は工程が進んだ瞬間に期限へ焼き込まれる仕組みなので、
            <b>既に走っているトラックの期限はこの変更では変わりません。</b>
          </AfAlert>
          {!remote && <div style={{ marginTop: 10 }}><AfAlert variant="info" title="接続がありません">管理用の読み書き口(adminListRouteSteps)が読めないため、この画面は使えません。</AfAlert></div>}
        </div>
      </AfCard>

      <AfCard pad={0}>
        <div style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)', fontWeight: 700 }}>ルート一覧(13)</div>
        {err && <div style={{ padding: 16 }}><AfAlert variant="error">{err}</AfAlert></div>}
        {routes === null && !err && <div style={{ padding: 18, fontSize: 'var(--text-body-md)', color: 'var(--foreground-subtle)' }}>読み込み中…</div>}
        {routes && (
          <div style={{ padding: 6, display: 'flex', flexDirection: 'column', gap: 3 }}>
            {routes.map(r => (
              <button key={r.routeCode} type="button" aria-pressed={selCode === r.routeCode} onClick={() => setSelCode(r.routeCode)}
                style={{ all: 'unset', cursor: 'pointer', padding: '8px 10px', borderRadius: 'var(--radius-sm)',
                  color: selCode === r.routeCode ? 'var(--brand)' : 'var(--foreground)',
                  background: selCode === r.routeCode ? 'var(--brand-muted)' : 'transparent' }}>
                <div style={{ fontSize: 'var(--text-body-md)', fontWeight: selCode === r.routeCode ? 700 : 500 }}>
                  #{r.routeCode} {r.routeName}</div>
                <div style={{ fontSize: 'var(--text-caption)', color: 'var(--muted-foreground)', marginTop: 2 }}>
                  {r.steps.length}工程{r.approach ? ' ・ ' + afApproachLabel(r.approach) : ''}</div>
              </button>))}
          </div>)}
      </AfCard>

      {sel && (
        <AfCard pad={0} style={{ overflowX: 'auto', overflowY: 'hidden', minWidth: 0 }}>
          <div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
            <span style={{ fontWeight: 700, fontFamily: 'var(--font-sans)', fontSize: 'var(--text-display-sm)' }}>#{sel.routeCode} {sel.routeName}</span>
            <span style={{ fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)' }}>
              {[sel.trackCategory, sel.settlementType, afApproachLabel(sel.approach)].filter(Boolean).join(' / ')}{sel.frequency ? '(頻度: ' + sel.frequency + ')' : ''}</span>
          </div>
          <div style={{ overflowX: 'auto', width: '100%' }}>
            <table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 'var(--text-body-md)' }}>
              <thead><tr>{['#', '工程', 'fact_key', 'ボール', 'ゲート', '標準日数(日)', ''].map(h => <TH key={h} right={h === '標準日数(日)'}>{h}</TH>)}</tr></thead>
              <tbody>
                {sel.steps.map(s => {
                  const busy = busyId === s.stepId;
                  const dirty = isDirty(s);
                  const invalid = dirty && parsedDays(s) == null;
                  return (
                    <tr key={s.stepId}>
                      <TD><span className="mono">{s.stepNo}</span></TD>
                      <TD style={{ fontWeight: 600 }}>{s.stepName}</TD>
                      <TD style={{ color: 'var(--muted-foreground)' }}><span className="mono">{s.factKey}</span></TD>
                      <TD>{s.ball}</TD>
                      <TD>{s.isGate ? chip('着工ゲート', 'warn') : ''}</TD>
                      <TD right>
                        <AfInput value={draftFor(s)} onChange={e => setDraft(s.stepId, e.target.value)}
                          aria-label={s.stepName + 'の標準日数'} inputMode="numeric" disabled={busy}
                          style={{ width: 70, textAlign: 'right' }} />
                      </TD>
                      <TD>
                        <AfButton size="sm" variant={dirty && !invalid ? 'primary' : undefined}
                          disabled={!dirty || invalid || busy} onClick={() => save(s)}>
                          {busy ? '保存中…' : '保存'}</AfButton>
                      </TD>
                    </tr>);
                })}
              </tbody>
            </table>
          </div>
          <Hint>標準日数は1〜365日の範囲で入力してください。工程ごとに1件ずつ保存します。</Hint>
        </AfCard>)}
    </div>
  );
}

/* ───────────────────────── まだ無い画面 ───────────────────────── */
function AdminNotYet({ what, note }) {
  return (
    <AfCard>
      <SectionTitle>{what}</SectionTitle>
      <AfAlert variant="notice">{note}</AfAlert>
    </AfCard>);
}

/* ───────────────────────── 入口 ───────────────────────── */
function AdminScreen({ onToast, onOpenNeedsAssignee }) {
  const [tab, setTab] = React.useState(() => { try { return sessionStorage.getItem('mr.adminTab') || 'users'; } catch (e) { return 'users'; } });
  const go = (id) => { setTab(id); try { sessionStorage.setItem('mr.adminTab', id); } catch (e) {} };
  return (
    <div style={{ padding: '20px 24px 32px 20px', display: 'flex', flexDirection: 'column', gap: 16, minWidth: 0, maxWidth: '100%', boxSizing: 'border-box' }}>
      <SectionTitle sub="利用者・取引先・工程と期限・TODO種別を編集できます。">管理</SectionTitle>
      <div role="tablist" aria-label="管理" style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
        {ADMIN_TABS.map(t => {
          const on = tab === t.id;
          return (
            <button key={t.id} type="button" role="tab" aria-selected={on} onClick={() => go(t.id)} style={tabBtn(on)}>
              {t.label}</button>);
        })}
      </div>
      {tab === 'users' && <AdminUsers onToast={onToast} onOpenNeedsAssignee={onOpenNeedsAssignee} />}
      {tab === 'orgs' && <AdminOrgs kind="依頼元" onToast={onToast} />}
      {tab === 'vendors' && <AdminOrgs kind="業者" onToast={onToast} />}
      {tab === 'routes' && <AdminRoutes onToast={onToast} />}
      {tab === 'options' && <AdminTodoTypes onToast={onToast} />}
    </div>
  );
}

Object.assign(window, { AdminScreen, AdminTodoTypes, AdminRoutes });
