/* ============================================================
   案件一覧 + クイックアサイン（QuickAssign）
   ============================================================ */

/* 担当セル：主担当アバター＋副担当、クリックで指定ポップオーバー */
function QuickAssign({ caseId, compact = false }) {
  const { cases, assignOwner, toggleSub, currentUser, can } = useStore();
  const D = window.APP_DATA;
  const c = cases.find(k => k.id === caseId);
  const [open, setOpen] = React.useState(false);
  const [popPos, setPopPos] = React.useState({ top: 0, left: 0 });
  const popWrapRef = React.useRef(null);
  // ページ側のスクロールで閉じる（ポップオーバー内のスクロールでは閉じない）
  React.useEffect(() => {
    if (!open) return;
    const close = (e) => { if (popWrapRef.current && e.target && popWrapRef.current.contains(e.target)) return; setOpen(false); };
    window.addEventListener('scroll', close, true);
    return () => window.removeEventListener('scroll', close, true);
  }, [open]);
  const owner = c.ownerId ? D.user(c.ownerId) : null;
  const subs = c.subIds.map(id => D.user(id));
  const isAdmin = currentUser.role === 'admin';
  // 担当は全員表示：1行に1人（アバター＋名前）。+N 表記はしない
  const members = [owner, ...subs].filter(Boolean);
  const membersEl = compact
    ? (members.length ? <AvatarGroup users={members} size={24} /> : <Avatar user={null} size={24} />)
    : (
      <span style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-start' }}>
        {members.length === 0 && (
          <span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
            <Avatar user={null} size={22} />
            <span style={{ fontSize: 12.5, color: '#9aa1ab', fontWeight: 500 }}>{t('cases.unassigned')}</span>
          </span>
        )}
        {members.map((u, mi) => (
          <span key={u.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
            <Avatar user={u} size={22} />
            <span style={{ fontSize: 12.5, color: '#2b2f38', fontWeight: 500, whiteSpace: 'nowrap' }}>{u.short}</span>
            {mi === 0 && !!owner && <span style={{ fontSize: 10, fontWeight: 800, color: '#4a5af0', background: '#eef0fe', padding: '1px 5px', borderRadius: 4, whiteSpace: 'nowrap' }}>{t('cases.mainOwner')}</span>}
          </span>
        ))}
      </span>
    );
  // 担当アサインの変更はマネジャー＋。メンバーには読み取り専用で表示
  if (!can('assign')) {
    return (
      <span style={{ display: 'inline-flex', alignItems: 'flex-start', gap: 7, padding: '3px 4px' }}>
        {membersEl}
      </span>
    );
  }
  return (
    <div style={{ position: 'relative', display: 'inline-block' }}>
      <button onClick={(e) => { e.stopPropagation();
          const r = e.currentTarget.getBoundingClientRect();
          // 画面端・リスト下端で切れないよう fixed 配置
          setPopPos({ top: Math.max(8, Math.min(r.bottom + 6, window.innerHeight - 330)), left: Math.max(8, Math.min(r.left, window.innerWidth - 333)) });
          setOpen(o => !o); }}
        style={{ display: 'flex', alignItems: 'center', gap: 7, border: '1px solid transparent', borderRadius: 8, padding: '4px 6px 4px 4px',
          cursor: 'pointer', background: open ? '#eef0f3' : 'transparent', fontFamily: 'inherit', transition: 'background .12s', textAlign: 'left' }}
        onMouseEnter={(e) => { if (!open) e.currentTarget.style.background = '#f0f1f4'; }}
        onMouseLeave={(e) => { if (!open) e.currentTarget.style.background = 'transparent'; }}>
        {membersEl}
        <Icon name="chevronDown" size={13} stroke={2} style={{ color: '#b4bac3', flex: '0 0 auto' }} />
      </button>
      {open && (
        <div ref={popWrapRef} onClick={(e) => e.stopPropagation()}>
          <AssignPopover ownerId={c.ownerId} subIds={c.subIds}
            onPick={async (uid) => { await assignOwner(caseId, uid); setOpen(false); }}
            onToggleSub={(uid) => toggleSub(caseId, uid)}
            onClose={() => setOpen(false)}
            anchorStyle={{ position: 'fixed', top: popPos.top, left: popPos.left, zIndex: 300 }} />
        </div>
      )}
    </div>
  );
}

function FilterPill({ label, value, options, onChange, icon, multi }) {
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef(null);
  React.useEffect(() => { const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, []);
  /* multi＝複数選択（ステータス等）。value は配列（旧データの文字列 'all'/'won' も受けて正規化）。
     行クリックでトグル（開いたまま次を選べる）・先頭「すべて」でクリア */
  const selArr = multi ? (Array.isArray(value) ? value : (value && value !== 'all' ? [value] : [])) : null;
  const cur = multi ? null : options.find(o => o.value === value);
  const active = multi ? selArr.length > 0 : !!(cur && options[0] && cur.value !== options[0].value);
  const multiLabel = () => {
    const ls = selArr.map(v => (options.find(o => o.value === v) || {}).label || v);
    return ls.length <= 2 ? ls.join('・') : ls.slice(0, 2).join('・') + ' ＋' + (ls.length - 2);
  };
  return (
    <div ref={ref} style={{ position: 'relative' }}>
      <button onClick={() => setOpen(o => !o)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '7px 10px', borderRadius: 8,
        border: '1px solid ' + (active ? '#c9cff8' : '#e2e5ea'), background: active ? '#f6f6fe' : '#fff', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, color: '#3b414b', fontWeight: 500, whiteSpace: 'nowrap' }}>
        {icon && <Icon name={icon} size={14} stroke={2} style={{ color: '#9aa1ab' }} />}
        <span style={{ color: '#9aa1ab' }}>{label}</span>
        {/* 既定（すべて）の時は値を隠してコンパクトに。絞り込み中のみ値を表示 */}
        {active && <span style={{ fontWeight: 700, color: '#4a5af0' }}>{multi ? multiLabel() : cur.label}</span>}
        <Icon name="chevronDown" size={13} stroke={2} style={{ color: '#b4bac3' }} />
      </button>
      {open && (
        <div style={{ position: 'absolute', top: 40, left: 0, zIndex: 60, minWidth: 190, maxHeight: 340, overflowY: 'auto', background: '#fff', borderRadius: 10,
          border: '1px solid #e6e8ec', boxShadow: '0 14px 34px rgba(20,22,40,.16)', padding: 6 }}>
          {options.map((o, oi) => {
            const checked = multi ? (oi === 0 ? selArr.length === 0 : selArr.includes(o.value)) : o.value === value;
            const click = () => {
              if (!multi) { onChange(o.value); setOpen(false); return; }
              if (oi === 0) { onChange([]); setOpen(false); return; }   // すべて＝クリア
              onChange(selArr.includes(o.value) ? selArr.filter(v => v !== o.value) : [...selArr, o.value]);
            };
            return (
              <div key={o.value} className="row-hover" onClick={click}
                style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, padding: '7px 9px', borderRadius: 7, cursor: 'pointer', fontSize: 13, color: '#3b414b' }}>
                <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>{o.dot && <span style={{ width: 7, height: 7, borderRadius: '50%', background: o.dot }} />}{o.label}</span>
                {checked && <Icon name="check" size={14} stroke={2.4} style={{ color: '#4a5af0' }} />}
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}

function RankSelect({ caseId, size = 'md' }) {
  const { cases, setRank } = useStore();
  const D = window.APP_DATA;
  const c = cases.find(k => k.id === caseId);
  const r = D.RANKS[c.rank] || { label: c.rank || '—', soft: '#eff1f3', fg: '#4b5563' };
  const [open, setOpen] = React.useState(false);
  const [pos, setPos] = React.useState({ top: 0, left: 0 });
  const ref = React.useRef(null);
  const btnRef = React.useRef(null);
  React.useEffect(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target) && btnRef.current && !btnRef.current.contains(e.target)) setOpen(false); };
    const close = (e) => { if (ref.current && e.target && ref.current.contains(e.target)) return; setOpen(false); };
    document.addEventListener('mousedown', h);
    window.addEventListener('scroll', close, true);
    return () => { document.removeEventListener('mousedown', h); window.removeEventListener('scroll', close, true); };
  }, []);
  // 画面下端で切れないよう fixed 配置（リストの行や画面端の overflow に影響されない）
  const toggle = (e) => {
    e.stopPropagation();
    const rect = e.currentTarget.getBoundingClientRect();
    const height = D.RANK_ORDER.length * 38 + 44;
    setPos({ top: Math.max(8, Math.min(rect.bottom + 6, window.innerHeight - height - 10)), left: Math.max(8, Math.min(rect.left, window.innerWidth - 165)) });
    setOpen(o => !o);
  };
  return (
    <div style={{ position: 'relative', display: 'inline-block' }}>
      <button ref={btnRef} onClick={toggle}
        style={{ display: 'inline-flex', alignItems: 'center', gap: 4, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
          background: r.soft, color: r.fg, borderRadius: 7, padding: size === 'lg' ? '5px 11px' : '4px 8px', fontSize: size === 'lg' ? 13.5 : 12.5, fontWeight: 700 }}>
        {r.label}<Icon name="chevronDown" size={12} stroke={2.2} style={{ opacity: .55 }} />
      </button>
      {open && (
        <div ref={ref} onClick={(e) => e.stopPropagation()} style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 300, width: 152, background: '#fff', borderRadius: 10, border: '1px solid #e6e8ec', boxShadow: '0 14px 34px rgba(20,22,40,.16)', padding: 6, maxHeight: 280, overflowY: 'auto' }}>
          <div style={{ fontSize: 12, fontWeight: 700, color: '#a8aeb8', padding: '5px 9px 4px', letterSpacing: '.04em' }}>{t('cases.rankLabel')}</div>
          {D.RANK_ORDER.map(rk => {
            const rr = D.RANKS[rk];
            return (
              <div key={rk} className="row-hover" onClick={() => { setRank(caseId, rk); setOpen(false); }}
                style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '7px 9px', borderRadius: 7, cursor: 'pointer' }}>
                <span style={{ width: 22, height: 22, borderRadius: 6, background: rr.soft, color: rr.fg, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flex: '0 0 auto' }}>{rr.label}</span>
                <span style={{ fontSize: 12.5, color: '#3b414b', flex: 1 }}>{rr.desc}</span>
                {c.rank === rk && <Icon name="check" size={14} stroke={2.4} style={{ color: '#4a5af0' }} />}
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}

/* 状態の行内編集（ランク／担当と同様に一覧から直接変更）。RankSelect と同じ fixed ポップオーバー方式 */
function StatusSelect({ caseId }) {
  const { cases, changeStatus } = useStore();
  const D = window.APP_DATA;
  const c = cases.find(k => k.id === caseId);
  const s = D.STATUS[c.status] || { label: c.status || '—', soft: '#eff1f3', fg: '#4b5563', color: '#9aa1ab' };
  const [open, setOpen] = React.useState(false);
  const [pos, setPos] = React.useState({ top: 0, left: 0 });
  const ref = React.useRef(null);
  const btnRef = React.useRef(null);
  React.useEffect(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target) && btnRef.current && !btnRef.current.contains(e.target)) setOpen(false); };
    const close = (e) => { if (ref.current && e.target && ref.current.contains(e.target)) return; setOpen(false); };
    document.addEventListener('mousedown', h);
    window.addEventListener('scroll', close, true);
    return () => { document.removeEventListener('mousedown', h); window.removeEventListener('scroll', close, true); };
  }, []);
  const [lostAsk, setLostAsk] = React.useState(false); // 失注へ変える瞬間に理由入力モーダル
  const [wonAsk, setWonAsk] = React.useState(null); // 成約/受注へ変える瞬間の受注理由モーダル（値=適用status）
  const toggle = (e) => {
    e.stopPropagation();
    const rect = e.currentTarget.getBoundingClientRect();
    const height = D.STATUS_ORDER.length * 38 + 16;
    setPos({ top: Math.max(8, Math.min(rect.bottom + 6, window.innerHeight - height - 10)), left: Math.max(8, Math.min(rect.left, window.innerWidth - 190)) });
    setOpen(o => !o);
  };
  return (
    <div style={{ position: 'relative', display: 'inline-block' }}>
      <button ref={btnRef} onClick={toggle}
        style={{ display: 'inline-flex', alignItems: 'center', gap: 5, border: 'none', cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
          background: s.soft, color: s.fg, borderRadius: 999, padding: '3px 9px', fontSize: 12, fontWeight: 600, lineHeight: 1.4 }}>
        <span style={{ width: 6, height: 6, borderRadius: '50%', background: s.color, flex: '0 0 auto' }} />{s.label}<Icon name="chevronDown" size={11} stroke={2.2} style={{ opacity: .5, flex: '0 0 auto' }} />
      </button>
      {open && (
        <div ref={ref} onClick={(e) => e.stopPropagation()} style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 300, width: 176, background: '#fff', borderRadius: 10, border: '1px solid #e6e8ec', boxShadow: '0 14px 34px rgba(20,22,40,.16)', padding: 6 }}>
          {D.STATUS_ORDER.map(st => {
            const ss = D.STATUS[st];
            return (
              <div key={st} className="row-hover" onClick={() => { if (st !== c.status) { if (st === 'lost') setLostAsk(true); else if ((st === 'won' || st === 'done') && !isCaseWonStatus(c.status)) setWonAsk(st); else changeStatus(caseId, st); } setOpen(false); }}
                style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '7px 9px', borderRadius: 7, cursor: 'pointer' }}>
                <span style={{ width: 8, height: 8, borderRadius: '50%', background: ss.color, flex: '0 0 auto' }} />
                <span style={{ fontSize: 12.5, color: '#3b414b', flex: 1 }}>{ss.label}</span>
                {c.status === st && <Icon name="check" size={14} stroke={2.4} style={{ color: '#4a5af0' }} />}
              </div>
            );
          })}
        </div>
      )}
      {lostAsk && <LostReasonModal caseId={caseId} initialReason={c.lostReason} initialNote={c.lostReasonNote} applyStatus onClose={() => setLostAsk(false)} />}
      {wonAsk && <WonReasonModal caseId={caseId} initialReason={c.wonReason} initialNote={c.wonReasonNote} applyStatus={wonAsk} onClose={() => setWonAsk(null)} />}
    </div>
  );
}

/* caseViaMeta（経由メタ）は ui.jsx(L0) へ移設済み（2026-07-19・dashboard/analytics と共用のため）。 */
// 経由フィルタの選択肢（実データに在る取得元だけ提示）。dot は列バッジと同色
const VIA_DEFS = [['readycrew', 'ReadyCrew', '#4a5af0'], ['hnavi', '発注ナビ', '#c2410c'], ['gmail', 'Gmail', '#b91c1c'], ['fireflies', 'Fireflies', '#7c3aed'], ['manual', window.t("ws.manual"), '#7b828d']];

/* 列定義（ヘッダー・行で共通）— 案件名と顧客の両方を伸縮させ、広い画面でも空白が偏らない */
const caseGrid = {
  display: 'grid',
  gridTemplateColumns: '116px 58px minmax(200px,1.6fr) minmax(148px,1fr) 122px 112px 138px 96px 76px 132px',
  columnGap: 10,
  minWidth: 1324,
  alignItems: 'center',
};

/* 一覧の絞り込み・並び順は localStorage に保持（詳細→戻るで毎回リセットされないように）。
   検索文字とページ番号は保持しない（その場限り）。ダッシュボードからの preset が最優先。 */
const CASELIST_PREFS_KEY = 'anken_caselist_prefs';
const loadCasePrefs = key => { try { return JSON.parse(localStorage.getItem(key)) || {}; } catch (_) { return {}; } };

function CaseList() {
  const { cases, workspaceCases: allCases, navigate, currentUser, route, emails, can, changeStatus, loginAs, showToast, workspaceId } = useStore();
  const caseDupGroups = React.useMemo(() => kmCaseDupGroups(allCases), [allCases]);
  const [openCaseMerge, setOpenCaseMerge] = React.useState(false);
  // 顧客への送信メール(out)が1通でもある顧客の集合＝実際に送信済み。提案メール状態を「実メール送信」と連動させるために使う
  const sentCustomers = React.useMemo(() => new Set((emails || []).filter(e => e && e.direction === 'out' && e.customerId).map(e => e.customerId)), [emails]);
  // 表示用の実効・提案メール状態：明示的に設定済み(none以外)ならそれ、未設定でも送信メールがあれば「送信済み」とみなす
  const effProposalStatus = (c) => { const raw = c.proposalMailStatus || 'none'; return raw !== 'none' ? raw : (sentCustomers.has(c.customerId) ? 'sent' : 'none'); };
  const [gmailSyncing, setGmailSyncing] = React.useState(false);
  // Gmail一括同期：自分の担当案件の顧客メールをサーバ側で本文込みに同期→再取得して反映
  const syncGmailAll = async () => {
    if (gmailSyncing) return; setGmailSyncing(true);
    try {
      const r = await API.gmailServerSyncNow();
      const d = await API.bootstrap(); window.hydrateAppData(d); loginAs(currentUser.id);
      showToast(t('cases.gmailSyncedN', { n: r.count || 0 }));
    } catch (e) {
      if (e && e.status === 409) showToast(t('cases.gmailNotConnected'), 'x');
      else showToast((e && e.message) || t('cases.gmailSyncFailed'), 'x');
    }
    setGmailSyncing(false);
  };
  const D = window.APP_DATA;
  const isMobile = useIsMobile();
  // ダッシュボードのカードから来た場合は絞り込みを引き継ぐ（navigate('cases', null, {status, mine})）
  const preset = (route.tab && typeof route.tab === 'object') ? route.tab : {};
  const prefsKey = CASELIST_PREFS_KEY + '_' + currentUser.id + '_' + workspaceId;
  const saved = loadCasePrefs(prefsKey);
  const [q, setQ] = React.useState('');
  const [status, setStatus] = React.useState(preset.status || saved.status || 'all');
  const [owner, setOwner] = React.useState(saved.owner || 'all');
  const [rank, setRankF] = React.useState(saved.rank || 'all');
  const [mine, setMine] = React.useState(preset.mine != null ? !!preset.mine : !!saved.mine);
  // 既定は「商談日（新しい順）」＝直近・これからの商談が上に来る。前回の並び順があればそれを優先
  const [sort, setSort] = React.useState(saved.sort || 'meetingDesc');
  const [mailF, setMailF] = React.useState('all');       // メール返信状態フィルタ（all/in/out/none）
  const [satF, setSatF] = React.useState('all');         // 発注者評価フィルタ（all / good=満足度3-4 / bad=1-2 / none=未評価）
  const [mtgDate, setMtgDate] = React.useState(preset.mtgDate || '');  // 商談日フィルタ（YYYY-MM-DD、空=全期間）。ダッシュボード「本日の商談」からの遷移で当日を受け取る
  const [catF, setCatF] = React.useState(saved.cat || 'all');  // カテゴリフィルタ（'all' or カテゴリ名）
  const [dateFrom,setDateFrom]=React.useState(''), [dateTo,setDateTo]=React.useState('');
  const [viaF, setViaF] = React.useState(saved.via || 'all');  // 経由（取得元）フィルタ（'all' or readycrew/hnavi/gmail/manual）
  const [archF, setArchF] = React.useState('active');  // アーカイブ表示（active=非アーカイブのみ / archived=アーカイブのみ / all=両方）
  const [viewMode, setViewMode] = React.useState(saved.view === 'card' ? 'card' : 'table');  // 一覧の表示形式（テーブル/カード）
  const asCards = isMobile || viewMode === 'card';  // スマホは常にカード、PCはトグルで切替
  const [openForm, setOpenForm] = React.useState(false);
  const [advancedOpen,setAdvancedOpen]=React.useState(false),[moreOpen,setMoreOpen]=React.useState(false);
  const [dragId, setDragId] = React.useState(null);
  const [wonAskDrop, setWonAskDrop] = React.useState(null); // カンバンで成約列へドロップした瞬間の受注理由モーダル（値=caseId）        // カンバンでドラッグ中のカードID
  const [dragOverCol, setDragOverCol] = React.useState(null); // ドラッグオーバー中の状態列
  // 絞り込み・並び順・表示形式が変わるたびに保存（検索文字・ページは除く）
  React.useEffect(() => {
    try { localStorage.setItem(prefsKey, JSON.stringify({ status, owner, rank, mine, sort, cat: catF, via: viaF, view: viewMode })); } catch (_) {}
  }, [prefsKey, status, owner, rank, mine, sort, catF, viaF, viewMode]);
  // 絞り込みが1つでも掛かっているか（クリアボタン表示・件数の「全N件中」表示に使用）
  // ステータスは複数選択。旧保存値（'all' や 'won' 等の文字列）も配列に正規化して互換
  const stArr = Array.isArray(status) ? status : (status && status !== 'all' ? [status] : []);
  const isFiltering = !!(dateFrom || dateTo || q || stArr.length || owner !== 'all' || rank !== 'all' || mine || mailF !== 'all' || satF !== 'all' || mtgDate || catF !== 'all' || viaF !== 'all');
  const clearFilters = () => { setDateFrom(''); setDateTo(''); setQ(''); setStatus('all'); setOwner('all'); setRankF('all'); setMine(false); setMailF('all'); setSatF('all'); setMtgDate(''); setCatF('all'); setViaF('all'); };

  // 保存ビュー：絞り込み＋並び順＋表示形式の組合せに名前を付けて保存・呼び出し（端末ごと localStorage）
  const [views, setViews] = React.useState(() => { try { return JSON.parse(localStorage.getItem('anken_caselist_views')) || []; } catch (_) { return []; } });
  const [viewsOpen, setViewsOpen] = React.useState(false);
  const viewsRef = React.useRef(null);
  React.useEffect(() => {
    const h = (e) => { if (viewsRef.current && !viewsRef.current.contains(e.target)) setViewsOpen(false); };
    document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
  }, []);
  const persistViews = (next) => { setViews(next); try { localStorage.setItem('anken_caselist_views', JSON.stringify(next)); } catch (_) {} };
  const applyView = (v) => { setStatus(v.status || 'all'); setOwner(v.owner || 'all'); setRankF(v.rank || 'all'); setMine(!!v.mine); setSort(v.sort || 'meetingDesc'); setCatF(v.cat || 'all'); setViaF(v.via || 'all'); setMailF(v.mailF || 'all'); setSatF(v.satF || 'all'); setViewMode(v.view || 'table'); setViewsOpen(false); };
  const saveCurrentView = () => { const name = (window.prompt(window.t("label.extra29")) || '').trim(); if (!name) return; persistViews([...views, { id: 'v' + Date.now(), name, status, owner, rank, mine, sort, cat: catF, via: viaF, mailF, satF, view: viewMode }]); setViewsOpen(false); };
  const deleteView = (id) => persistViews(views.filter(v => v.id !== id));

  let rows = cases.filter(c => {
    // 未回答(rcStatus=1)等のリード状態の case は案件一覧に出さない＝アポ管理へ（データに紛れても恒久的に除外）。
    if (rcIsLeadCase(c)) return false;
    // アーカイブ（封存）：既定はアクティブのみ表示。'archived'＝アーカイブのみ、'all'＝両方
    if (archF === 'active' && c.archived) return false;
    if (archF === 'archived' && !c.archived) return false;
      if (stArr.length && !stArr.includes(c.status)) return false;   // ステータスは複数選択（空=すべて）
    // 担当フィルタ：主担当だけでなく副担当も一致とみなす（2026-07-16 ユーザー指定・「自分」トグルと同基準）
    if (owner !== 'all' && c.ownerId !== owner && !(c.subIds || []).includes(owner)) return false;
    if (rank !== 'all' && c.rank !== rank) return false;
    if (mine && c.ownerId !== currentUser.id && !c.subIds.includes(currentUser.id)) return false;
    if (q) {
      const cust = D.customer(c.customerId);
      // 検索対象：案件名・社名・窓口氏名・SourceNo に加え、発注者フィードバック本文も含める
      const hay = (c.title + cust.company + cust.shortName + (cust.contact || '') + c.sourceNo + ((c.ordererReview && c.ordererReview.feedback) || '')).toLowerCase();
      if (!hay.includes(q.toLowerCase())) return false;
    }
    if (mailF !== 'all') {
      const ms = mailStatusOf(emails, c.customerId);
      const k = ms ? ms.key : 'none';
      if (k !== mailF) return false;
    }
    if (satF !== 'all') {
      // 発注者の評価（満足度1〜4）の良し悪し：良い=3-4 / 悪い=1-2 / 未評価=0
      const s = (c.ordererReview && c.ordererReview.satisfaction) || 0;
      const band = s >= 3 ? 'good' : s >= 1 ? 'bad' : 'none';
      if (band !== satF) return false;
    }
    const received=(c.createdAt||'').slice(0,10);
    if ((dateFrom && (!received || received < dateFrom)) || (dateTo && (!received || received > dateTo))) return false;
    if (mtgDate) { const md = (caseMeetingAt(c) || '').slice(0, 10); if (md !== mtgDate) return false; }
    if (catF !== 'all' && !(c.categories || []).includes(catF)) return false;
    if (viaF !== 'all' && caseViaMeta(c).key !== viaF) return false;
    return true;
  });
  // 成約確率：この案件の会議記録のうち最新のものの probability
  const probOf = (c) => {
    const docs = (c.meetingDocs || []).filter(d => d.probability != null).sort((x, y) => (y.datetime || '').localeCompare(x.datetime || ''));
    return docs.length ? docs[0].probability : -1;
  };
  const meetingAtOf = (c) => caseMeetingAt(c) || '';
  rows = rows.sort((a, b) => {
    switch (sort) {
      case 'category': return (a.categories||[]).join('、').localeCompare((b.categories||[]).join('、'),'ja');
      case 'categoryDesc': return (b.categories||[]).join('、').localeCompare((a.categories||[]).join('、'),'ja');
      case 'via': return caseViaMeta(a).label.localeCompare(caseViaMeta(b).label,'ja');
      case 'viaDesc': return caseViaMeta(b).label.localeCompare(caseViaMeta(a).label,'ja');
      case 'meetingAsc': return (meetingAtOf(a) || '9999').localeCompare(meetingAtOf(b) || '9999'); // 商談日が近い順
      case 'meetingDesc': return (meetingAtOf(b) || '').localeCompare(meetingAtOf(a) || ''); // 商談日が新しい順
      case 'meetingCount': return caseMeetingCount(b) - caseMeetingCount(a); // 商談回数が多い順
      case 'created': return (b.createdAt || '').localeCompare(a.createdAt || ''); // 取得（新着）順
      case 'createdAsc': return ((a.createdAt || '9999')).localeCompare(b.createdAt || '9999'); // 取得（古い順）＝古い案件が上。取得日なしは末尾
      case 'due': return (caseMailDue(a) || '9999').localeCompare(caseMailDue(b) || '9999'); // 提案メール期限が近い順
      case 'prob': return probOf(b) - probOf(a); // 成約確率が高い順
      case 'status': return D.STATUS_ORDER.indexOf(a.status) - D.STATUS_ORDER.indexOf(b.status);
      case 'rank': return D.RANK_ORDER.indexOf(a.rank) - D.RANK_ORDER.indexOf(b.rank);
      case 'company': return (D.customer(a.customerId) || {}).company?.localeCompare((D.customer(b.customerId) || {}).company || '', 'ja') || 0; // 会社名（あいうえお）
      case 'owner': return ((D.user(a.ownerId) || {}).short || 'んん').localeCompare((D.user(b.ownerId) || {}).short || 'んん', 'ja'); // 担当
      case 'proposal': return (MAIL_ORDER.indexOf(a.proposalMailStatus || 'none')) - (MAIL_ORDER.indexOf(b.proposalMailStatus || 'none')); // 提案メール提出
      case 'title': return (a.title || '').localeCompare(b.title || '', 'ja'); // 案件名
      case 'via': return caseViaMeta(a).order - caseViaMeta(b).order || (a.updatedAt && b.updatedAt ? b.updatedAt.localeCompare(a.updatedAt) : 0); // 経由（ReadyCrew→発注ナビ→Gmail→手動）

      // ↓ 列ヘッダーの再クリックで使う逆順（全列で昇順↔降順トグル）
      case 'meetingCountAsc': return caseMeetingCount(a) - caseMeetingCount(b); // 商談回数が少ない順
      case 'statusDesc': return D.STATUS_ORDER.indexOf(b.status) - D.STATUS_ORDER.indexOf(a.status);
      case 'rankDesc': return D.RANK_ORDER.indexOf(b.rank) - D.RANK_ORDER.indexOf(a.rank);
      case 'companyDesc': return (D.customer(b.customerId) || {}).company?.localeCompare((D.customer(a.customerId) || {}).company || '', 'ja') || 0;
      case 'ownerDesc': return ((D.user(b.ownerId) || {}).short || 'んん').localeCompare((D.user(a.ownerId) || {}).short || 'んん', 'ja');
      case 'proposalDesc': return (MAIL_ORDER.indexOf(b.proposalMailStatus || 'none')) - (MAIL_ORDER.indexOf(a.proposalMailStatus || 'none'));
      case 'titleDesc': return (b.title || '').localeCompare(a.title || '', 'ja');
      case 'dueDesc': return (caseMailDue(b) || '').localeCompare(caseMailDue(a) || ''); // 提案メール期限が遠い順（空は末尾）
      default: return (b.updatedAt || '').localeCompare(a.updatedAt || ''); // 更新日
    }
  });

  // 各列ヘッダーのクリックで [既定方向, 反対方向] をトグル（全列で昇順↔降順）。再クリックで反転。
  const HSORT = { category:['category','categoryDesc'], via:['via','viaDesc'],
    status: ['status', 'statusDesc'], rank: ['rank', 'rankDesc'], title: ['title', 'titleDesc'],
    company: ['company', 'companyDesc'], owner: ['owner', 'ownerDesc'], meeting: ['meetingDesc', 'meetingAsc'],
    meetingCount: ['meetingCount', 'meetingCountAsc'], proposal: ['proposal', 'proposalDesc'], due: ['due', 'dueDesc'],
  };
  // 現在のソート値が「降順（矢印↓）」か（昇順は↑）。矢印の向き表示用
  const DESC_SORTS = new Set(['categoryDesc','viaDesc','meetingDesc', 'statusDesc', 'rankDesc', 'titleDesc', 'companyDesc', 'ownerDesc', 'meetingCount', 'proposalDesc', 'dueDesc', 'created', 'prob', 'updated']);
  const headerSort = (key) => {
    const pair = HSORT[key]; if (!pair) return;
    setSort(s => s === pair[0] ? pair[1] : pair[0]);
  };
  const SortHeader = ({ label, sortKey }) => {
    const pair = HSORT[sortKey] || [sortKey];
    const active = pair.includes(sort);
    const desc = active ? DESC_SORTS.has(sort) : true;
    return (
      <button type="button" aria-label={label+window.t("extra.cases.sortBy")} onClick={() => headerSort(sortKey)} className="row-hover-plain"
        style={{ border:0,padding:0,background:'transparent',font:'inherit',display: 'inline-flex', alignItems: 'center', gap: 3, cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', color: active ? '#4a5af0' : '#9aa1ab' }}>
        {label}
        <Icon name="chevronDown" size={12} stroke={2.4} style={{ opacity: active ? 1 : 0.35, transform: (active && !desc) ? 'rotate(180deg)' : 'none' }} />
      </button>
    );
  };

  const statusOpts = [{ value: 'all', label: t('cases.all') }, ...D.STATUS_ORDER.map(s => ({ value: s, label: D.STATUS[s].label, dot: D.STATUS[s].color }))];
  const ownerOpts = [{ value: 'all', label: t('cases.all') }, ...D.users.map(u => ({ value: u.id, label: u.short }))];
  // ページネーション（20件/ページ）。ページ番号はURL(?page=N)に反映＝詳細から戻っても同じページに戻る。絞り込み変更時は1ページ目へ
  const PER_PAGE = 20;
  const [page, setPage] = useUrlPage([q, status, owner, rank, mine, sort, mailF, mtgDate, catF, viaF, archF, dateFrom, dateTo]);
  const totalPages = Math.max(1, Math.ceil(rows.length / PER_PAGE));
  const safePage = Math.min(page, totalPages);
  syncUrlPage(safePage);
  const pageRows = rows.slice((safePage - 1) * PER_PAGE, safePage * PER_PAGE);
  // 重複検出：同じ会社名＋同じ商談日 が2件以上＝チャネル重複（ReadyCrew⇔発注ナビ等）の可能性。バッジで警告。
  // 会社名だけだと同社の別案件まで誤検出するため、商談日も一致を条件にして精度を上げる。
  const ncoName = (s) => String(s || '').replace(/株式会社|（株）|\(株\)|有限会社|合同会社|\s+/g, '').trim();
  const dupKeyOf = (c) => { const co = ncoName((D.customer(c.customerId) || {}).company || ''); const mt = (caseMeetingAt(c) || '').slice(0, 16); return (co && mt) ? (co + '|' + mt) : ''; };
  const dupKeySet = (() => {
    const m = {};
    (cases || []).forEach(c => { if (c.archived) return; const k = dupKeyOf(c); if (k) m[k] = (m[k] || 0) + 1; });
    return new Set(Object.keys(m).filter(k => m[k] >= 2));
  })();

  const sortOpts = [
    { value: 'updated', label: t('sort.updated') },
    { value: 'created', label: t('sort.created') },
    { value: 'createdAsc', label: t('sort.createdAsc') },
    { value: 'meetingAsc', label: t('sort.meetingAsc') },
    { value: 'meetingDesc', label: t('sort.meetingDesc') },
    { value: 'due', label: t('sort.due') },
    { value: 'prob', label: t('sort.prob') },
    { value: 'rank', label: t('sort.rank') },
    { value: 'status', label: t('sort.status') },
    { value: 'company', label: t('sort.company') },
    { value: 'meetingCount', label: t('sort.meetingCount') },
    { value: 'owner', get label(){return window.t("label.extra30");} },
    { value: 'title', get label(){return window.t("label.extra31");} },
    { value: 'proposal', get label(){return window.t("label.extra32");} },
    { value: 'via', get label(){return window.t("label.extra33");} },
    { value: 'category', get label(){return window.t("label.extra34");} },
  ];
  // ヘッダークリックで設定される列（担当/案件名/提案メール提出＋各列の逆順）も「並び順」ピルに表示するためのラベル
  const SORT_LABELS = {
    category:t("label.extra34"), categoryDesc:t("label.extra35"), via:t("label.extra33"), viaDesc:t("label.extra36"),
    updated: t('sort.updated'), created: t('sort.created'), createdAsc: t('sort.createdAsc'), meetingAsc: t('sort.meetingAsc'), meetingDesc: t('sort.meetingDesc'),
    due: t('sort.due'), dueDesc: t("label.extra37"), prob: t('sort.prob'),
    rank: t('sort.rank'), rankDesc: t("label.extra38"), status: t('sort.status'), statusDesc: t("label.extra39"),
    company: t('sort.company'), companyDesc: t("label.extra40"), meetingCount: t('sort.meetingCount'), meetingCountAsc: t("label.extra41"),
    owner: t("label.extra30"), ownerDesc: t("label.extra42"), get title(){return window.t("label.extra31");}, titleDesc: t("label.extra43"), proposal: t("label.extra32"), proposalDesc: t("label.extra44"),
  };
  // 現在のソートが下拉に無い（ヘッダーで設定した列）場合も「並び順」ピルに反映されるよう補完＝表頭とピルの不一致を解消
  const sortPillOpts = sortOpts.some(o => o.value === sort) ? sortOpts : sortOpts.concat([{ value: sort, label: SORT_LABELS[sort] || sort }]);
  const rankOpts = [{ value: 'all', label: t('cases.all') }, ...D.RANK_ORDER.map(r => ({ value: r, label: `${r}（${D.RANKS[r].desc}）`, dot: D.RANKS[r].color }))];
  // カテゴリ候補＝プリセット＋実データで使われている値の和集合（自由入力分も絞り込めるように）
  const catOpts = [{ value: 'all', label: t('cases.all') }, ...Array.from(new Set([...(D.CATEGORIES || []), ...cases.flatMap(c => c.categories || [])])).map(cat => ({ value: cat, label: cat }))];
  // 経由（取得元）候補＝実データに在る取得元だけ提示（無い取得元はピルに出さない）
  const viaOpts = [{ value: 'all', label: t('cases.all') }, ...VIA_DEFS.filter(([k]) => cases.some(c => caseViaMeta(c).key === k)).map(([value, label, dot]) => ({ value, label, dot }))];
  const mailOpts = [
    { value: 'all', label: t('cases.all') },
    { value: 'in', label: t('cases.mail.needsReply'), dot: '#dc2626' },
    { value: 'out', label: t('cases.mail.awaitingReply'), dot: '#b45309' },
    { value: 'none', label: t('cases.mail.noHistory'), dot: '#c4c9d0' },
  ];
  const archOpts = [
    { value: 'active', label: t('cases.arch.active') },
    { value: 'archived', label: t('cases.arch.archived') },
    { value: 'all', label: t('cases.arch.all') },
  ];
  // 発注者評価フィルタ（満足度1〜4：良い=3-4 / 悪い=1-2 / 未評価）
  const satOpts = [
    { value: 'all', get label(){return window.t("label.extra45");} },
    { value: 'good', get label(){return window.t("label.extra46");}, dot: '#15803d' },
    { value: 'bad', get label(){return window.t("label.extra47");}, dot: '#dc2626' },
    { value: 'none', get label(){return window.t("apo.notEvaluated");}, dot: '#c4c9d0' },
  ];

  const [openImport, setOpenImport] = React.useState(false);
  const [openHnavi, setOpenHnavi] = React.useState(false);
  // CSVエクスポート：現在の絞り込み結果（rows）をそのまま出力（経営報告・Excel集計用）
  const exportCsv = () => {
    const amount = (c) => (((c.quoteSheet && c.quoteSheet.items) || []).reduce((s, it) => it.type === 'text' ? s : s + (Number(it.qty) || 0) * (Number(it.unitPrice) || 0), 0)) || '';
    const data = rows.map(c => { const cust = D.customer(c.customerId) || {}; const st = (D.STATUS && D.STATUS[c.status]) || {}; const ow = c.ownerId ? (D.user(c.ownerId) || {}).name : '';
      return [c.sourceNo || c.id, c.title, cust.company || '', st.label || c.status, c.rank || '', ow, caseMeetingAt(c) || '', caseDue(c) || '', caseViaMeta(c).label, amount(c), c.createdAt || '', c.updatedAt || '']; });
    csvDownload('cases-' + today() + '.csv', [window.t("integ.source.field.caseNo"), window.t("dz.n.titleSize"), window.t("cases.col.customer"), window.t("cases.status"), window.t("cases.col.rank"), window.t("cases.col.owner"), window.t("apo.row.appointAt"), window.t("cd.due"), window.t("extra.common.source"), window.t("label.extra48"), window.t("label.extra49"), window.t("label.extra50")], data);
    showToast(t('cases.csvDone', { n: rows.length }));
  };
  const right = (
    <div style={{ display: 'flex', gap: isMobile ? 6 : 9, flexWrap: 'wrap', justifyContent: isMobile ? 'flex-start' : 'flex-end' }}>
      {caseDupGroups.length > 0 && can('caseEdit') && <Button variant="default" size={isMobile ? 'sm' : undefined} icon="link" onClick={() => { setMoreOpen(false); setOpenCaseMerge(true); }}>{t('case.merge.btn')} {caseDupGroups.length}</Button>}
      <Button variant="default" size={isMobile ? 'sm' : undefined} icon="download" onClick={exportCsv}>{t('btn.exportCsv')}</Button>
      <Button variant="default" size={isMobile ? 'sm' : undefined} icon="mail" onClick={syncGmailAll} disabled={gmailSyncing}>{gmailSyncing ? t('cases.gmailSyncing') : t('cases.gmailSyncAll')}</Button>
      {can('apoOps') && <Button variant="default" size={isMobile ? 'sm' : undefined} icon="refresh" onClick={() => { setMoreOpen(false); setOpenImport(true); }}>{t('cases.readycrewImport')}</Button>}
      {can('apoOps') && <Button variant="default" size={isMobile ? 'sm' : undefined} icon="refresh" onClick={() => { setMoreOpen(false); setOpenHnavi(true); }}>{t('cases.hnaviImport')}</Button>}
    </div>
  );

  // 空状態（絞り込み時はワンクリックでクリアできる導線を併設）
  const emptyState = (
    <div style={{ padding: '50px 20px', textAlign: 'center', color: '#9aa1ab', fontSize: 13 }}>
      {t('cases.empty')}
      {isFiltering && <div style={{ marginTop: 14 }}><button onClick={clearFilters} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 16px', borderRadius: 8, border: '1px solid #e2e5ea', background: '#fff', color: '#4a5af0', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, fontWeight: 600 }}><Icon name="x" size={13} stroke={2.4} />{window.t("extra.cases.clearFilters")}</button></div>}
    </div>
  );

  // PCのカード表示＝状態別カンバン（Notion風ボード）。スマホは1列カード
  const kanban = viewMode === 'card' && !isMobile;
  // カード1枚の描画（カードグリッド／カンバンで共用）
  const renderCaseCard = (c, draggable) => {
    const cust = D.customer(c.customerId);
    const mt = caseMeetingAt(c);
    const eff = effProposalStatus(c); // 実メール送信と連動した実効ステータス
    const ps = MAIL_STATUSES[eff] || MAIL_STATUSES.none;
    const closed = ['won', 'lost', 'done'].includes(c.status);
    const idle = !closed && caseIdleInfo(c); // 放置（進行中なのに動きなし・未来の商談も無い）＝要フォロー。ダッシュボードと同一判定
    // 初回商談が実施済みになるまで提案メール状態は出さない（商談前の「未送信」を抑止）。送信済み/対象外は維持
    const mailReady = !closed && (caseMeetingCount(c) >= 1 || eff === 'sent' || eff === 'na');
    return (
      <div key={c.id} className="case-row"
        draggable={!!draggable}
        onDragStart={draggable ? (e) => { setDragId(c.id); try { e.dataTransfer.effectAllowed = 'move'; } catch (_) {} } : undefined}
        onDragEnd={draggable ? () => { setDragId(null); setDragOverCol(null); } : undefined}
        onClick={() => navigate('case', c.id)}
        style={{ padding: '13px 15px', border: '1px solid #ecedf0', borderRadius: 12, background: '#fff', boxShadow: '0 1px 2px rgba(20,22,40,.04)', cursor: draggable ? 'grab' : 'pointer', opacity: dragId === c.id ? 0.4 : 1 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
          <div onClick={(e) => e.stopPropagation()}><StatusSelect caseId={c.id} /></div>
          <div onClick={(e) => e.stopPropagation()}><RankSelect caseId={c.id} /></div>
          {idle
            ? <span style={{ marginLeft: 'auto', fontSize: 10.5, fontWeight: 700, color: '#b45309', background: '#fdf0db', padding: '2px 8px', borderRadius: 999, whiteSpace: 'nowrap' }}>{window.t("cust.filter.followup")}{idle.idleDays}{window.t("an.mode.day")}</span>
            : <span style={{ marginLeft: 'auto', fontFamily: '"Roboto Mono", monospace', fontSize: 12.5, color: closed ? '#c4c9d0' : (mt ? '#2b2f38' : '#aab0ba'), whiteSpace: 'nowrap' }}>{closed ? '—' : (mt ? <span><span style={{ fontWeight: 600 }}>{fmtDateY(mt)}</span> {fmtTime(mt)}</span> : t('cases.col.tbd'))}</span>}
        </div>
        <div style={{ fontSize: 14, fontWeight: 600, color: '#1f2430', lineHeight: 1.45, marginBottom: 6 }}>{c.title}</div>
        {workspaceId === 'all' && <div style={{marginBottom:6}}><WorkspaceBadge id={c.workspaceId}/></div>}
        {(c.categories && c.categories.length > 0) && <div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 7 }}>{c.categories.map(cat => <span key={cat} style={{ fontSize: 10.5, fontWeight: 600, color: '#5048c8', background: '#eef0fe', padding: '2px 8px', borderRadius: 999 }}>{cat}</span>)}</div>}
        {/* 経由＋提案/見積の進捗（②）：どこから来て・どこまで進んだかを一目で */}
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center', marginBottom: 7 }}>
          {caseNeedsRcDetailImport(c) && <span title={t('cd.rcDetail.hint')} style={{ fontSize: 10, fontWeight: 700, color: '#b45309', background: '#fdf0db', padding: '1px 7px', borderRadius: 999 }}>{t('cd.rcDetail.badge')}</span>}
          {(() => { const v = caseViaMeta(c); return <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 10, fontWeight: 700, color: v.color, background: v.bg, padding: '1px 7px', borderRadius: 999 }}><Icon name={v.icon} size={9} stroke={2.2} />{v.label}</span>; })()}
          {['preparing', 'review', 'submitted'].includes(c.proposalStatus) && (() => { const p = PROPOSAL_STATUSES[c.proposalStatus]; return <span style={{ fontSize: 10, fontWeight: 700, color: p.color, background: p.soft, padding: '1px 7px', borderRadius: 999 }}>{window.t("extra.common.proposal")}{p.label}</span>; })()}
          {['preparing', 'review', 'submitted'].includes(c.quoteStatus) && (() => { const p = PROPOSAL_STATUSES[c.quoteStatus]; return <span style={{ fontSize: 10, fontWeight: 700, color: p.color, background: p.soft, padding: '1px 7px', borderRadius: 999 }}>{window.t("na.type.quote")}{p.label}</span>; })()}
        </div>
        {/* 担当窓口：氏名＋最高役職（ReadyCrew由来） */}
        {(() => {
          const role = (cust.contactTitle || highestTitle(cust.contactDept) || '').trim();
          if (!cust.contact && !role) return null;
          return (
            <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 7, minWidth: 0 }}>
              <Icon name="user" size={11} stroke={2} style={{ color: '#c4c9d0', flex: '0 0 auto' }} />
              <span style={{ fontSize: 11.5, color: '#8a909b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}>
                {cust.contact || '—'}{role && <span style={{ marginLeft: 4, fontWeight: 400, color: '#9aa1ab' }}>{role}</span>}
              </span>
            </div>
          );
        })()}
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
          <span style={{ fontSize: 12.5, color: '#7b828d', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{cust.shortName || cust.company}</span>
          {mailReady && <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, flex: '0 0 auto', fontSize: 11.5, fontWeight: 700, color: ps.color, background: ps.soft, padding: '2px 9px', borderRadius: 999, whiteSpace: 'nowrap' }}><span style={{ width: 6, height: 6, borderRadius: '50%', background: ps.color }} />{ps.label}</span>}
          <div onClick={(e) => e.stopPropagation()}><QuickAssign caseId={c.id} compact /></div>
        </div>
      </div>
    );
  };

  return (
    <Page title={t('page.cases')}>
      <div className="asm-status-tabs" role="tablist" aria-label={window.t("extra.cases.status")}>
        {[{key:'all',label:t('cases.all')},...D.STATUS_ORDER.map(key=>({key,label:D.STATUS[key].label}))].map(st=><button role="tab" aria-selected={st.key==='all'?(status==='all'||(Array.isArray(status)&&!status.length)):(status===st.key||(Array.isArray(status)&&status.length===1&&status[0]===st.key))} key={st.key} onClick={()=>setStatus(st.key==='all'?'all':[st.key])}><span className="asm-status-symbol" style={{color:D.STATUS[st.key]?.color,background:D.STATUS[st.key]?.soft}}>{({all:"◇",new:"●",working:"◐",negotiating:"◐",won:"✓",lost:"×"})[st.key]||"○"}</span>{st.label}<span className="asm-count">{cases.filter(c=>!rcIsLeadCase(c)&&!c.archived&&(st.key==='all'||c.status===st.key)).length}</span></button>)}
      </div>
      <div className="asm-case-filters">
        <div className="asm-case-primary-filters"><label className="asm-case-search"><Icon name="search" size={16}/><input aria-label={window.t("extra.cases.search")} value={q} onChange={e=>setQ(e.target.value)} placeholder={window.t("extra.cases.searchHint")}/></label>
          <FilterPill label={t('cases.status')} value={status} options={statusOpts} onChange={setStatus} multi/>
          <FilterPill label={t('cases.rank')} value={rank} options={rankOpts} onChange={setRankF}/>
          <FilterPill label={window.t("cd.category")} value={catF} options={catOpts} onChange={setCatF}/>
          <FilterPill label={window.t("extra.common.source")} value={viaF} options={viaOpts} onChange={setViaF}/>
          <FilterPill label={t('cases.owner')} value={owner} options={ownerOpts} onChange={setOwner}/>
        </div>
        <div className="asm-case-secondary-filters"><div className="asm-date-range" title={window.t("extra.cases.receivedPeriod")}><Icon name="calendar" size={14}/><input aria-label={window.t("extra.cases.receivedStart")} type="date" value={dateFrom} onChange={e=>setDateFrom(e.target.value)}/><span>〜</span><input aria-label={window.t("extra.cases.receivedEnd")} type="date" value={dateTo} onChange={e=>setDateTo(e.target.value)}/></div>
          <button className="asm-filter-button" aria-pressed={mtgDate===today()} onClick={()=>setMtgDate(d=>d===today()?'':today())}>{t('cases.todayMeetings')}</button>
          <button className="asm-filter-button" aria-pressed={mine} onClick={()=>setMine(m=>!m)}>{t('cases.mineOnly')}</button>
          <button className="asm-filter-button asm-advanced-toggle" aria-expanded={advancedOpen} onClick={()=>setAdvancedOpen(v=>!v)}><Icon name="filter" size={15}/>{window.t("extra.common.filters")}{isFiltering&&<span className="asm-count">{[q,stArr.length,owner!=='all',rank!=='all',mine,mailF!=='all',satF!=='all',mtgDate,catF!=='all',viaF!=='all',dateFrom||dateTo].filter(Boolean).length}</span>}</button>
          <div className="asm-case-view-actions">{!isMobile&&<div className="asm-view-toggle">{[['table',window.t("label.extra51"),'menu'],['card',window.t("label.extra52"),'dashboard']].map(([v,label,icon])=><button key={v} aria-pressed={viewMode===v} onClick={()=>setViewMode(v)}><Icon name={icon} size={14}/>{label}</button>)}</div>}
            <span className="asm-result-count">{window.t("extra.common.totalPrefix")}{cases.filter(c=>!rcIsLeadCase(c)&&(archF==='all'||(archF==='archived'?c.archived:!c.archived))).length}{window.t("extra.common.ofCount")}<strong>{rows.length}</strong>{window.t("unit.count")}</span>
            {can('caseCreate')&&<Button variant="primary" icon="plus" onClick={()=>setOpenForm(true)}>{t('btn.newCase')}</Button>}
            <button className="asm-case-more" aria-label={window.t("extra.cases.otherActions")} onClick={()=>setMoreOpen(true)}>···</button>
          </div>
        </div>
        {dateFrom&&dateTo&&dateFrom>dateTo&&<div role="alert" className="asm-error">{window.t("extra.cases.dateOrder")}</div>}
        {advancedOpen&&<div className="asm-advanced-filters">
          <FilterPill label={t('cases.sort')} value={sort} options={sortPillOpts} onChange={setSort} icon="filter"/>
          <FilterPill label={t('cases.mail')} value={mailF} options={mailOpts} onChange={setMailF} icon="mail"/>
          <FilterPill label={window.t("extra.common.rating")} value={satF} options={satOpts} onChange={setSatF} icon="spark"/>
          <FilterPill label={t('cases.arch')} value={archF} options={archOpts} onChange={setArchF} icon="inbox"/>
        <div ref={viewsRef} style={{ position: 'relative' }}>
          <button onClick={() => setViewsOpen(o => !o)}
            style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '7px 10px', borderRadius: 8, border: '1px solid ' + (views.length ? '#c9cff8' : '#e2e5ea'), background: '#fff', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, color: '#3b414b', fontWeight: 500, whiteSpace: 'nowrap' }}>
            <Icon name="filter" size={14} stroke={2} style={{ color: '#9aa1ab' }} /><span style={{ color: '#9aa1ab' }}>{window.t("extra.common.view")}</span><Icon name="chevronDown" size={13} stroke={2} style={{ color: '#b4bac3' }} />
          </button>
          {viewsOpen && (
            <div style={{ position: 'absolute', top: 40, left: 0, zIndex: 60, minWidth: 220, maxHeight: 340, overflowY: 'auto', background: '#fff', borderRadius: 10, border: '1px solid #e6e8ec', boxShadow: '0 14px 34px rgba(20,22,40,.16)', padding: 6 }}>
              {views.length === 0 && <div style={{ padding: '8px 9px', fontSize: 12.5, color: '#9aa1ab' }}>{window.t("extra.cases.noViews")}</div>}
              {views.map(v => (
                <div key={v.id} className="row-hover" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 9px', borderRadius: 7 }}>
                  <span onClick={() => applyView(v)} style={{ flex: 1, fontSize: 13, color: '#3b414b', cursor: 'pointer', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{v.name}</span>
                  <button onClick={() => deleteView(v.id)} title={window.t("btn.delete")} style={{ border: 'none', background: 'transparent', padding: 2, cursor: 'pointer', display: 'inline-flex', flex: '0 0 auto' }}><Icon name="x" size={13} stroke={2} style={{ color: '#b4bac3' }} /></button>
                </div>
              ))}
              <div style={{ height: 1, background: '#f0f1f4', margin: '5px 4px' }} />
              <div className="row-hover" onClick={saveCurrentView} style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '8px 9px', borderRadius: 7, cursor: 'pointer', color: '#4a5af0', fontSize: 13, fontWeight: 600 }}>
                <Icon name="plus" size={14} stroke={2.2} />{window.t("extra.cases.saveView")}</div>
            </div>
          )}
        </div>
          <label className="asm-date-range">{window.t("apo.row.appointAt")}<input type="date" aria-label={window.t("cases.filterByMeetingDate")} value={mtgDate} onChange={e=>setMtgDate(e.target.value)}/></label>
          {isFiltering&&<Button size="sm" icon="x" onClick={clearFilters}>{window.t("extra.cases.clearFilters")}</Button>}
        </div>}
      </div>
      {moreOpen&&<Modal open onClose={()=>setMoreOpen(false)} title={window.t("extra.cases.otherActions")} width={580}>{right}</Modal>}

      {/* 表示モード：カンバン(PC×カード)＝状態別ボード ／ カードグリッド(スマホ) ／ テーブル */}
      {kanban ? (
        <div style={{ display: 'flex', gap: 14, overflowX: 'auto', alignItems: 'flex-start', paddingBottom: 6 }}>
          {['new', 'working', 'negotiating', 'won'].map(s => {
            const st = D.STATUS[s];
            // 成約(won)列は今期（当月・成約日ベース）だけ＝過去の成約が積み上がらない。失注/完了はボードから外す（状態変更はカードのプルダウンから・一覧はテーブルで）
            const list = s === 'won'
              ? rows.filter(c => c.status === s && (caseWonYmd(c) || '').slice(0, 7) === today().slice(0, 7))
              : rows.filter(c => c.status === s);
            return (
              <div key={s}
                onDragOver={dragId ? (e) => { e.preventDefault(); if (dragOverCol !== s) setDragOverCol(s); } : undefined}
                onDrop={dragId ? (e) => { e.preventDefault(); const dc = cases.find(x => x.id === dragId); if (dc && dc.status !== s) { if (s === 'won' && !isCaseWonStatus(dc.status)) setWonAskDrop(dragId); else changeStatus(dragId, s); } setDragId(null); setDragOverCol(null); } : undefined}
                style={{ flex: '0 0 306px', width: 306, display: 'flex', flexDirection: 'column', background: dragOverCol === s ? '#eaeafc' : '#f6f7fa', border:'1px solid #e9eaee', borderTop:'3px solid '+st.color, borderRadius: 14, maxHeight: 'calc(100vh - 220px)', outline: dragOverCol === s ? '2px dashed #4a5af0' : '2px solid transparent', transition: 'background .12s' }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 14px 9px', flex: '0 0 auto' }}>
                  <span style={{ width: 9, height: 9, borderRadius: '50%', background: st.color }} />
                  <span style={{ fontSize: 13, fontWeight: 700, color: '#3b414b' }}>{st.label}</span>
                  {s === 'won' && <span style={{ fontSize: 10, fontWeight: 700, color: '#15803d', background: '#e3f5e9', padding: '1px 6px', borderRadius: 999 }}>{window.t("extra.analytics.current")}</span>}
                  <span style={{ fontSize: 12, fontWeight: 600, color: '#9aa1ab' }}>{list.length}</span>
                  {can('caseCreate') && <button onClick={() => setOpenForm({ status: s })} title={st.label + window.t("extra.cases.shortcutNew")} style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, border: 'none', borderRadius: 7, background: '#e7e8ec', color: '#6b7280', cursor: 'pointer' }}><Icon name="plus" size={15} stroke={2.2} /></button>}
                </div>
                <div style={{ overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8, padding: '0 8px 8px' }}>
                  {list.map(c => renderCaseCard(c, true))}
                  {list.length === 0 && <div style={{ padding: '10px 4px 16px', textAlign: 'center', color: '#c4c9d0', fontSize: 12 }}>—</div>}
                </div>
              </div>
            );
          })}
        </div>
      ) : asCards ? (
        <div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 12 }}>
          {pageRows.map(c => renderCaseCard(c))}
          {rows.length === 0 && emptyState}
        </div>
        ) : (
        <div className="asm-case-table">
        <div style={{ overflowX: 'auto' }}>
        <div style={{ ...caseGrid, padding: '11px 18px', borderBottom: '1px solid #f0f1f4',
          fontSize: 11.5, fontWeight: 700, color: '#9aa1ab', letterSpacing: '.02em',
          background: '#fbfbfd' }}>
          <div><SortHeader label={t('cases.col.state')} sortKey="status" /></div>
          <div><SortHeader label={t('cases.col.rank')} sortKey="rank" /></div>
          <div><SortHeader label={t('cases.col.title')} sortKey="title" /></div>
          <div><SortHeader label={t('cases.col.customer')} sortKey="company" /></div>
          <div><SortHeader label={window.t("cd.category")} sortKey="category" /></div>
          <div><SortHeader label={window.t("extra.common.source")} sortKey="via" /></div>
          <div><SortHeader label={t('cases.col.owner')} sortKey="owner" /></div>
          <div><SortHeader label={t('cases.col.meeting')} sortKey="meeting" /></div>
          <div><SortHeader label={t('cases.col.meetings')} sortKey="meetingCount" /></div>
          <div><SortHeader label={window.t("cd.proposalMail")} sortKey="proposal" /></div>
        </div>
        {pageRows.map((c, i) => {
          const cust = D.customer(c.customerId);
          const due = caseMailDue(c); // 提案メール期限（手動 or 商談日+7）
          const du = daysUntil(due);
          const closed = ['won', 'lost', 'done'].includes(c.status); // 結案（成約/失注/完了）は提案メール提出・期限を表示しない（意味が無いため）
          const urgent = du !== null && du <= 3 && !closed;
          const mt = caseMeetingAt(c); // 次回商談 or ReadyCrew 商談日
          const cnt = caseMeetingCount(c); // 商談回数（実施済みのみ）
          const eff = effProposalStatus(c); // 実メール送信(out)と連動した実効ステータス（送信済みなら未送信にしない）
          const ps = MAIL_STATUSES[eff] || MAIL_STATUSES.none;
          // 提案メール欄を出すのは「初回商談が実施済み(cnt>=1)」になってから。商談日が未来/未開催の案件は提案前なので「—」（既に送信済み/対象外の明示状態は維持）
          const mailReady = !closed && (cnt >= 1 || eff === 'sent' || eff === 'na');
          const ms = mailStatusOf(emails, c.customerId); // メール返信状態
          return (
            <div key={c.id} className="case-row" onClick={() => navigate('case', c.id)}
              style={{ ...caseGrid, padding: '13px 18px',
                borderBottom: i === pageRows.length - 1 ? 'none' : '1px solid #f4f5f7', boxShadow: 'inset 4px 0 ' + (urgent ? '#e5484d' : (D.STATUS[c.status]?.color || '#dfe2e8')), cursor: 'pointer' }}>
              <div onClick={(e) => e.stopPropagation()}><StatusSelect caseId={c.id} /></div>
              <div onClick={(e) => e.stopPropagation()}><RankSelect caseId={c.id} /></div>
              <div style={{ minWidth: 0, paddingRight: 12 }}>
                <div title={c.title} style={{fontSize:13.5,fontWeight:700,color:'#2b2f38',whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{c.title}</div>
                <div className="asm-case-metadata"><span className="asm-case-number">{c.sourceNo||c.id}</span>
                  {c.archived && <span style={{ fontSize: 10, fontWeight: 700, color: '#6b727c', background: '#eef0f3', padding: '1px 6px', borderRadius: 999, marginRight: 6 }}>{t('cases.archived')}</span>}
                  {dupKeySet.has(dupKeyOf(c)) && <span title={window.t("extra.cases.duplicateHint")} style={{ fontSize: 10, fontWeight: 700, color: '#b91c1c', background: '#fdecec', padding: '1px 6px', borderRadius: 999, marginRight: 6 }}>{window.t("extra.cases.possibleDuplicate")}</span>}
                  {caseNeedsRcDetailImport(c) && <span title={t('cd.rcDetail.hint')} style={{ fontSize: 10, fontWeight: 700, color: '#b45309', background: '#fdf0db', padding: '1px 6px', borderRadius: 999, marginRight: 6 }}>{t('cd.rcDetail.badge')}</span>}
                  {workspaceId==='all'&&ASM_WORKSPACES.idsOf(c).map(id=><WorkspaceBadge key={id} id={id}/>)}
                  {ms&&<span title={ms.full} style={{fontSize:10,fontWeight:700,color:ms.color,background:ms.bg,padding:'1px 6px',borderRadius:6}}>{ms.label}</span>}
                  <SatBadge value={c.ordererReview&&c.ordererReview.satisfaction} size="xs"/>
                </div>
              </div>
              <div style={{ minWidth: 0 }}>
                <div style={{ fontSize: 12.5, fontWeight: 600, color: '#2b2f38', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={cust.company}>{cust.company||cust.shortName}</div>
                {/* 担当窓口：氏名＋最高役職（ReadyCrew由来）。役職は contactTitle 優先、無ければ contactDept から抽出 */}
                {(() => {
                  const role = (cust.contactTitle || highestTitle(cust.contactDept) || '').trim();
                  if (!cust.contact && !role) return null;
                  return (
                    <div title={(cust.contact || '') + (role ? '（' + role + '）' : '')} style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 3, minWidth: 0 }}>
                      <Icon name="user" size={11} stroke={2} style={{ color: '#c4c9d0', flex: '0 0 auto' }} />
                      <span style={{ fontSize: 11.5, color: '#8a909b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}>
                        {cust.contact || '—'}
                        {role && <span style={{ marginLeft: 4, fontWeight: 400, color: '#9aa1ab' }}>{role}</span>}
                      </span>
                    </div>
                  );
                })()}
              </div>
              {/* カテゴリ（独立列） */}
              <div style={{ minWidth: 0, display: 'flex', flexWrap: 'wrap', gap: 4, alignContent: 'center' }}>
                {(c.categories && c.categories.length) ? <><span className="asm-category" title={c.categories.join('・')}>{c.categories[0]}</span>{c.categories.length>1&&<span className="asm-muted" title={c.categories.slice(1).join('・')}>+{c.categories.length-1}</span>}</> : <span style={{ color: '#c4c9d0', fontSize: 12.5 }}>—</span>}
              </div>
              {/* 経由（取得元）：ReadyCrew / 発注ナビ / Gmail / 手動 */}
              <div style={{ minWidth: 0 }}>
                {(() => { const v = caseViaMeta(c); return (
                  <span title={c.sourceName || v.label} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11.5, fontWeight: 700, color: v.color, background: v.bg, padding: '2px 8px', borderRadius: 6, whiteSpace: 'nowrap', maxWidth: '100%' }}>
                    <span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{v.label}</span>
                  </span>
                ); })()}
              </div>
              <div onClick={(e) => e.stopPropagation()} style={{ minWidth: 0, overflow: 'hidden' }}><QuickAssign caseId={c.id} /></div>
              <div style={{ fontFamily: '"Roboto Mono", monospace', fontSize: 12.5, color: closed ? '#c4c9d0' : (mt ? '#2b2f38' : '#aab0ba'), whiteSpace: 'nowrap', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', lineHeight: 1.35 }}>
                {closed ? '—' : (mt ? <><span style={{ fontWeight: 700 }}>{fmtDate(mt,true)}</span><br /><span style={{ fontSize: 11.5,color:'#9aa1ab' }}>{fmtTime(mt)}</span></> : t('cases.col.tbd'))}
              </div>
              <div style={{ fontSize: 12.5, fontWeight: 600, color: cnt ? '#3b414b' : '#c4c9d0', whiteSpace: 'nowrap' }}>{cnt ? t('cases2.meetingCount', { cnt }) : '—'}</div>
              {/* 提案メール：提出状態＋期限を1列に集約。結案（成約/失注/完了）／初回商談前は意味が無いため「—」 */}
              <div style={{ minWidth: 0 }}>
                {!mailReady ? <span style={{ color: '#c4c9d0', fontSize: 12.5 }}>—</span> : (
                  <div style={{ display: 'flex', flexDirection: 'column', gap: 3, alignItems: 'flex-start' }}>
                    <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 12, fontWeight: 700, color: ps.color, background: ps.soft, padding: '3px 10px', borderRadius: 999, whiteSpace: 'nowrap' }}>
                      <span style={{ width: 6, height: 6, borderRadius: '50%', background: ps.color }} />{ps.label}
                    </span>
                    <span style={{ fontSize: 11.5, fontWeight: 600, color: due ? (urgent ? '#dc2626' : '#8a909b') : '#c4c9d0', display: 'flex', alignItems: 'center', gap: 4, whiteSpace: 'nowrap' }}>
                      {urgent && <span style={{ width: 5, height: 5, borderRadius: '50%', background: '#dc2626' }} />}{window.t("cd.due")}{due ? fmtDateY(due) : '—'}
                    </span>
                  </div>
                )}
              </div>
            </div>
          );
        })}
        {rows.length === 0 && emptyState}
        </div>
        </div>
      )}

      {/* ページャー（20件/ページ）— 前へ / 1 2 3 … N / 次へ */}
      {!kanban && totalPages > 1 && (() => {
        // 先頭・末尾・現在ページ±1 を表示、間は … で省略
        const nums = [];
        for (let i = 1; i <= totalPages; i++) {
          if (i === 1 || i === totalPages || (i >= safePage - 1 && i <= safePage + 1)) nums.push(i);
          else if (nums[nums.length - 1] !== '…') nums.push('…');
        }
        const pill = { minWidth: 34, height: 34, padding: '0 9px', borderRadius: 8, border: '1px solid #e2e5ea', background: '#fff', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, fontWeight: 600, color: '#3b414b', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 4 };
        const navBtn = (dir) => {
          const dis = dir < 0 ? safePage === 1 : safePage === totalPages;
          return (
            <button onClick={() => setPage(p => Math.min(totalPages, Math.max(1, p + dir)))} disabled={dis}
              style={{ ...pill, opacity: dis ? 0.4 : 1, cursor: dis ? 'not-allowed' : 'pointer' }}>
              {dir < 0 && <Icon name="chevronLeft" size={14} stroke={2.2} />}{dir < 0 ? t('btn.prev') : t('btn.next')}{dir > 0 && <Icon name="chevronRight" size={14} stroke={2.2} />}
            </button>
          );
        };
        return (
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 16, flexWrap: 'wrap' }}>
            {navBtn(-1)}
            {nums.map((n, i) => n === '…'
              ? <span key={'e' + i} style={{ minWidth: 22, textAlign: 'center', color: '#b4bac3', fontSize: 13 }}>…</span>
              : <button key={n} onClick={() => setPage(n)}
                  style={{ ...pill, ...(n === safePage ? { background: '#4a5af0', borderColor: '#4a5af0', color: '#fff' } : {}) }}>{n}</button>)}
            {navBtn(1)}
            <span style={{ marginLeft: 8, fontSize: 12, color: '#b4bac3' }}>{t('cases.totalCases', { n: rows.length })}</span>
          </div>
        );
      })()}
      {wonAskDrop && (() => { const dc = cases.find(x => x.id === wonAskDrop) || {}; return <WonReasonModal caseId={wonAskDrop} initialReason={dc.wonReason} initialNote={dc.wonReasonNote} applyStatus="won" onClose={() => setWonAskDrop(null)} />; })()}
      {openForm && <CaseFormModal initialStatus={openForm && openForm.status} onClose={() => setOpenForm(false)} />}
      {openImport && <RCImportModal onClose={() => setOpenImport(false)} />}
      {openHnavi && <HnaviImportModal onClose={() => setOpenHnavi(false)} />}
      {openCaseMerge && <MergeCasesModal onClose={() => setOpenCaseMerge(false)} />}
    </Page>
  );
}

/* ============================================================
   ReadyCrew 半自動取り込み — ブックマークレット生成モーダル
   ReadyCrew はSSOの内側なのでサーバーから直接取得できない。
   代わりにログイン済みブラウザ上で動くスクリプト（ブックマークレット）を
   その場で生成し、案件一覧ページのデータをこのシステムのAPIへ送らせる。
   新規案件は自動作成、既存案件は商談日(appointAtLive)を同期する。
   ============================================================ */
const RC_LIST_URL = 'https://alion.partner.readycrew.cloud/matchings?status=IN_PROGRESS_ALL&page=1';

// 取込ブックマーク/userscript 用：長期有効トークンを取得（30日で切れるセッションとは別寿命。
// 一度ブックマークに焼き込めば原則作り直し不要）。取得失敗時は従来のセッショントークンにフォールバック。
// RCImportModal / HnaviImportModal の両方が使う cases 機能ローカルの hook（単一定義・重複禁止 SDD R2）。
/* useImportToken は ui.jsx（L0）へ移設（integrations の Fireflies ブックマークレットと共用・2026-08-02） */

function buildRcImportScript(apiBase, token) {
  // ブックマークレット本体（ES5・自己完結）。token は現在ログイン中の本人の認証トークン
  // 一覧ページ → 全件サイクル同期（新規作成＋商談日更新）
  // 詳細ページ → その案件の完全取得（相談内容 全文・顧客情報・会議リンク。未登録なら案件＋顧客を新規作成）
  return `(async function(){try{
var API=${JSON.stringify(apiBase)},H={'Content-Type':'application/json',Authorization:'Bearer '+${JSON.stringify(token)}};
if(!/(^|\\.)readycrew\\.cloud$/i.test(location.hostname)){alert('ReadyCrew（readycrew.cloud）のページで実行してください');return}
/* Apolloクライアントは任意（隔離環境=USER_SCRIPT worldでは見えない・2026-07-16）。取込本体は
   GraphQL直叩き＋DOM抽出でApollo非依存なので、無くても中断しない。あればキャッシュから発注者回答を補完。 */
var ap=window.__APOLLO_CLIENT__||null;
var bs=await fetch(API+'/api/bootstrap',{headers:H});
if(bs.status===401){alert('認証の有効期限が切れています。案件管理システムの「ReadyCrew取込」からブックマークレットを作り直してください');return}
var data=await bs.json();var byId={};data.cases.forEach(function(k){byId[k.id]=k});var custById={};(data.customers||[]).forEach(function(cu){custById[cu.id]=cu;});
var f=function(d){var x=new Date(d);var p=function(n){return ('0'+n).slice(-2)};return x.getFullYear()+'-'+p(x.getMonth()+1)+'-'+p(x.getDate())+'T'+p(x.getHours())+':'+p(x.getMinutes())};
var c=(ap&&ap.cache&&ap.cache.extract)?ap.cache.extract():{};
/* 発注者の回答（MatchingOperation）を Apollo キャッシュから抽出（Apolloが無い隔離環境では空＝この補完はスキップ） */
/* 商談報告書PDF（2026-08-23）：RCの「報告書をPDFダウンロード」と同じGraphQL(GetMatchingReportPdf)で署名URLを取り、
   サーバが即保存して案件の資料（その他）に添付。未添付の案件だけ。報告書が無い商談は rcReportChecked を付けて7日間は再確認しない */
var rcJwtOf=function(){try{for(var si=0;si<sessionStorage.length;si++){var sv=sessionStorage.getItem(sessionStorage.key(si))||'';if(sv.indexOf('eyJ')===0&&sv.length>200)return sv;}}catch(e){}return null;};
var captureReport=async function(mid,caseId,ex){
 var jwt0=rcJwtOf();if(!jwt0)return false;
 var exA=(ex&&ex.attachments)||[];if(exA.some(function(a){return a&&a.rcReport;}))return false;
 var chk=String((ex&&ex.rcReportChecked)||'');if(chk&&(Date.now()-new Date(chk).getTime())<7*86400000)return false;
 try{
  var pr=await fetch('/api/graphql',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+jwt0,'apollo-require-preflight':'true'},body:JSON.stringify({operationName:'GetMatchingReportPdf',query:'query GetMatchingReportPdf($matchingId: Int!){getMatchingReportPdf(matchingId:$matchingId){url}}',variables:{matchingId:Number(mid)}})});
  var pj=await pr.json().catch(function(){return {};});
  var purl=pj&&pj.data&&pj.data.getMatchingReportPdf&&pj.data.getMatchingReportPdf.url;
  if(!purl){try{await fetch(API+'/api/cases/'+caseId,{method:'PATCH',headers:H,body:JSON.stringify({rcReportChecked:f(Date.now())})});}catch(_r){}return false;}
  var cap=await fetch(API+'/api/rc-report-capture',{method:'POST',headers:H,body:JSON.stringify({caseId:caseId,url:purl,name:'ReadyCrew商談報告書_RDC-'+mid+'.pdf'})});
  return cap.ok;
 }catch(e){return false;}
};
var ordReview=function(mid){
 var M=c['Matching:'+mid];var ops=(M&&M.matchingOperations)||[];var best=null;
 ops.forEach(function(rf){var o=c[rf&&rf.__ref];if(o&&(o.ordererSatisfaction||o.publicOrdererFeedbacks)){if(!best||(o.operationAt||'')>(best.operationAt||''))best=o;}});
 if(!best)return null;
 return {satisfaction:best.ordererSatisfaction||null,feedback:best.publicOrdererFeedbacks||'',proposalDate:best.nextActionDate?f(best.nextActionDate).slice(0,10):null};
};
/* 一覧用：matchingOperations は一覧キャッシュに無いので apollo.query で取得（認証ヘッダー自動付与・gql無しで手組みAST） */
var fld=function(n,sel){var o={kind:'Field',name:{kind:'Name',value:n},arguments:[],directives:[]};if(sel)o.selectionSet={kind:'SelectionSet',selections:sel};return o;};
var fetchReview=async function(mid){
 if(!ap)return null; // Apollo無し（隔離環境）＝ap.queryが使えないので即null（4回リトライの空回りを防ぐ）
 var doc={kind:'Document',definitions:[{kind:'OperationDefinition',operation:'query',variableDefinitions:[],directives:[],selectionSet:{kind:'SelectionSet',selections:[
  {kind:'Field',name:{kind:'Name',value:'matching'},directives:[],arguments:[{kind:'Argument',name:{kind:'Name',value:'id'},value:{kind:'IntValue',value:String(mid)}}],selectionSet:{kind:'SelectionSet',selections:[
   fld('id'),fld('matchingOperations',[fld('ordererSatisfaction'),fld('publicOrdererFeedbacks'),fld('nextActionDate'),fld('operationAt')])
  ]}}
 ]}}]};
 for(var attempt=0;attempt<4;attempt++){
  try{
   var res=await ap.query({query:doc,fetchPolicy:'network-only'});
   var ops=res&&res.data&&res.data.matching&&res.data.matching.matchingOperations;
   if(!ops||!ops.length)return null;
   var best=null;ops.forEach(function(o){if(o&&(o.ordererSatisfaction||o.publicOrdererFeedbacks)){if(!best||(o.operationAt||'')>(best.operationAt||''))best=o;}});
   if(!best)return null;
   return {satisfaction:best.ordererSatisfaction||null,feedback:best.publicOrdererFeedbacks||'',proposalDate:best.nextActionDate?f(best.nextActionDate).slice(0,10):null};
  }catch(e){ if(attempt<3) await new Promise(function(r){setTimeout(r,1500);}); }
 }
 return null;
};
/* CRM振り分け：未回答・リード（まだ商談化していない）はアポ管理に留め、それ以外は案件管理へ案件化する。
   商談日(appointAt)が未定でも未回答以外なら案件化する。※ src/apo.jsx の RC_LEAD_STATUSES と同義（ブックマークレット内＝自己完結のため別定義）。 */
var LEADST={'':1,'1':1,UNANSWERED:1,NOT_ANSWERED:1,NEW:1,LEAD:1,LEAD_ALL:1};
var DEALST={'2':1,'3':1};
var isLeadSt=function(s,ap){s=String(s==null?'':s);if(LEADST[s])return true;if(DEALST[s])return false;return !ap;};
var looksUrl=function(v){return /^https?:\\/\\//.test(v)||/[\\w-]+\\.[\\w-]{2,}/.test(v);};
var newCase=function(m,extra){
 var ord=(m.matchingOrders&&m.matchingOrders[0]&&m.matchingOrders[0].order)||{};
 var cat=ord.orderCategory?(ord.orderCategory.categoryName+'/'+ord.orderCategory.subCategoryName):'';
 var desc=ord.catchCopy||(m.ordererDeal&&m.ordererDeal.title)||'';
 var comp=(m.ordererDeal&&m.ordererDeal.company&&m.ordererDeal.company.name)||'（社名不明）';
 var appoint=m.appointAt?f(m.appointAt):null;
 return {cid:'c'+m.id,comp:comp,cat:cat,desc:desc,appoint:appoint,
  customer:(function(){var co2=(m.ordererDeal&&m.ordererDeal.company)||{};
   /* 所在地・従業員数・URL・会社概要はReadyCrewが返しているのに空で作っていた（2026-08-20修正） */
   return {id:'c'+m.id,company:comp,shortName:comp.replace(/^株式会社/,'').replace(/株式会社$/,'').slice(0,5),contact:'',contactDept:'',tel:'',email:'',
    address:(co2.prefecture?co2.prefecture+(co2.station?'（最寄：'+co2.station+'）':''):''),
    industry:cat.split('/')[0]||'',
    employees:(co2.employeeCount!=null&&String(co2.employeeCount)!==''?String(co2.employeeCount)+'名':''),
    url:co2.url||'',
    note:'ReadyCrew 取込みで自動作成'+(co2.companyOverview?'／【会社概要】'+String(co2.companyOverview).slice(0,400):'')+(co2.salesAmount?'／売上高:'+co2.salesAmount+'円':'')+(co2.capitalAmount?'／資本金:'+co2.capitalAmount+'円':'')};})(),
  kase:Object.assign({id:'k'+m.id,sourceNo:'RDC-'+m.id,title:comp+'：'+desc.slice(0,22),status:'new',customerId:'c'+m.id,ownerId:null,subIds:[],rank:'未',due:null,nextMeeting:appoint,appointAt:appoint,appointAtLive:appoint,source:'scrape',rcStatus:(m.status||''),sourceUrl:'https://alion.partner.readycrew.cloud/matchings/'+m.id,createdAt:f(m.createdAt||Date.now()),updatedAt:f(Date.now()),body:(ord.matchingMemo||('【'+cat+'】'+desc)),note:'ReadyCrew ブックマークレット取込み',meetingLinks:[],originalTitle:comp+' '+cat,sourceName:'ReadyCrew（レディクル）'},extra||{})};
};

/* ===== 詳細ページ：この案件を完全取得 ===== */
var mm=location.pathname.match(/matchings\\/(\\d+)/);
if(mm){
 var mid=mm[1];var id='k'+mid;
 /* ★memoは「この商談(mid)のOrder」から取る。以前は Object.keys(c).filter('Order:')[0] で
    キャッシュ内の"最初のOrder"を無条件採用し、別案件の相談原文を本文に書き込む重大バグだった（2026-07-07修正）。
    正しくは Matching → matchingOrders[0].order(__ref) → その Order の matchingMemo。 */
 var moArr=(c['Matching:'+mid]||{}).matchingOrders||[];
 var ordRef=(moArr[0]&&moArr[0].order&&moArr[0].order.__ref)||'';
 var memo=(ordRef&&c[ordRef]&&c[ordRef].matchingMemo)||'';
 var t=document.body.innerText;
 /* 値が空欄の項目は次の行＝次のラベル（「役職」「出身地」等）を値として拾ってしまい、「役職 専務取締役」のような
    汚れた値が顧客に入っていた（2026-08-22修正）。ラベル名そのもの・'-' は空として扱う */
 var LABELS=['法人名','URL','業界','設立','資本金','売上高','決算月','社員数','都道府県','最寄駅','法人概要','名前','メールアドレス','電話番号','性別','担当範囲','部署名','役職','出身地','人柄'];
 var pick=function(label){var x=t.match(new RegExp(label+'\\\\n+([^\\\\n]+)'));var v=x?x[1].trim():'';return (LABELS.indexOf(v)>=0||v==='-'||v==='—'||v==='ー')?'':v;};
 /* 公式サイトURL：発注企業情報(Apolloキャッシュ company.url)を最優先。無ければ「行頭がURLだけ」の行を拾う＝参加者用URL等の会議リンクは拾わない */
 var rcCo=((c['Matching:'+mid]||{}).ordererDeal||{}).company||{};
 var pickUrl=function(){var u=(rcCo.url||'').trim();if(looksUrl(u))return u;var x=t.match(/(?:^|\\n)[ \\t]*URL[ \\t]*\\n+([^\\n]+)/);var v=x?x[1].trim():'';return looksUrl(v)?v:'';};
 /* 複数行ブロック取得（法人概要・人柄メモ等）：ラベル行の次から、次の既知ラベルまでを丸ごと取る */
 var SECT=['法人名','URL','業界','設立','資本金','売上高','決算月','社員数','都道府県','最寄駅','法人概要','名前','メールアドレス','電話番号','性別','担当範囲','部署名','役職','出身地','人柄'];
 var pickBlock=function(label){var i=t.indexOf(label);if(i<0)return '';var nl=t.indexOf('\\n',i);if(nl<0)return '';var after=t.slice(nl+1);var end=after.length;for(var si=0;si<SECT.length;si++){if(label.indexOf(SECT[si])===0)continue;var j=after.indexOf('\\n'+SECT[si]);if(j>=0&&j<end)end=j;}return after.slice(0,end).trim().slice(0,2000);};
 var pref=pick('都道府県'),sta=pick('最寄駅');
 var cust={company:pick('法人名'),url:pickUrl(),industry:(pick('業界').split('/')[0]||''),employees:pick('社員数'),
  founded:pick('設立'),capital:pick('資本金'),sales:pick('売上高'),fiscalMonth:pick('決算月'),companyOverview:pickBlock('法人概要'),
  address:pref+(sta&&sta!=='-'?'（最寄：'+sta+'）':''),contact:pick('名前'),email:pick('メールアドレス'),tel:pick('電話番号'),
  contactDept:(pick('部署名')+' '+pick('役職')).trim(),contactTitle:pick('役職'),contactGender:pick('性別'),contactRole:pickBlock('担当範囲'),contactNote:pickBlock('人柄')};
 var links=[];var seenU={};
 var isMeetU=function(u){return /zoom\\.us\\/|meet\\.google|teams\\.microsoft|meeting\\.readycrew\\.jp/.test(u);};
 var meetLabel=function(u){return /meeting\\.readycrew\\.jp/.test(u)?'担当者用URL（ホスト）':'参加者用URL';};
 var addLink=function(u){if(!u||!isMeetU(u)||seenU[u])return;seenU[u]=1;links.push({id:'ml'+Date.now()+links.length,label:meetLabel(u),url:u});};
 [].forEach.call(document.querySelectorAll('a[href]'),function(a){addLink(a.href);});
 if(memo){var mre=/https?:\\/\\/[^\\s<>"'）)\\]]+/g,mm;while((mm=mre.exec(memo))){addLink(mm[0].replace(/[.,、。]+$/,''));}}
 var bodyTxt=document.body.innerText||'';var tre=/https?:\\/\\/[^\\s<>"'）)\\]]+/g,tm;while((tm=tre.exec(bodyTxt))){addLink(tm[0].replace(/[.,、。]+$/,''));}
 var ex=byId[id];
 if(!ex){
  var mObj=c['Matching:'+mid];
  if(!mObj){alert('ページのデータが読み切れていません。少し待ってから再実行してください');return}
  if(mObj.status&&isLeadSt(mObj.status,mObj.appointAt)){alert('このリードはまだ商談化していないためアポ管理で管理します。商談化（商談予定/商談済 or 商談日あり）になれば案件として取り込まれます');return}
  var nc=newCase(mObj,{body:memo||undefined,meetingLinks:links,ordererReview:ordReview(mid)||undefined});
  for(var ck in cust){if(cust[ck])nc.customer[ck]=cust[ck];}
  await fetch(API+'/api/customers',{method:'POST',headers:H,body:JSON.stringify(nc.customer)});
  await fetch(API+'/api/cases',{method:'POST',headers:H,body:JSON.stringify(nc.kase)});
  var rep1=await captureReport(mid,id,null);
  alert('新規案件として完全取得しました：'+nc.comp+'\\n（相談内容 '+memo.length+'字・顧客情報・会議リンク '+links.length+'件）');
 }else{
  var cp={};for(var ck2 in cust){if(cust[ck2])cp[ck2]=cust[ck2];}
  await fetch(API+'/api/customers/'+ex.customerId,{method:'PATCH',headers:H,body:JSON.stringify(cp)});
  var kp={};if(memo)kp.body=memo;if(links.length)kp.meetingLinks=links;var orev=ordReview(mid);if(orev)kp.ordererReview=orev;
  if(Object.keys(kp).length)await fetch(API+'/api/cases/'+id,{method:'PATCH',headers:H,body:JSON.stringify(kp)});
  var rep2=await captureReport(mid,id,ex);
  alert('案件の詳細を完全取得しました：'+(cust.company||id)+'\\n（相談内容 '+memo.length+'字・顧客情報を更新・会議リンク '+links.length+'件）');
 }
 return;
}

/* ===== 一覧ページ ===== */
var rcStatus=(location.search.match(/status=([^&]+)/)||[])[1]||'';
var compOf=function(m){return (m.ordererDeal&&m.ordererDeal.company&&m.ordererDeal.company.name)||(m.company&&m.company.name)||(m.ordererCompany&&m.ordererCompany.name)||'（社名不明）';};
var catOf=function(m){var o=(m.matchingOrders&&m.matchingOrders[0]&&m.matchingOrders[0].order)||{};return o.orderCategory?(o.orderCategory.categoryName+'/'+o.orderCategory.subCategoryName):'';};
var descOf=function(m){var o=(m.matchingOrders&&m.matchingOrders[0]&&m.matchingOrders[0].order)||{};return o.catchCopy||(m.ordererDeal&&m.ordererDeal.title)||'';};
var coOf=function(m){return (m.ordererDeal&&m.ordererDeal.company)||{};};
var titleOf=function(m){return (m.ordererDeal&&m.ordererDeal.title)||'';};
var detailOf=function(m){var o=(m.matchingOrders&&m.matchingOrders[0]&&m.matchingOrders[0].order)||{};return o.matchingMemo||'';};
/* このステータスの全リードを ReadyCrew GraphQL から直接ページング取得（表示中の20件に限定されない＝全件）。
   認証は ReadyCrew 自身のアクセストークン（sessionStorage の JWT）を走査して Bearer に使う。
   取得不可（JWT無し・エラー）の時は Apollo キャッシュ＝現在ページのみにフォールバック。 */
var fetchSrc='cache',fetchErr='';
var allM=[];
var rcJwt=null;try{for(var si=0;si<sessionStorage.length;si++){var sv=sessionStorage.getItem(sessionStorage.key(si))||'';if(sv.indexOf('eyJ')===0&&sv.length>200){rcJwt=sv;break;}}}catch(e){}
if(rcJwt){
 try{
  var GQ='query getMatchings($page:Int,$per:Int,$filter:MatchingFilterArgs!){matchings(per:$per,page:$page,filter:$filter){items{id status statusForClient clientViewed appointAt createdAt matchingAt updatedAt isFavorite ordererDeal{title address directRemarks company{name prefecture station companyOverview employeeCount capitalAmount salesAmount url foundedDate accountClosingMonth industrialClassId industrialSubClassId industrialFurtherSubClassId}} matchingOrders{order{name catchCopy matchingMemo orderCategory{categoryName subCategoryName}}}}}}';
  /* 取込は複数のステータスフィルタを走査して合算する（2026-07-16 修正）。
     従来の IN_PROGRESS_ALL 固定では、RC側の新バケット（APPOINTED=商談予定・RESCHEDULING=リスケ中・
     SCHEDULING=日程調整中 等）の案件が返らず、商談予定7件がリードにも案件にも入らない取り漏れが起きた
     （センコーアドバンス等）。一覧サイドバーの全アクティブバケットを走査（終了系 DONE_ALL/CLOSED_ALL は
     従来どおり対象外）。未知のフィルタ名はGraphQLエラーになるだけなので黙ってスキップ＝バケット増減に耐える。 */
  var FILTERS=['IN_PROGRESS_ALL','LEAD_ALL','APPOINTED','RESCHEDULING','SCHEDULING','IN_PROGRESS','PROPOSED','UNREAD','NOT_ANSWERED'];
  var seenM={};
  for(var fi=0;fi<FILTERS.length;fi++){
   try{
    var flt={categoryName:'',companyName:'',isFavorite:false,sortDirection:'desc',sortKey:'matchingAt',status:FILTERS[fi]};
    var per=50,pg=1,gd=0;
    while(gd++<40){
     var gr=await fetch('/api/graphql',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+rcJwt,'apollo-require-preflight':'true'},body:JSON.stringify({operationName:'getMatchings',query:GQ,variables:{page:pg,per:per,filter:flt}})});
     var gj=await gr.json();
     if(gj&&gj.errors){if(fi===0)fetchErr=(gj.errors[0]&&gj.errors[0].message)||'GraphQL error';break;}
     var its=(gj&&gj.data&&gj.data.matchings&&gj.data.matchings.items)||[];
     its.forEach(function(m){if(m&&m.id&&!seenM[m.id]){seenM[m.id]=1;allM.push(m);}});
     if(its.length<per)break;
     pg++;
    }
   }catch(e){if(fi===0)fetchErr=(e&&e.message)||'fetch error';}
  }
  if(allM.length)fetchSrc='graphql';
 }catch(e){fetchErr=(e&&e.message)||'fetch error';allM=[];}
}
if(!allM.length){allM=Object.keys(c).filter(function(k){return k.indexOf('Matching:')===0}).map(function(k){return c[k]}).filter(function(m){return m&&m.id});}
/* ============================================================================
   獲客管理（アポ管理）＝ReadyCrew 全件台帳（2026-09-01）
   従来は「未回答（リード）だけ」を取込んでいたため、受付終了/見送り/先方不可/キャンセル/受注/失注
   （＝RCの終了系バケット・実測1,092件）が獲客管理に一切入らなかった。開封済み・未開封を問わず
   全マッチングを台帳として保存する。
   ★重要：ここで集めた ledgerM は「獲客管理への保存」だけに使う。案件管理（cases）の振り分けに使う
     allM は従来のアクティブバケットのままにしてある＝終了系が案件一覧へ流れ込む事故を構造的に防ぐ。
   ============================================================================ */
var ledgerM=allM.slice(),ledgerSeen={},ledgerErr='';allM.forEach(function(m){ledgerSeen[m.id]=1;});
var icMap={};
if(rcJwt){
 var LFIL=['DONE_ALL','CLOSED_ALL']; // 終了系。アクティブ分は上の FILTERS で取得済み
 for(var lfi=0;lfi<LFIL.length;lfi++){
  try{
   var lpg=1,lgd=0;
   while(lgd++<40){
    var lgr=await fetch('/api/graphql',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+rcJwt,'apollo-require-preflight':'true'},body:JSON.stringify({operationName:'getMatchings',query:GQ,variables:{page:lpg,per:50,filter:{categoryName:'',companyName:'',isFavorite:false,sortDirection:'desc',sortKey:'matchingAt',status:LFIL[lfi]}}})});
    var lgj=await lgr.json();
    if(lgj&&lgj.errors){ledgerErr=(lgj.errors[0]&&lgj.errors[0].message)||'GraphQL error';break;}
    var lits=(lgj&&lgj.data&&lgj.data.matchings&&lgj.data.matchings.items)||[];
    lits.forEach(function(m){if(m&&m.id&&!ledgerSeen[m.id]){ledgerSeen[m.id]=1;ledgerM.push(m);}});
    if(lits.length<50)break;
    lpg++;
   }
  }catch(e){ledgerErr=(e&&e.message)||'fetch error';}
 }
 /* 業界マスタ（大/中/小分類・681件）。company は industrialClassId しか返さないので名前へ解決する */
 try{
  var icr=await fetch('/api/graphql',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+rcJwt,'apollo-require-preflight':'true'},body:JSON.stringify({query:'query{industrialClasses{id name}}'})});
  var icj=await icr.json();((icj&&icj.data&&icj.data.industrialClasses)||[]).forEach(function(x){icMap[x.id]=x.name;});
 }catch(e){}
}
var icName=function(id){return (id!=null&&icMap[id])?icMap[id]:'';};
var leadRow=function(m){
 var co=coOf(m),od=m.ordererDeal||{},or=((m.matchingOrders&&m.matchingOrders[0]&&m.matchingOrders[0].order)||{});
 var fd=co.foundedDate?String(co.foundedDate).slice(0,10):'';
 return {matchingId:String(m.id),company:compOf(m),title:titleOf(m),category:catOf(m),desc:descOf(m),detail:detailOf(m),
  prefecture:co.prefecture||'',station:co.station||'',companyOverview:co.companyOverview||'',
  employeeCount:co.employeeCount,salesAmount:co.salesAmount,capitalAmount:co.capitalAmount,companyUrl:co.url||'',
  rcStatus:(m.status||''),rcStatusForClient:(m.statusForClient==null?'':String(m.statusForClient)),
  viewed:(typeof m.clientViewed==='boolean'?m.clientViewed:undefined),
  industry:icName(co.industrialClassId),industrySub:icName(co.industrialSubClassId),industryDetail:icName(co.industrialFurtherSubClassId),
  foundedOn:fd,foundedYear:(fd?Number(fd.slice(0,4)):null),fiscalMonth:co.accountClosingMonth||'',
  address:od.address||'',directRemarks:od.directRemarks||'',orderName:or.name||'',
  matchingAt:m.matchingAt?f(m.matchingAt):null,rcUpdatedAt:m.updatedAt?f(m.updatedAt):null,
  isFavorite:(typeof m.isFavorite==='boolean'?m.isFavorite:undefined),
  appointAt:m.appointAt?f(m.appointAt):null,registeredAt:m.createdAt?f(m.createdAt):null,
  sourceUrl:'https://alion.partner.readycrew.cloud/matchings/'+m.id};
};
var leadSaved=0,leadErr='';
if(ledgerM.length){
 var leads=ledgerM.map(leadRow);
 /* サーバは1回1500件までなので分割送信（全件＝実測1,346件） */
 try{
  for(var lb=0;lb<leads.length;lb+=400){
   var lr=await fetch(API+'/api/import/leads',{method:'POST',headers:H,body:JSON.stringify({leads:leads.slice(lb,lb+400)})});
   var lj=await lr.json().catch(function(){return {};});
   if(lr.ok)leadSaved+=(lj.count||0);else{leadErr=(lj&&lj.error)||('HTTP '+lr.status);break;}
  }
 }catch(e){leadErr=(e&&e.message)||'通信エラー';}
}
/* 発注企業の担当者（ordererDealEmployees）：一覧APIでは常に空で返るため matching(id:) の個別クエリが要る。
   全件を毎回叩くと重いので「まだ担当者を取っていないリードだけ・1回120件まで」に絞る＝数回の実行で全件そろう。 */
var contactSaved=0;
if(rcJwt&&ledgerM.length){
 try{
  var haveC={};(data.rcLeads||[]).forEach(function(l){if(l&&l.matchingId&&l.contactsAt)haveC[String(l.matchingId)]=1;});
  var need=ledgerM.filter(function(m){return !haveC[String(m.id)];}).slice(0,120);
  var QD='query($id:Int!){matching(id:$id){id ordererDeal{ordererDealEmployees{employee{id name email tel mobile gender title department{name} employeeRoleTags{name} birthplace prefecture publicMemo}}}}}';
  var cbuf=[];
  for(var ci=0;ci<need.length;ci++){
   try{
    var cr=await fetch('/api/graphql',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+rcJwt,'apollo-require-preflight':'true'},body:JSON.stringify({query:QD,variables:{id:Number(need[ci].id)}})});
    var cj=await cr.json();
    var eds=((cj&&cj.data&&cj.data.matching&&cj.data.matching.ordererDeal&&cj.data.matching.ordererDeal.ordererDealEmployees)||[]).map(function(x){return x&&x.employee;}).filter(Boolean);
    if(!eds.length)continue;
    cbuf.push({matchingId:String(need[ci].id),contacts:eds.map(function(e){return {name:e.name||'',title:e.title||'',department:(e.department&&e.department.name)||'',email:e.email||'',tel:e.tel||'',mobile:e.mobile||'',roleTags:(e.employeeRoleTags||[]).map(function(r){return r&&r.name;}).filter(Boolean),prefecture:e.prefecture||'',birthplace:e.birthplace||'',memo:e.publicMemo||''};})});
   }catch(_c){}
  }
  for(var cb=0;cb<cbuf.length;cb+=200){
   var cres=await fetch(API+'/api/import/leads',{method:'POST',headers:H,body:JSON.stringify({leads:cbuf.slice(cb,cb+200)})});
   if(cres.ok)contactSaved+=Math.min(200,cbuf.length-cb);
  }
 }catch(e){}
}
/* 既存案件の rcStatus を ReadyCrew の現値へ常に同期＝状態変化(商談化⇄未回答)を案件一覧/アポ管理の振り分けへ即反映。
   これにより「商談予定→未回答へ逆行」した案件も次回取込で案件一覧から外れアポ管理へ戻る（漏れ恒久防止）。空ステータスは触らない。 */
var rcSync=0;for(var li=0;li<allM.length;li++){var lm=allM[li];if(!lm.status)continue;var lex=byId['k'+lm.id];if(lex&&String(lex.rcStatus||'')!==String(lm.status)){try{await fetch(API+'/api/cases/'+('k'+lm.id),{method:'PATCH',headers:H,body:JSON.stringify({rcStatus:String(lm.status)})});rcSync++;}catch(_e){}}}
/* 案件化＝「未回答（リード）以外」のステータスのもの。商談日(appointAt)が未定でも案件管理に作成する
   （未回答だけアポ管理に留め、それ以外は案件管理へ＝CRMの振り分けルール。案件管理とアポ管理は排他）。 */
var items=allM.filter(function(m){return m.ordererDeal&&m.ordererDeal.company&&m.status&&!isLeadSt(m.status,m.appointAt);});
var added=[],addedIds=[],synced=0,revN=0,urlFix=0,bodyFix=0,failN=0,skipN=0,failMsg='',skipMsg='',repN=0;
for(var i=0;i<items.length;i++){var m=items[i];var id2='k'+m.id;
 var ex2=byId[id2];
 if(ex2){var ap2=m.appointAt?f(m.appointAt):null;
  if(ap2&&ex2.appointAt!==ap2&&ex2.appointAtLive!==ap2){await fetch(API+'/api/cases/'+id2,{method:'PATCH',headers:H,body:JSON.stringify({appointAtLive:ap2})});synced++;}
  /* ★本文修復＋全文補完（この商談の正しいmemo＝detailOf(m)=matchingOrders[0].order.matchingMemo へ）：
     ①誤爆修復＝以前のmemoバグで入った別案件の相談原文（長文かつ相談原文体）を正しいmemoへ直す。
     ②ティーザー補完＝一覧取込が書く「【cat】一行」や空本文を相談原文の全文へ格上げ（2026-07-08 富士ピー・エス
       「案件内容が空」対応）。自動生成形の本文だけが対象＝手書きの本文（形が違う）は触らない。 */
  var correctMemo=(detailOf(m)||'').trim();var curBody=String(ex2.body||'').trim();
  var autoTeaser=!curBody||(curBody.length<200&&/^【[^】]{0,60}】/.test(curBody));
  var contam=curBody.length>150&&/【|ご相談|お探し/.test(curBody);
  if(correctMemo&&curBody!==correctMemo&&(autoTeaser||contam)){try{await fetch(API+'/api/cases/'+id2,{method:'PATCH',headers:H,body:JSON.stringify({body:correctMemo})});bodyFix++;}catch(_b){}}
 }else{
  /* 作成POSTの結果を必ず検分する（2026-07-16）：HTTP失敗もサーバ振り分けの skipped も
     以前は無言＝「取込は成功に見えるのに案件が増えない」原因の特定に半日かかった。件数と理由を完了ダイアログへ出す。 */
  var nc2=newCase(m);
  try{
   await fetch(API+'/api/customers',{method:'POST',headers:H,body:JSON.stringify(nc2.customer)});
   var crR=await fetch(API+'/api/cases',{method:'POST',headers:H,body:JSON.stringify(nc2.kase)});
   var crJ=await crR.json().catch(function(){return {};});
   if(!crR.ok){failN++;if(!failMsg)failMsg='HTTP '+crR.status+((crJ&&crJ.error)?(' '+crJ.error):'');}
   else if(crJ&&crJ.skipped){skipN++;if(!skipMsg)skipMsg=String(crJ.skipped);}
   else{added.push(nc2.comp);addedIds.push(m.id);}
  }catch(_ce){failN++;if(!failMsg)failMsg='通信エラー';}
 }
 /* 顧客Webサイトを発注企業の公式URL(company.url)で一括補正：空 or ゴミ（参加者用URL等＝URL形式でない）のときだけ。手動修正した正しいURLは温存 */
 var hco=coOf(m);var hou=(hco.url||'').trim();
 if(looksUrl(hou)){var hcc=custById['c'+m.id];var hcu=(hcc&&hcc.url||'').trim();if(!looksUrl(hcu)){try{await fetch(API+'/api/customers/'+('c'+m.id),{method:'PATCH',headers:H,body:JSON.stringify({url:hou})});urlFix++;}catch(_u){}}}
 // 発注者の回答：未取込のみ取得→PATCH（キャッシュ優先、無ければGraphQL）
 var hasRev=ex2&&ex2.ordererReview&&(ex2.ordererReview.satisfaction||ex2.ordererReview.feedback);
 if(!hasRev){var orev=ordReview(m.id)||await fetchReview(m.id);if(orev){await fetch(API+'/api/cases/'+id2,{method:'PATCH',headers:H,body:JSON.stringify({ordererReview:orev})});revN++;}}
 // 商談報告書PDF：未添付なら取得して添付（1回の取込で最大12件・案件が存在する場合のみ）
 if(repN<12&&(ex2||addedIds.indexOf(m.id)>=0)){if(await captureReport(m.id,id2,ex2))repN++;}
}
var msg;
if(!allM.length){msg='このページに ReadyCrew データ（Matching）が見つかりませんでした。\\n一覧を開き、画面が完全に表示されてからもう一度実行してください。\\n（それでも0件なら、F12コンソールで window.__APOLLO_CLIENT__ の構造を確認します）';}
else{msg='ReadyCrew 取込み\\n全マッチング '+ledgerM.length+' 件（終了系含む）'+(fetchSrc==='graphql'?'（全ページ取得）':'（※表示中ページのみ・全件取得に失敗: '+(fetchErr||'認証情報なし')+'）')+' → 獲客管理へ '+leadSaved+' 件保存'+(contactSaved?'・発注企業の担当者 '+contactSaved+' 件取得':'')+(leadErr?'\\n⚠ 保存エラー: '+leadErr:'')+(ledgerErr?'\\n⚠ 終了系の取得エラー: '+ledgerErr:'')+'\\n・新規案件（未回答以外）'+added.length+' 件・商談日更新 '+synced+' 件・状態同期 '+rcSync+' 件・発注者の回答 '+revN+' 件取込'+(repN?'・商談報告書PDF '+repN+' 件添付':'')+(urlFix?'・公式サイトURL補正 '+urlFix+' 件':'')+(bodyFix?'・本文修復 '+bodyFix+' 件（別案件の相談原文が誤って入っていたのを訂正）':'')+(failN?'\\n⚠ 案件作成の保存失敗 '+failN+' 件（'+failMsg+'）。再実行しても直らない場合は管理者へ':'')+(skipN?'\\n※ サーバ振り分けで案件化見送り '+skipN+' 件（'+skipMsg+'）＝リード側のまま':'');}
if(added.length&&addedIds.length===1&&confirm(msg+'\\n\\n今すぐ '+added[0]+' の詳細ページを開きますか？')){location.href='/matchings/'+addedIds[0];return;}
alert(msg+'\\n\\n終わったら案件管理システムに戻り「最新を反映」を押してください。');
}catch(e){alert('取込みに失敗しました: '+(e&&e.message||e))}})();`;
}

/* Tampermonkey 用ユーザースクリプト：ReadyCrew のページを開くだけで自動取込
   （ブックマークレットと同じ処理を、ページ読込後に自動実行。alert はトースト表示・画面遷移なし） */
function buildRcUserscript(apiBase, token) {
  const core = buildRcImportScript(apiBase, token);
  return `// ==UserScript==
// @name         ReadyCrew → 案件管理 自動同期
// @namespace    anken-kanri
// @version      2.1
// @description  ReadyCrewを開くだけで案件管理システムへ自動取込（一覧=リードをアポ管理へ＋未回答以外を案件化・発注者の回答も取込 / 詳細=完全取得）。タブを開いたままにすると2時間ごとに自動リロード＆再取込。トークンは長期有効（再発行は アプリの「ReadyCrew取込」から）
// @match        https://alion.partner.readycrew.cloud/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==
(function () {
  var alert = function (m) { try { var d = document.createElement('div'); d.textContent = '【案件管理 自動同期】' + m; d.style.cssText = 'position:fixed;bottom:18px;right:18px;z-index:999999;background:#20232f;color:#fff;padding:12px 16px;border-radius:10px;font:12px/1.6 sans-serif;max-width:360px;white-space:pre-wrap;box-shadow:0 8px 24px rgba(0,0,0,.3)'; document.body.appendChild(d); setTimeout(function () { d.remove(); }, 7000); } catch (e) {} };
  var confirm = function () { return false; }; // 自動実行では確認ダイアログ・画面遷移をしない
  var ranFor = '';
  var runOnce = function () {
    if (!/^\\/matchings/.test(location.pathname)) return;
    var key = location.pathname + location.search;
    if (key === ranFor) return;
    /* 読込待ちの判定：window.__APOLLO_CLIENT__ はページ(MAIN world)のReactが張る目印だが、Chromeの
       「ユーザースクリプトを許可」ON時は Tampermonkey が隔離環境(USER_SCRIPT world)で走り、そこからは
       MAIN worldの __APOLLO_CLIENT__ が見えず取込が永久に始まらなくなる（2026-07-16 判明）。両環境で共有
       される sessionStorage の JWT 有無でフォールバック判定する（取込本体はJWT直叩きなのでApolloは不要）。 */
    var __ready = !!window.__APOLLO_CLIENT__ || (function () { try { for (var i = 0; i < sessionStorage.length; i++) { var v = sessionStorage.getItem(sessionStorage.key(i)) || ''; if (v.indexOf('eyJ') === 0 && v.length > 200) return true; } } catch (e) {} return false; })();
    if (!__ready || (document.body.innerText || '').length < 800) return; // 読込待ち
    ranFor = key;
    ${core}
  };
  setInterval(runOnce, 2500);
  // タブ自動リロード：ReadyCrewの一覧(/matchings)を開いたままにしておくと2時間ごとに自動で再取込（サーバー不要の自動同期）
  if (/^\\/matchings/.test(location.pathname)) setTimeout(function () { try { location.reload(); } catch (e) {} }, 2 * 60 * 60 * 1000);
})();`;
}

/* 自動更新版（GMトークン＋@updateURL）のインストール導線。一度入れてトークンを1回設定すれば、以降は貼り直し不要 */
function AutoUpdateInstallBox({ scriptUrl }) {
  const token = useImportToken();
  const [copiedTok, setCopiedTok] = React.useState(false);
  const copyTok = async () => { if (!token) return; try { await navigator.clipboard.writeText(token); setCopiedTok(true); setTimeout(() => setCopiedTok(false), 1800); } catch (_) {} };
  return (
    <div style={{ background: '#eef5ff', border: '1px solid #cfe0fb', borderRadius: 10, padding: 13, marginBottom: 14 }}>
      <div style={{ fontSize: 12.5, fontWeight: 700, color: '#1c4f8a', marginBottom: 5 }}>{window.t("extra.import.autoUpdate")}</div>
      <div style={{ fontSize: 11.5, color: '#5b6b7d', lineHeight: 1.7, marginBottom: 10 }}>{window.t("extra.import.installHint")}</div>
      <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
        <a href={scriptUrl} target="_blank" rel="noreferrer" style={{ textDecoration: 'none' }}><Button variant="primary" icon="download">{window.t("extra.import.install")}</Button></a>
        <Button variant="default" icon={copiedTok ? 'check' : 'attach'} onClick={copyTok} disabled={!token}>{copiedTok ? window.t("cd.brief.copied") : window.t("extra.import.copyToken")}</Button>
      </div>
    </div>
  );
}

function RCImportModal({ onClose }) {
  const { currentUser, loginAs, showToast } = useStore();
  const [copied, setCopied] = React.useState(false);
  const [refreshing, setRefreshing] = React.useState(false);
  const linkRef = React.useRef(null);

  // 本番APIへ直接送る（ローカル確認時も同じDBを共有しているため常に本番URLでよい）
  const apiBase = 'https://api-production-c40f.up.railway.app';
  const token = useImportToken(); // 長期有効トークン（取得まで空文字 → ボタンは無効）
  const bookmarklet = token ? 'javascript:' + encodeURIComponent(buildRcImportScript(apiBase, token)) : '';

  // React は javascript: href を拒否するため、属性は直接セットする
  React.useEffect(() => { if (linkRef.current && bookmarklet) linkRef.current.setAttribute('href', bookmarklet); }, [bookmarklet]);

  const copy = async () => {
    if (!bookmarklet) { showToast(t('cases2.import.tokenLoading'), 'x'); return; }
    try { await navigator.clipboard.writeText(bookmarklet); setCopied(true); setTimeout(() => setCopied(false), 1800); }
    catch (_) { showToast(t('cases.copyFailed'), 'x'); }
  };
  const [copiedUs, setCopiedUs] = React.useState(false);
  const copyUserscript = async () => {
    if (!token) { showToast(t('cases2.import.tokenLoading'), 'x'); return; }
    try { await navigator.clipboard.writeText(buildRcUserscript(apiBase, token)); setCopiedUs(true); setTimeout(() => setCopiedUs(false), 1800); showToast(t('cases2.toast.userscriptCopied')); }
    catch (_) { showToast(t('cases2.toast.copyFailedShort'), 'x'); }
  };
  // 取り込み後にサーバーから再取得して画面へ反映（何が変わったかを必ず表示する）
  const refresh = async () => {
    setRefreshing(true);
    try {
      const D = window.APP_DATA;
      const beforeIds = new Set(D.cases.map(c => c.id));
      const beforeUpd = {}; D.cases.forEach(c => { beforeUpd[c.id] = c.updatedAt; });
      const beforeLeadIds = new Set((D.rcLeads || []).map(l => l.id));
      const data = await API.bootstrap();
      window.hydrateAppData(data);
      loginAs(currentUser.id);
      const added = D.cases.filter(c => !beforeIds.has(c.id));
      const updated = D.cases.filter(c => beforeIds.has(c.id) && beforeUpd[c.id] !== c.updatedAt);
      const addedLeads = (D.rcLeads || []).filter(l => !beforeLeadIds.has(l.id)).length;
      // 案件（商談一覧取込み）とリード（未回答取込み・アポ管理）の両方を報告する
      const parts = [];
      if (added.length) parts.push(t('cases2.refresh.newCases', { n: added.length, names: added.slice(0, 3).map(c => (D.customer(c.customerId) || {}).shortName).join('・'), more: added.length > 3 ? t('cases2.refresh.andMore') : '' }));
      else if (updated.length) parts.push(t('cases2.refresh.updatedCases', { n: updated.length }));
      if (addedLeads) parts.push(t('cases2.refresh.newLeads', { n: addedLeads }));
      const msg = parts.length ? t('cases2.refresh.applied', { detail: parts.join(' / ') }) : t('cases2.refresh.nothingNew');
      showToast(msg);
      onClose();
    } catch (e) { showToast(t('cases2.toast.refreshFailed', { msg: e.message }), 'x'); }
    setRefreshing(false);
  };

  const Step = ({ n, children }) => (
    <div style={{ display: 'flex', gap: 12, marginBottom: 14 }}>
      <div style={{ width: 22, height: 22, borderRadius: '50%', background: '#eef0fe', color: '#4a5af0', fontSize: 12, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', flex: '0 0 auto', marginTop: 1 }}>{n}</div>
      <div style={{ fontSize: 13, color: '#3b414b', lineHeight: 1.65, flex: 1 }}>{children}</div>
    </div>
  );

  const [showHelp, setShowHelp] = React.useState(false);
  return (
    <Modal open onClose={onClose} title={t('cases2.import.modalTitle')} width={480}
      footer={<>
        <Button variant="subtle" onClick={onClose}>{t('btn.close')}</Button>
        <Button variant="primary" icon="refresh" onClick={refresh} disabled={refreshing}>{refreshing ? t('cases2.import.applying') : t('cases2.import.applyLatest')}</Button>
      </>}>
      <AutoUpdateInstallBox scriptUrl="https://asm.ai-sutie.com/src/readycrew-sync.user.js" />
      {/* 手動版（ブックマークレット）：①ブックマーク登録 ②ReadyCrewを開く ③最新を反映(フッター) */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
        <a ref={linkRef} onClick={(e) => e.preventDefault()} draggable
          style={{ flex: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 7, background: '#4a5af0', color: '#fff', padding: '11px 16px', borderRadius: 9, fontSize: 13.5, fontWeight: 700, textDecoration: 'none', cursor: 'grab', boxShadow: '0 1px 2px rgba(40,30,120,.18)' }}>
          <Icon name="download" size={15} stroke={2.2} />{t('cases2.import.dragToBookmarkBar')}
        </a>
      </div>
      <a href={RC_LIST_URL} target="_blank" rel="noreferrer" style={{ textDecoration: 'none', display: 'block', marginBottom: 12 }}>
        <Button variant="default" icon="link" full>{t('cases2.import.openReadyCrew')}</Button>
      </a>
      <div style={{ fontSize: 12.5, color: '#7b828d', lineHeight: 1.7 }}>
        {t('cases2.import.quickHint')}
      </div>
      <button onClick={() => setShowHelp(h => !h)}
        style={{ display: 'inline-flex', alignItems: 'center', gap: 5, border: 'none', background: 'transparent', color: '#9aa1ab', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', padding: 0, marginTop: 10 }}>
        <Icon name="chevronRight" size={12} stroke={2.2} style={{ transform: showHelp ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
        {t('cases2.import.detailedHelp')}
      </button>
      {showHelp && (
        <div style={{ marginTop: 10 }}>
          <Step n={1}>{t('cases2.import.step1a')}<b>{t('cases2.import.step1bold')}</b>{t('cases2.import.step1b')}
            <Button variant="default" size="sm" onClick={copy} style={{ margin: '0 4px' }}>{copied ? t('cases2.import.copiedDone') : t('cases2.import.copyCode')}</Button>
            {t('cases2.import.step1c')}</Step>
          <Step n={2}>{t('cases2.import.step2')}</Step>
          <Step n={3}>{t('cases2.import.step3a')}<b>{t('cases2.import.step3bold')}</b>{t('cases2.import.step3b')}</Step>
          <Step n={4}>{t('cases2.import.step4')}</Step>
          <Step n={5}><b>{t('cases2.import.step5bold')}</b>{t('cases2.import.step5a')}
            <Button variant="default" size="sm" onClick={copyUserscript} style={{ margin: '0 4px' }}>{copiedUs ? t('cases2.import.copiedDone') : t('cases2.import.copyUserscript')}</Button>
            {t('cases2.import.step5b')}<b>{t('cases2.import.step5bold2')}</b>{t('cases2.import.step5c')}</Step>
          <div style={{ background: '#fbf8ef', border: '1px solid #f0e6c8', borderRadius: 10, padding: '10px 14px', fontSize: 12, color: '#8a6d1f', lineHeight: 1.6 }}>
            {t('cases2.import.credentialWarning')}
          </div>
        </div>
      )}
    </Modal>
  );
}

/* ============================================================
   発注ナビ（hnavi）取り込み — ReadyCrew と同じ思想（ログイン内側＝ブラウザ上で実行）
   発注ナビは Rails のサーバーレンダリング型のため、Apollo ではなく DOM をスクレイプする。
   一覧 /negotiations → 各商談カード（a[href^="/negotiations/{id}"]）から案件を新規作成。
   詳細 /negotiations/{id} → メール・電話・会議リンクを既存案件に追記。
   案件ID衝突を避けるため発注ナビは khn{id}/chn{id} で名前空間を分ける。
   ============================================================ */
const HNAVI_LIST_URL = 'https://developer.hnavi.co.jp/negotiations';

function buildHnaviImportScript(apiBase, token) {
  return `(async function(){try{
var API=${JSON.stringify(apiBase)},H={'Content-Type':'application/json',Authorization:'Bearer '+${JSON.stringify(token)}};
if(!/(^|\\.)hnavi\\.co\\.jp$/i.test(location.hostname)){alert('発注ナビ（developer.hnavi.co.jp）のページで実行してください');return}
var bs=await fetch(API+'/api/bootstrap',{headers:H});
if(bs.status===401){alert('認証の有効期限が切れています。案件管理システムの「発注ナビ取込」からブックマークレットを作り直してください');return}
var data=await bs.json();var leadById={};(data.rcLeads||[]).forEach(function(l){leadById[l.id]=l;});var byId={};(data.cases||[]).forEach(function(k){byId[k.id]=k;});
var custById2={};(data.customers||[]).forEach(function(x){custById2[x.id]=x;});var normCo=function(s){return String(s||'').replace(/株式会社|（株）|\\(株\\)|有限会社|合同会社|\\s+/g,'').trim()};var existCo={};(data.cases||[]).forEach(function(k){var co=normCo((custById2[k.customerId]||{}).company||'');if(co)existCo[co]=k.id;});
var f=function(d){var x=new Date(d);var p=function(n){return ('0'+n).slice(-2)};return x.getFullYear()+'-'+p(x.getMonth()+1)+'-'+p(x.getDate())+'T'+p(x.getHours())+':'+p(x.getMinutes())};
var clean=function(s){return (s||'').replace(/\\s+/g,' ').trim()};
var jdate=function(s){var m=(s||'').match(/(\\d{4})年(\\d{1,2})月(\\d{1,2})日(?:\\s*(\\d{1,2}):(\\d{2}))?/);if(!m)return null;var p=function(n){return ('0'+n).slice(-2)};return m[1]+'-'+p(m[2])+'-'+p(m[3])+'T'+p(m[4]||'00')+':'+p(m[5]||'00')};
var pref=function(addr){return (String(addr).match(/^.{2,4}?[都道府県]/)||[''])[0]};
/* 商談詳細ページのテキストから案件内容を構造抽出：利用プラン・発注会社情報・初回アポ・エントリー提案文
   （詳細ページには発注者の相談原文は無く、エントリー提案文が「相談内容の理解＋提案骨子」を含む最良の本文） */
var HXMARK='━━ 発注ナビ詳細（自動取込） ━━';
var hxDetail=function(t){
 t=String(t||'');
 var plan=clean((t.match(/本案件の利用プラン[：:]\\s*([^\\n]+)/)||[])[1]||'');
 var oname=clean((t.match(/発注者名[：:]\\s*([^\\n]+)/)||[])[1]||'');
 var oaddr=clean((t.match(/会社住所[：:]\\s*([^\\n]+)/)||[])[1]||'');
 var apoRaw=clean((t.match(/初回アポイントメント\\s*[\\n\\s]*(\\d{4}年\\d{1,2}月\\d{1,2}日[^\\n]*)/)||[])[1]||'');
 var rec=(t.split('自己推薦文')[1]||'').split('メモ（発注者には公開されません）')[0]||'';
 rec=rec.replace(/もっと見る|閉じる|少なく表示する/g,'').replace(/^[\\s\\n]+|[\\s\\n]+$/g,'');
 /* 「もっと見る」で短縮版＋全文が両方textContentに入る→先頭40字が再出現したら後半（全文）だけ採用＝重複除去 */
 var probe=rec.replace(/^\\s+/,'').slice(0,40);if(probe.length>=20){var jj=rec.indexOf(probe,probe.length);if(jj>0)rec=rec.slice(jj).replace(/^[\\s\\n]+/,'');}
 rec=rec.slice(0,3000);
 var info=[oname?'発注者名：'+oname:'',oaddr?'会社住所：'+oaddr:''].filter(Boolean).join('\\n');
 var secs=[plan?'■利用プラン\\n'+plan:'',info?'■発注会社の情報\\n'+info:'',apoRaw?'■初回アポイントメント\\n'+apoRaw:'',rec?'■エントリー提案文（相談内容の理解・提案骨子）\\n'+rec:''].filter(Boolean);
 return { block: secs.length?HXMARK+'\\n'+secs.join('\\n\\n'):'', apoAt: jdate(apoRaw), oname:oname, oaddr:oaddr };
};
/* 既存bodyのユーザー記述は温存し、マーカー以降だけ作り直す（何度実行しても増殖しない） */
var hxMergeBody=function(oldBody,block){var base=String(oldBody||'').split(HXMARK)[0].replace(/[\\s\\n]+$/,'');return block?((base?base+'\\n\\n':'')+block):String(oldBody||'')};
/* ===== 詳細ページ /negotiations/{id}：案件内容（プラン・発注会社情報・初回アポ・エントリー提案文）＋連絡先・会議リンクを案件へ反映 ===== */
var dm=location.pathname.match(/\\/negotiations\\/(\\d+)/);
if(dm){
 /* 「もっと見る」を展開して全文をDOMに出してから抽出 */
 try{[].slice.call(document.querySelectorAll('a,button,span,div')).filter(function(e){return /もっと見る/.test((e.textContent||'').trim())&&e.offsetParent!==null}).forEach(function(e){e.click()});await new Promise(function(r){setTimeout(r,400)});}catch(_e){}
 var uid=dm[1];var bodyT=document.body.innerText||'';
 var email=(bodyT.match(/メールアドレス[：:]\\s*([^\\s\\n]+@[^\\s\\n]+)/)||[])[1]||'';
 var tel=(bodyT.match(/電話番号[：:]\\s*([0-9+\\-() 　]{8,20})/)||[])[1]||'';
 var links=[],seenU={};
 var isMeetU=function(u){return /zoom\\.us\\/|meet\\.google|teams\\.microsoft|webex\\.com/.test(u)};
 var addLink=function(u){if(!u||!isMeetU(u)||seenU[u])return;seenU[u]=1;links.push({id:'ml'+Date.now()+links.length,label:'会議URL',url:u})};
 [].forEach.call(document.querySelectorAll('a[href]'),function(a){addLink(a.href)});
 var tre=/https?:\\/\\/[^\\s<>"'）)\\]]+/g,tm;while((tm=tre.exec(bodyT))){addLink(tm[0].replace(/[.,、。]+$/,''))}
 var kid='khnv'+uid;var cid='chnv'+uid;var exK=byId[kid];
 if(!exK){alert('この商談はまだ案件に入っていません。先に「案件一覧」で取込してから、詳細ページで実行してください');return}
 var det=hxDetail(bodyT);
 var cp={};if(email)cp.email=clean(email);if(tel)cp.tel=clean(tel);
 var exC=custById2[cid]||{};
 if(det.oname&&!clean(exC.contact||''))cp.contact=det.oname;
 if(det.oaddr&&!clean(exC.address||''))cp.address=det.oaddr;
 if(Object.keys(cp).length)await fetch(API+'/api/customers/'+cid,{method:'PATCH',headers:H,body:JSON.stringify(cp)});
 var kp={};
 if(det.block)kp.body=hxMergeBody(exK.body,det.block);
 if(det.apoAt&&!exK.appointAt&&!exK.appointAtLive&&!exK.nextMeeting)kp.appointAtLive=det.apoAt;
 if(links.length)kp.meetingLinks=links;
 if(Object.keys(kp).length)await fetch(API+'/api/cases/'+kid,{method:'PATCH',headers:H,body:JSON.stringify(kp)});
 alert('発注ナビ：詳細を案件に反映しました（案件内容'+(det.block?'✓':'—')+'・初回アポ'+(det.apoAt?'✓':'—')+'・メール'+(email?'✓':'—')+'・電話'+(tel?'✓':'—')+'・会議リンク '+links.length+'件）');
 return;
}
/* ===== 募集中(/jobs) / エントリー(/jobs/entries) / 全案件(/jobs/all=全エントリー履歴)：
   発注ナビのリードをアポ管理(rcLeads)へ取込。各カードのステータス（募集中=未エントリー、選定中/選定終了/
   コンタクト中…=エントリー済み）を rcStatus に保存。社名は商談化カードのみ取得（募集中は非公開） ===== */
var jm=location.pathname.match(/^\\/jobs(\\/(entries|all))?\\/?$/);
if(jm){
 /* 遅延ロード対策：「更に読み込む」を全部クリックして全件をDOMに展開してからスクレイプ（全案件は初期は十数件しか出ない） */
 var loadMore=async function(){for(var lp=0;lp<60;lp++){var btn=[].slice.call(document.querySelectorAll('button,a,div,span')).filter(function(e){var tx=(e.textContent||'').trim();return (tx==='更に読み込む'||tx==='もっと見る'||tx==='さらに読み込む'||tx==='もっと読み込む')&&e.offsetParent!==null})[0];if(!btn)break;var before=document.querySelectorAll('a[href]').length;btn.click();await new Promise(function(r){setTimeout(r,1100)});if(document.querySelectorAll('a[href]').length<=before)break;}};
 await loadMore();
 var jas=[].slice.call(document.querySelectorAll('a[href]')).filter(function(a){return /^\\/jobs\\/\\d+$/.test(a.getAttribute('href')||'')});
 if(!jas.length){alert('このページに案件が見つかりません。発注ナビの「募集中」「エントリー」「全案件」一覧を開いてから実行してください');return}
 var jcardOf=function(a){var one=function(el){var t=el.textContent||'';var m=t.match(/No\\.\\s*\\d/g);return !!(m&&m.length===1&&(t.indexOf('エリア')>=0||t.indexOf('〆')>=0||t.indexOf('紹介日時')>=0))};var c=a;for(var i=0;i<10;i++){if(one(c))return c;if(c.parentElement)c=c.parentElement;else break}return a.parentElement||a};
 var jseen={},jleads=[];
 for(var ji=0;ji<jas.length;ji++){var ja=jas[ji];var jid=ja.getAttribute('href').split('/').pop();if(jseen[jid])continue;jseen[jid]=1;
  var jc=jcardOf(ja),jt=jc.innerText||'';
  var jstatus=clean((jt.match(/(募集中|選定中|選定終了|コンタクト中|商談中[^\\n]*|アポ取得済み|提案準備中[^\\n]*|見積提示[^\\n]*|口頭受注|受注|失注)/)||[])[1]||'募集中');
  var jtitle=clean((jt.match(/No\\.\\s*\\d+[\\r\\n]+([^\\r\\n]+)/)||[])[1])||'（無題）';
  var jcm=jt.match(/(開発・制作|システム|アプリ|AI|SaaS|インフラ|デザイン|ホームページ|動画制作|SES・派遣|EC|マーケティング)/g),jcats=jcm?jcm.filter(function(v,ix){return jcm.indexOf(v)===ix}):[];
  var jpref=clean((jt.match(/エリア[：:]\\s*([^\\n※]+)/)||[])[1]||'');
  var jcomp=clean((jt.match(/社名[：:]\\s*([^\\n]+)/)||[])[1]||'');
  var jreg=jdate((((jt.match(/紹介日時[：:]\\s*([^\\r\\n]+)/)||jt.match(/〆\\s*([^\\r\\n]+)/))||[])[1])||'')||f(Date.now());
  jleads.push({matchingId:'hnv'+jid,source:'hnavi',company:jcomp,title:jtitle,category:jcats.join('・'),prefecture:jpref,rcStatus:jstatus,registeredAt:jreg,sourceUrl:'https://developer.hnavi.co.jp/jobs/'+jid});
 }
 var jr=await fetch(API+'/api/import/leads',{method:'POST',headers:H,body:JSON.stringify({leads:jleads})});var jj=await jr.json().catch(function(){return {}});var jsaved=jr.ok?(jj.count||0):0;
 alert('発注ナビ：このページの '+jsaved+' 件をアポ管理に取込みました'+(jr.ok?'':'\\n⚠ HTTP '+jr.status)+'\\n\\n※エントリー率を正しく出すには両方を開いて実行してください：\\n・「全案件」＝エントリー済みの全履歴\\n・「募集中」＝まだエントリーしていない案件\\n終わったらアプリの「アポ管理」を開き「最新を反映」を押してください');
 return;
}
/* ===== 一覧ページ /negotiations：表示中の商談を「案件」として作成（発注ナビは全件が商談＝案件）。案件id='khnv'+id でアポの重複排除と整合・既存は上書きしない ===== */
var as=[].slice.call(document.querySelectorAll('a[href]')).filter(function(a){return /^\\/negotiations\\/\\d+$/.test(a.getAttribute('href')||'')});
if(!as.length){alert('このページに商談が見つかりません。発注ナビの「案件一覧」を開いてから実行してください');return}
var cardOf=function(a){var oneCard=function(el){var t=el.textContent||'';var m=t.match(/No\\.\\s*\\d/g);return !!(m&&m.length===1&&t.indexOf('紹介日時')>=0)};var c=a;for(var i=0;i<10;i++){if(oneCard(c))return c;if(c.parentElement)c=c.parentElement;else break}return a.parentElement||a};
var pick=function(card,label){var tt=card.innerText||'';var m=tt.match(new RegExp(label+'[：:]\\\\s*([^\\\\n]+)'));return m?clean(m[1]):''};
var seen={},added=0,skipped=0,failed=0,dup=0,err='',enriched=0;
for(var i=0;i<as.length;i++){var a=as[i];var uid2=a.getAttribute('href').split('/').pop();if(seen[uid2])continue;seen[uid2]=1;
 var kid='khnv'+uid2,cid='chnv'+uid2;
 /* 既存案件：案件内容(body)に発注ナビ詳細マーカーが無い＝薄い案件だけ、詳細ページから内容を後追い充填（最大15件/回）。それ以外はスキップ */
 if(byId[kid]){var exq=byId[kid];if(enriched<15&&String(exq.body||'').indexOf(HXMARK)<0){try{var er=await fetch('/negotiations/'+uid2);if(er.ok){var edoc=new DOMParser().parseFromString(await er.text(),'text/html');var etext=(edoc.body&&(edoc.body.innerText||edoc.body.textContent))||'';var edet=hxDetail(etext);var eem=(etext.match(/メールアドレス[：:]\\s*([^\\s\\n]+@[^\\s\\n]+)/)||[])[1]||'';var etl=(etext.match(/電話番号[：:]\\s*([0-9+\\-() 　]{8,20})/)||[])[1]||'';var ecp={};if(edet.oname&&!clean((custById2[cid]||{}).contact||''))ecp.contact=edet.oname;if(edet.oaddr&&!clean((custById2[cid]||{}).address||''))ecp.address=edet.oaddr;if(eem)ecp.email=clean(eem);if(etl)ecp.tel=clean(etl);if(Object.keys(ecp).length)await fetch(API+'/api/customers/'+cid,{method:'PATCH',headers:H,body:JSON.stringify(ecp)});var ekp={};if(edet.block)ekp.body=hxMergeBody(exq.body,edet.block);if(edet.apoAt&&!exq.appointAt&&!exq.appointAtLive&&!exq.nextMeeting)ekp.appointAtLive=edet.apoAt;if(Object.keys(ekp).length){await fetch(API+'/api/cases/'+kid,{method:'PATCH',headers:H,body:JSON.stringify(ekp)});enriched++;}await new Promise(function(r){setTimeout(r,220)});}}catch(_e){}}
  continue;}
 var card=cardOf(a),tt=card.innerText||'';
 var intro=jdate((tt.match(/紹介日時[：:]\\s*([^\\n]+)/)||[])[1]||'');
 var title=clean((tt.match(/No\\.\\s*\\d+[\\r\\n]+([^\\r\\n]+)/)||[])[1])||clean((a.querySelector('.title')||{}).textContent||'')||'（無題）';
 var comp=pick(card,'社名')||'（社名不明）';
 var ncomp=(comp.indexOf('社名不明')<0)?normCo(comp):'';if(ncomp&&existCo[ncomp]){dup++;continue;}
 var contact=pick(card,'担当者'),addr=pick(card,'発注者住所');
 var cm=tt.match(/(開発・制作|システム|アプリ|AI|SaaS|インフラ|デザイン)/g),cats=cm?cm.filter(function(v,ix){return cm.indexOf(v)===ix}):[];
 var status=clean((tt.match(/(見積提示のみ[^\\n]*|提案準備中[^\\n]*|コンタクト中|商談中[^\\n]*|アポ取得済み|口頭受注|受注|失注)/)||[])[1]||'');
 var appSt=/失注/.test(status)?'lost':(/受注/.test(status)?'won':(/見積提示|商談中/.test(status)?'negotiating':'working'));
 var short=comp.replace(/^株式会社/,'').replace(/株式会社$/,'').slice(0,5);
 try{
  await fetch(API+'/api/customers',{method:'POST',headers:H,body:JSON.stringify({id:cid,company:comp,shortName:short,contact:contact,contactDept:'',tel:'',email:'',address:addr,industry:cats[0]||'',employees:'',url:'',note:'発注ナビ 取込みで自動作成'})});
  var cr=await fetch(API+'/api/cases',{method:'POST',headers:H,body:JSON.stringify({id:kid,sourceNo:'HNV-'+uid2,title:comp+'：'+title.slice(0,22),status:appSt,customerId:cid,ownerId:null,subIds:[],rank:'未',due:null,nextMeeting:null,appointAt:null,appointAtLive:null,source:'hnavi',sourceName:'発注ナビ',rcStatus:'',sourceUrl:'https://developer.hnavi.co.jp/negotiations/'+uid2,createdAt:intro||f(Date.now()),updatedAt:f(Date.now()),body:'【'+cats.join('・')+'】'+title,note:'発注ナビ 取込み',originalTitle:comp+' '+cats.join('・'),meetingLinks:[]})});
  if(!cr.ok){failed++;err='案件保存エラー(HTTP '+cr.status+')';}
  else{var cj={};try{cj=await cr.json()}catch(e){}if(cj&&cj.skipped){skipped++;}else{added++;
   /* 新規作成分は詳細ページを自動取得し、案件内容（プラン・発注会社情報・初回アポ・エントリー提案文）と連絡先を充填（最大12件/回・同一オリジンfetch） */
   if(enriched<12){try{var dr=await fetch('/negotiations/'+uid2);if(dr.ok){var dhtml=await dr.text();var ddoc=new DOMParser().parseFromString(dhtml,'text/html');var dtext=(ddoc.body&&(ddoc.body.innerText||ddoc.body.textContent))||'';var det2=hxDetail(dtext);
    var em2=(dtext.match(/メールアドレス[：:]\\s*([^\\s\\n]+@[^\\s\\n]+)/)||[])[1]||'';var tl2=(dtext.match(/電話番号[：:]\\s*([0-9+\\-() 　]{8,20})/)||[])[1]||'';
    var cp2={};if(det2.oname)cp2.contact=det2.oname;if(det2.oaddr)cp2.address=det2.oaddr;if(em2)cp2.email=clean(em2);if(tl2)cp2.tel=clean(tl2);
    if(Object.keys(cp2).length)await fetch(API+'/api/customers/'+cid,{method:'PATCH',headers:H,body:JSON.stringify(cp2)});
    var kp2={};if(det2.block)kp2.body=hxMergeBody('【'+cats.join('・')+'】'+title,det2.block);if(det2.apoAt)kp2.appointAtLive=det2.apoAt;
    if(Object.keys(kp2).length){await fetch(API+'/api/cases/'+kid,{method:'PATCH',headers:H,body:JSON.stringify(kp2)});enriched++;}
    await new Promise(function(r){setTimeout(r,250)});}}catch(_e){}}
  }}
 }catch(e){failed++;err=(e&&e.message)||'通信エラー'}
}
/* ★掃引：商談中一覧に載らない過去の薄い khnv 案件も byId から全部拾い、詳細ページfetchで一括充填（最大20件/回・トースト通知・上のループで処理済みは seen でスキップ）。これで商談中から外れた古い案件も /negotiations を開くだけで埋まる */
var swept=0;var thinK=Object.keys(byId).filter(function(id){return /^khnv\\d+$/.test(id)&&String(byId[id].body||'').indexOf(HXMARK)<0;});
for(var si=0;si<thinK.length&&swept<20;si++){var kid3=thinK[si],uid3=kid3.slice(4),exq3=byId[kid3];if(seen[uid3])continue;seen[uid3]=1;
 try{var sr=await fetch('/negotiations/'+uid3);if(!sr.ok)continue;var sdoc=new DOMParser().parseFromString(await sr.text(),'text/html');var stext=(sdoc.body&&(sdoc.body.innerText||sdoc.body.textContent))||'';var sdet=hxDetail(stext);var scid='chnv'+uid3;
  var sem=(stext.match(/メールアドレス[：:]\\s*([^\\s\\n]+@[^\\s\\n]+)/)||[])[1]||'';var stl=(stext.match(/電話番号[：:]\\s*([0-9+\\-() 　]{8,20})/)||[])[1]||'';
  var scp={};if(sdet.oname&&!clean((custById2[scid]||{}).contact||''))scp.contact=sdet.oname;if(sdet.oaddr&&!clean((custById2[scid]||{}).address||''))scp.address=sdet.oaddr;if(sem)scp.email=clean(sem);if(stl)scp.tel=clean(stl);
  if(Object.keys(scp).length)await fetch(API+'/api/customers/'+scid,{method:'PATCH',headers:H,body:JSON.stringify(scp)});
  var skp={};if(sdet.block)skp.body=hxMergeBody(exq3.body,sdet.block);if(sdet.apoAt&&!exq3.appointAt&&!exq3.appointAtLive&&!exq3.nextMeeting)skp.appointAtLive=sdet.apoAt;
  if(Object.keys(skp).length){await fetch(API+'/api/cases/'+kid3,{method:'PATCH',headers:H,body:JSON.stringify(skp)});swept++;}
  await new Promise(function(r){setTimeout(r,250)});}catch(_e){}}
alert('発注ナビ：'+added+' 件を「案件」に取込みました'+(enriched?'／'+enriched+'件に詳細ページから案件内容（プラン・提案文・初回アポ）を充填':'')+(swept?'／過去の薄い案件'+swept+'件も一括充填':'')+(dup?'（'+dup+' 件は同じ会社名の案件が既にあるためスキップ＝重複防止）':'')+(skipped?'（'+skipped+' 件はサーバ設定でスキップ）':'')+(failed?'（'+failed+' 件失敗: '+err+'）':'')+'\\n\\n※既存の案件は上書きしません。既存案件は各商談の詳細ページを開くと、案件内容・初回アポ・連絡先・会議リンクが自動反映されます。\\n終わったらアプリの「案件」を開き「最新を反映」を押してください');
}catch(e){alert('取込みに失敗しました: '+(e&&e.message||e))}})();`;
}

function buildHnaviUserscript(apiBase, token) {
  const core = buildHnaviImportScript(apiBase, token);
  return `// ==UserScript==
// @name         発注ナビ → 案件管理 自動同期
// @namespace    anken-kanri
// @version      1.4
// @description  発注ナビを開くだけで案件管理へ自動取込（募集中/エントリー/全案件=アポ管理へリード / 商談中=案件 / 詳細=連絡先追記）。タブを開いたままにすると2時間ごとに自動リロード＆再取込
// @match        https://developer.hnavi.co.jp/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==
(function () {
  var alert = function (m) { try { var d = document.createElement('div'); d.textContent = '【案件管理 自動同期】' + m; d.style.cssText = 'position:fixed;bottom:18px;right:18px;z-index:999999;background:#20232f;color:#fff;padding:12px 16px;border-radius:10px;font:12px/1.6 sans-serif;max-width:360px;white-space:pre-wrap;box-shadow:0 8px 24px rgba(0,0,0,.3)'; document.body.appendChild(d); setTimeout(function () { d.remove(); }, 7000); } catch (e) {} };
  var ranFor = '';
  // 取込対象ページ：商談中(/negotiations・詳細含む) と 募集中/エントリー/全案件(/jobs・/jobs/entries・/jobs/all)。/jobs/{id} 単体は対象外
  var isTarget = function (p) { return /^\\/negotiations/.test(p) || /^\\/jobs(\\/(entries|all))?\\/?$/.test(p); };
  var runOnce = function () {
    var p = location.pathname;
    if (!isTarget(p)) return;
    var key = p + location.search;
    if (key === ranFor) return;
    // 読込待ち：詳細ページは本文が出るまで、一覧は商談/案件カードが1件でも出るまで
    var ready = /\\/negotiations\\/\\d+/.test(p) ? (document.body.innerText || '').length > 200 : !!(document.querySelector('a[href^="/negotiations/"]') || document.querySelector('a[href^="/jobs/"]'));
    if (!ready) return;
    ranFor = key;
    ${core}
  };
  setInterval(runOnce, 2500);
  // タブ自動リロード：取込対象ページを開いたままにしておくと、2時間ごとに自動で再取込（サーバー不要の自動同期）
  if (isTarget(location.pathname)) setTimeout(function () { try { location.reload(); } catch (e) {} }, 2 * 60 * 60 * 1000);
})();`;
}

function HnaviImportModal({ onClose }) {
  const { currentUser, loginAs, showToast } = useStore();
  const [copied, setCopied] = React.useState(false);
  const [copiedUs, setCopiedUs] = React.useState(false);
  const [refreshing, setRefreshing] = React.useState(false);
  const linkRef = React.useRef(null);
  const apiBase = 'https://api-production-c40f.up.railway.app';
  const token = useImportToken(); // 長期有効トークン（取得まで空文字 → ボタンは無効）
  const bookmarklet = token ? 'javascript:' + encodeURIComponent(buildHnaviImportScript(apiBase, token)) : '';
  React.useEffect(() => { if (linkRef.current && bookmarklet) linkRef.current.setAttribute('href', bookmarklet); }, [bookmarklet]);

  const copy = async () => {
    if (!bookmarklet) { showToast(t('cases2.import.tokenLoading'), 'x'); return; }
    try { await navigator.clipboard.writeText(bookmarklet); setCopied(true); setTimeout(() => setCopied(false), 1800); }
    catch (_) { showToast(t('cases.copyFailed'), 'x'); }
  };
  const copyUserscript = async () => {
    if (!token) { showToast(t('cases2.import.tokenLoading'), 'x'); return; }
    try { await navigator.clipboard.writeText(buildHnaviUserscript(apiBase, token)); setCopiedUs(true); setTimeout(() => setCopiedUs(false), 1800); showToast(t('cases2.toast.userscriptCopied')); }
    catch (_) { showToast(t('cases2.toast.copyFailedShort'), 'x'); }
  };
  const refresh = async () => {
    setRefreshing(true);
    try {
      const D = window.APP_DATA;
      const beforeIds = new Set(D.cases.map(c => c.id));
      const data = await API.bootstrap();
      window.hydrateAppData(data);
      loginAs(currentUser.id);
      const added = D.cases.filter(c => !beforeIds.has(c.id));
      showToast(added.length ? t('cases2.refresh.applied', { detail: t('cases2.refresh.newCases', { n: added.length, names: added.slice(0, 3).map(c => (D.customer(c.customerId) || {}).shortName).join('・'), more: added.length > 3 ? t('cases2.refresh.andMore') : '' }) }) : t('cases2.refresh.nothingNew'));
      onClose();
    } catch (e) { showToast(t('cases2.toast.refreshFailed', { msg: e.message }), 'x'); }
    setRefreshing(false);
  };

  return (
    <Modal open onClose={onClose} title={t('hnavi.import.modalTitle')} width={480}
      footer={<>
        <Button variant="subtle" onClick={onClose}>{t('btn.close')}</Button>
        <Button variant="primary" icon="refresh" onClick={refresh} disabled={refreshing}>{refreshing ? t('cases2.import.applying') : t('cases2.import.applyLatest')}</Button>
      </>}>
      <AutoUpdateInstallBox scriptUrl="https://asm.ai-sutie.com/src/hnavi-sync.user.js" />
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
        <a ref={linkRef} onClick={(e) => e.preventDefault()} draggable
          style={{ flex: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 7, background: '#4a5af0', color: '#fff', padding: '11px 16px', borderRadius: 9, fontSize: 13.5, fontWeight: 700, textDecoration: 'none', cursor: 'grab', boxShadow: '0 1px 2px rgba(40,30,120,.18)' }}>
          <Icon name="download" size={15} stroke={2.2} />{t('hnavi.import.drag')}
        </a>
      </div>
      <a href={HNAVI_LIST_URL} target="_blank" rel="noreferrer" style={{ textDecoration: 'none', display: 'block', marginBottom: 12 }}>
        <Button variant="default" icon="link" full>{t('hnavi.import.open')}</Button>
      </a>
      <div style={{ fontSize: 12.5, color: '#7b828d', lineHeight: 1.7, marginBottom: 10 }}>
        {t('hnavi.import.hint')}
      </div>
      <Button variant="default" size="sm" onClick={copyUserscript}>{copiedUs ? t('cases2.import.copiedDone') : t('cases2.import.copyUserscript')}</Button>
      <Button variant="subtle" size="sm" onClick={copy} style={{ marginLeft: 6 }}>{copied ? t('cases2.import.copiedDone') : t('cases2.import.copyCode')}</Button>
    </Modal>
  );
}

/* 重複案件の検出：同じ発注ナビ商談が 旧khn{uid} と 現行khnv{uid} で二重登録されたグループを uid で束ねる。
   残す候補＝khnv優先（現行・詳細充填済み）→本文が長い方。統合済み(mergedInto)は候補から除外。 */
function kmCaseDupGroups(allCases) {
  const byUid = {};
  (allCases || []).forEach(c => { if (c.mergedInto) return; const m = String(c.id).match(/^khnv?(\d+)$/); if (m) (byUid[m[1]] = byUid[m[1]] || []).push(c); });
  return Object.entries(byUid).filter(([u, arr]) => arr.length > 1).map(([u, arr]) => {
    const members = arr.slice().sort((a, b) => {
      const av = /^khnv\d/.test(a.id) ? 0 : 1, bv = /^khnv\d/.test(b.id) ? 0 : 1;
      if (av !== bv) return av - bv;
      return String(b.body || '').length - String(a.body || '').length;
    });
    return { key: 'hnv' + u, uid: u, members, primaryId: members[0].id };
  }).sort((a, b) => Number(b.uid) - Number(a.uid));
}

/* 重複案件の統合モーダル（顧客統合と同方式）：グループごとに残す案件を選んで統合。
   統合＝商談記録を残す案件へ付け替え＋重複を mergedInto で一覧・集計から隠す（可逆）。 */
function MergeCasesModal({ onClose }) {
  const { workspaceCases: allCases, meetings, mergeCases, unmergeCase } = useStore();
  const D = window.APP_DATA;
  const groups = React.useMemo(() => kmCaseDupGroups(allCases), [allCases]);
  const merged = (allCases || []).filter(c => c.mergedInto);
  const [primarySel, setPrimarySel] = React.useState({});
  const primaryOf = (g) => primarySel[g.key] || g.primaryId;
  const mtgN = (id) => (meetings || []).filter(m => m.caseId === id).length;
  const doGroup = (g) => { const pid = primaryOf(g); mergeCases(pid, g.members.filter(m => m.id !== pid).map(m => m.id)); };
  const doAll = () => {
    if (!window.confirm(t('case.merge.runAllConfirm', { n: groups.length }))) return;
    groups.forEach(g => { const pid = primaryOf(g); mergeCases(pid, g.members.filter(m => m.id !== pid).map(m => m.id)); });
  };
  return (
    <Modal open onClose={onClose} title={t('case.merge.title')} subtitle={t('case.merge.subtitle')} width={760}
      footer={<><Button variant="subtle" onClick={onClose}>{t('btn.close')}</Button>{groups.length > 0 && <Button variant="primary" icon="link" onClick={doAll}>{t('case.merge.runAll', { n: groups.length })}</Button>}</>}>
      {groups.length === 0 ? (
        <div style={{ padding: '40px 0', textAlign: 'center', color: '#9aa1ab', fontSize: 13 }}>
          <Icon name="check2" size={26} stroke={2} style={{ color: '#8ec89a', marginBottom: 8 }} /><div>{t('case.merge.empty')}</div>
        </div>
      ) : (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
          <div style={{ fontSize: 12.5, color: '#7b828d', fontWeight: 600 }}>{t('case.merge.foundGroups', { n: groups.length })}</div>
          {groups.map(g => {
            const pid = primaryOf(g);
            return (
              <div key={g.key} style={{ border: '1px solid #ecedf0', borderRadius: 10, padding: 12, background: '#fff' }}>
                <div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
                  {g.members.map(m => {
                    const isPrimary = m.id === pid;
                    const st = (D.STATUS && D.STATUS[m.status]) || {};
                    const info = [m.id, st.label || m.status, t('case.merge.bodyLen', { n: String(m.body || '').length }), t('case.merge.mtgs', { n: mtgN(m.id) })].filter(Boolean).join(' · ');
                    return (
                      <label key={m.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 11px', borderRadius: 8, cursor: 'pointer',
                        background: isPrimary ? '#f4f3ff' : '#f8f9fb', border: isPrimary ? '1px solid #d9d6fb' : '1px solid transparent' }}>
                        <input type="radio" name={'cmerge-' + g.key} checked={isPrimary} onChange={() => setPrimarySel(s => ({ ...s, [g.key]: m.id }))} style={{ accentColor: '#4a5af0', flex: '0 0 auto' }} />
                        <div style={{ flex: 1, minWidth: 0 }}>
                          <div style={{ fontSize: 13, fontWeight: 600, color: '#1f2430', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{m.title}</div>
                          <div style={{ fontSize: 11.5, color: '#9aa1ab', marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{info}</div>
                        </div>
                        <span style={{ fontSize: 11, fontWeight: 700, color: isPrimary ? '#4a5af0' : '#c4c9d0', flex: '0 0 auto' }}>{isPrimary ? t('cust.merge.keep') : t('cust.merge.mergeInto')}</span>
                      </label>
                    );
                  })}
                </div>
                <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 10 }}>
                  <Button size="sm" variant="primary" icon="link" onClick={() => doGroup(g)}>{t('cust.merge.doGroup')}</Button>
                </div>
              </div>
            );
          })}
        </div>
      )}
      {merged.length > 0 && (
        <div style={{ marginTop: 18, paddingTop: 14, borderTop: '1px solid #f0f1f4' }}>
          <div style={{ fontSize: 12, fontWeight: 700, color: '#9aa1ab', marginBottom: 8 }}>{t('cust.merge.mergedTitle', { n: merged.length })}</div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
            {merged.map(m => {
              const into = (allCases || []).find(x => x.id === m.mergedInto);
              return (
                <div key={m.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '7px 11px', borderRadius: 8, background: '#f8f9fb' }}>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontSize: 12.5, fontWeight: 600, color: '#7b828d', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{m.title} <span style={{ fontWeight: 400, color: '#aab0ba' }}>({m.id})</span></div>
                    <div style={{ fontSize: 11, color: '#aab0ba', marginTop: 1 }}>{t('cust.merge.mergedInto2', { name: into ? into.title : m.mergedInto })}</div>
                  </div>
                  <Button size="sm" variant="subtle" onClick={() => unmergeCase(m.id)}>{t('cust.merge.undo')}</Button>
                </div>
              );
            })}
          </div>
        </div>
      )}
    </Modal>
  );
}

Object.assign(window, { QuickAssign, FilterPill, RankSelect, StatusSelect, CaseList, RCImportModal, HnaviImportModal, MergeCasesModal, kmCaseDupGroups });
