/* ============================================================
   ストア — ナビゲーション状態 + 可変データ + アクション
   React Context で全画面に共有・書き込みは API へ永続化
   ============================================================ */
const StoreContext = React.createContext(null);
const useStore = () => React.useContext(StoreContext);

/* ロール（メンバー＜マネジャー＜管理者）。管理者は常に全権限。 */
const ROLE_RANK = { member: 1, manager: 2, admin: 3 };
/* ロール表示名は言語切替に追随させるため getter で都度 t() 解決（top-level constのため即時評価を避ける） */
const ROLE_LABEL = {
  get member() { return t('store.role.member'); },
  get manager() { return t('store.role.manager'); },
  get admin() { return t('store.role.admin'); },
};
const ROLE_ORDER = ['member', 'manager', 'admin'];
const roleRank = (role) => ROLE_RANK[role] || 1;
/* 権限レジストリ（設定→権限 で編集可・masters に保存）。各行 [key, label, [member, manager]]。管理者は固定で全許可。
   未登録の操作（閲覧・案件/顧客の作成編集・記録・AI分析・見積表編集 等）は全員可（既定）。 */
/* 権限グループ。group ラベル・各項目ラベルは言語切替に追随させるため t() を遅延解決する。
   消費側（settings.jsx）の access 形（g.group / [k, label] / [k, , d]）を保つため、
   group は getter、項目ラベルは index 1 に getter を定義した配列にする。 */
const _permItem = (key, labelKey, def) => {
  const arr = [key, null, def];
  Object.defineProperty(arr, 1, { enumerable: true, get() { return t(labelKey); } });
  return arr;
};
const _permGroup = (groupKey, items) => ({ get group() { return t(groupKey); }, items });
const PERM_GROUPS = [
  _permGroup('store.perm.group.workspace', [
    _permItem('workspaceTriage', 'store.perm.workspaceTriage', [0, 1]),
    _permItem('workspaceCreate', 'store.perm.workspaceCreate', [0, 1]),
    _permItem('workspaceEdit', 'store.perm.workspaceEdit', [0, 1]),
    _permItem('workspaceDelete', 'store.perm.workspaceDelete', [0, 1]),
  ]),
  _permGroup('store.perm.group.case', [
    _permItem('caseCreate', 'store.perm.caseCreate', [1, 1]),
    _permItem('caseEdit', 'store.perm.caseEdit', [1, 1]),
    _permItem('assign', 'store.perm.assign', [0, 1]),
    _permItem('caseDelete', 'store.perm.caseDelete', [0, 0]),
  ]),
  _permGroup('store.perm.group.customer', [
    _permItem('customerEdit', 'store.perm.customerEdit', [1, 1]),
    _permItem('customerDelete', 'store.perm.customerDelete', [0, 0]),
  ]),
  _permGroup('store.perm.group.proposal', [
    _permItem('quoteSheetEdit', 'store.perm.quoteSheetEdit', [1, 1]),
    _permItem('freeeSend', 'store.perm.freeeSend', [0, 0]),
  ]),
  _permGroup('store.perm.group.prototype', [
    _permItem('prototypeAdd', 'store.perm.prototypeAdd', [0, 0]),
    _permItem('prototypeEdit', 'store.perm.prototypeEdit', [0, 0]),
  ]),
  _permGroup('store.perm.group.knowledge', [
    _permItem('manageKnowledge', 'store.perm.manageKnowledge', [0, 0]),
  ]),
  _permGroup('store.perm.group.apo', [
    _permItem('apoOps', 'store.perm.apoOps', [0, 1]),
  ]),
  _permGroup('store.perm.group.integ', [
    _permItem('workspaceInteg', 'store.perm.workspaceInteg', [0, 0]),
    _permItem('viewAnalytics', 'store.perm.viewAnalytics', [0, 0]), // 分析画面の閲覧（既定＝管理者のみ）
    _permItem('viewAuditLog', 'store.perm.viewAuditLog', [0, 0]), // 監査ログの閲覧（既定＝管理者のみ）
    _permItem('viewKpiReport', 'store.perm.viewKpiReport', [0, 0]), // KPIレポートの閲覧（既定＝管理者のみ）
    _permItem('settings', 'store.perm.settings', [0, 0]),
  ]),
];
const PERM_DEFAULT = {};
PERM_GROUPS.forEach(g => g.items.forEach(([k, , d]) => { PERM_DEFAULT[k] = { member: d[0] === 1, manager: d[1] === 1 }; }));
/* role が action を実行できるか。管理者=常に可。保存済み設定(D.permissions)優先、無ければ既定。未登録=全員可 */
const canRole = (role, action) => {
  if (role === 'admin') return true;
  if(ASM_WORKSPACES.MANAGER_ACTIONS.includes(action)&&role!=='manager')return false;
  const D = window.APP_DATA;
  const eff = (D && D.permissions && D.permissions[action]) || PERM_DEFAULT[action];
  return eff ? !!eff[role] : true;
};

/* 放置判定：クローズ（成約/失注/完了）・アーカイブ・リード以外で、未来の商談予定が無く、
   最終更新（updatedAt）から7日以上動きが無い案件。通知とダッシュボードの両方で使う共通判定 */
function caseIdleInfo(c) {
  if (!c || ['won', 'lost', 'done'].includes(c.status) || c.archived) return null;
  if (typeof rcIsLeadCase === 'function' && rcIsLeadCase(c)) return null;
  const mt = c.nextMeeting || c.appointAt;
  const dMt = mt ? daysUntil(mt) : null;
  if (dMt !== null && dMt >= 0) return null; // 未来の商談予定あり＝放置ではない
  const lastTouch = c.updatedAt || c.createdAt;
  if (!lastTouch) return null;
  const d = daysUntil(lastTouch);
  const idle = d === null ? null : -d;
  return (idle !== null && idle >= 7) ? { idleDays: idle } : null;
}

/* 実案件データ（期限・商談予定・新着）から通知を自動生成。
   既読はサーバーではなく localStorage で保持（ID は案件×種別で安定） */
function deriveNotifications(cases, D, currentUser) {
  if (!currentUser) return [];
  const isAdmin = currentUser.role === 'admin';
  const mine = (k) => isAdmin || k.ownerId === currentUser.id || (k.subIds || []).includes(currentUser.id);
  const active = (k) => !['won', 'lost', 'done'].includes(k.status);
  const custName = (k) => { const c = D.customer(k.customerId); return c ? (c.shortName || c.company) : t('store.notif.caseFallback'); };
  const short = (s, n = 24) => (s && s.length > n) ? s.slice(0, n) + '…' : (s || t('store.notif.untitled'));
  const whenLabel = (d) => d < 0 ? t('store.when.overdue', { n: -d }) : d === 0 ? t('store.when.today') : d === 1 ? t('store.when.tomorrow') : t('store.when.inDays', { n: d });
  const out = [];
  for (const k of cases) {
    if (!mine(k)) continue;
    // 商談予定リマインド（直近7日以内）
    const mt = k.nextMeeting || k.appointAt;
    if (mt && active(k)) {
      const d = daysUntil(mt);
      if (d !== null && d >= 0 && d <= 7) {
        out.push({ id: 'mt-' + k.id, type: 'reminder', _sort: d - 10, _date: mt, when: whenLabel(d),
          title: t('store.notif.meetingTitle', { cust: custName(k) }), body: t('store.notif.meetingBody', { datetime: `${fmtDate(mt, true)} ${fmtTime(mt)}` }), link: { screen: 'case', id: k.id } });
      }
    }
    // 案件 期限（手動 or 商談日+5日）：3日前〜超過。提案書が未提出ならその旨も
    const due = caseDue(k);
    if (due && active(k)) {
      const d = daysUntil(due);
      const proposalPending = !['submitted', 'na'].includes(k.proposalStatus || 'none');
      // 提案書が提出済み／不要なら「提出期限」通知は出さない（提出済みを催さない）
      if (d !== null && d <= 3 && proposalPending) {
        const over = d < 0;
        out.push({ id: 'due-' + k.id, type: over ? 'due' : 'reminder', _sort: d + 0.5, _date: due, when: whenLabel(d),
          title: (over ? t('store.notif.proposalDueOverTitle', { cust: custName(k) }) : t('store.notif.proposalDueSoonTitle', { cust: custName(k) })),
          body: (over ? t('store.notif.proposalDueOverBody', { n: -d }) : d === 0 ? t('store.notif.proposalDueTodayBody') : t('store.notif.proposalDueInBody', { n: d }))
            + (proposalPending ? t('store.notif.proposalPending') : ''), link: { screen: 'case', id: k.id } });
      }
    }
    // 放置検知：動きなし（未来商談なし・最終更新から7日以上）。新規のまま放置は new_case と二重にせずこちらに集約
    const idle = caseIdleInfo(k);
    if (idle) {
      out.push({ id: 'stale-' + k.id, type: 'stale', _sort: 30 + Math.max(0, 60 - idle.idleDays) / 100, _date: k.updatedAt || k.createdAt, when: t('notif.idleDays',{n:idle.idleDays}),
        title: t(k.status === 'new' ? 'notif.stillNew' : 'notif.followupStopped',{name:custName(k)}),
        body: t('notif.idleBody',{title:short(k.title),n:idle.idleDays}), link: { screen: 'case', id: k.id } });
    }
    // 新着（未対応）※放置通知が出ている場合はそちらに集約（同じ案件で2通鳴らさない）
    if (k.status === 'new' && !idle) {
      out.push({ id: 'new-' + k.id, type: 'new_case', _sort: 50, _date: k.createdAt || k.updatedAt, when: t('store.notif.unhandled'),
        title: t('store.notif.newCaseTitle', { cust: custName(k) }), body: short(k.title), link: { screen: 'case', id: k.id } });
    }
  }
  out.sort((a, b) => a._sort - b._sort); // 超過→間近→新着 の順（緊急度）
  return out.slice(0, 30);
}

function StoreProvider({ children }) {
  const D = window.APP_DATA;
  const [route, setRoute] = React.useState({ screen: 'login', id: null, tab: null });
  const [workspaceConfig, setWorkspaceConfig] = React.useState(() => D.workspaceConfig);
  const [workspaceInbox, setWorkspaceInbox] = React.useState(() => D.workspaceInbox);
  const [workspaceId, setWorkspaceId] = React.useState('system');
  const [chatOpen, setChatOpen] = React.useState(false);
  // Preserve APP_DATA array identities: legacy lookup helpers close over these arrays.
  const cacheCase = row => { const i=D.cases.findIndex(c=>c.id===row.id); if(i<0)D.cases.push(row);else D.cases[i]=row; };
  const workspacePrefs = uid => 'anken_workspace_' + uid;
  const applyWorkspaceState = state => {
    D.workspaceConfig = state.config; D.workspaceInbox = state.inbox;
    setWorkspaceConfig(state.config); setWorkspaceInbox(state.inbox);
  };
  const refreshWorkspaceInbox = async (classify = false) => {
    const state = await (classify && can('workspaceTriage') ? API.refreshWorkspace() : API.workspaceState());
    applyWorkspaceState(state); return state;
  };
  const saveWorkspaceConfig = async cfg => {
    const checked = ASM_WORKSPACES.validateConfig(cfg,{partial:!isAdmin});
    try {
      const saved = await API.saveWorkspaceConfig(checked);
      D.workspaceConfig = saved; setWorkspaceConfig(saved); showToast(t("ws.saved"));
      return saved;
    } catch(e) { if (e.status === 409) await refreshWorkspaceInbox().catch(() => {}); throw e; }
  };
  const resolveWorkspaceItem = async (id, targetId) => {
    const item = await API.resolveWorkspace(id, targetId);
    setWorkspaceInbox(rows => rows.map(r => r.id === id ? item : r));
    showToast(t("ws.confirmed")); return item;
  };
  const ignoreWorkspaceItem = async (id, ignored) => {
    const item=await API.ignoreWorkspace(id,ignored);
    setWorkspaceInbox(rows=>rows.map(r=>r.id===id?item:r));
    D.workspaceInbox=D.workspaceInbox.map(r=>r.id===id?item:r);
    showToast(ignored?t("triage.ignoredSaved"):t("triage.restoredSaved"));return item;
  };
  const importWorkspaceMeeting = async id => {
    const row=await API.importWorkspaceMeeting(id);
    setCases(rows=>rows.map(c=>c.id===id?row:c));cacheCase(row);return row;
  };
  const moveCaseWorkspace = async (id, targetId) => {
    const result = await API.moveCaseWorkspace(id, targetId), c = result.case;
    if (result.log) { setLogs(rows => [result.log,...rows.filter(r=>r.id!==result.log.id)]); if(!D.caseLogs.some(r=>r.id===result.log.id))D.caseLogs.unshift(result.log); }
    setCases(rows => rows.map(r => r.id === id ? c : r));
    cacheCase(c);
    const followSource = r => r.caseId === id ? {...r,workspaceId:c.workspaceId,workspaceIds:ASM_WORKSPACES.idsOf(c)} : r;
    setEmails(rows => rows.map(followSource));
    [D.emails,D.firefliesMeetings].forEach(rows=>rows.forEach((r,i)=>{rows[i]=followSource(r);}));
    if (workspaceId !== 'all') setWorkspaceId(c.workspaceId);
    try { if (workspaceId !== 'all') localStorage.setItem(workspacePrefs(currentUser.id),c.workspaceId); } catch (_) {}
    await refreshWorkspaceInbox().catch(() => showToast(t("ws.movedRefreshHint")));
    return c;
  };
  const createWorkspaceCase = async id => {
    const c = await API.createWorkspaceCase(id);
    let data;
    try { data = await API.bootstrap(); } catch (_) { throw new Error(t("triage.createdRefreshError")); }
    hydrateAppData(data); loginAs(currentUser.id);
    setWorkspaceId(c.workspaceId); return c;
  };
  const switchWorkspace = id => {
    if (id !== 'all' && !workspaceConfig.workspaces.some(w => w.id === id && w.active)) return;
    setWorkspaceId(id); setChatOpen(false);
    try { localStorage.setItem(workspacePrefs(currentUser.id),id); } catch (_) {}
    const parent = {case:'cases',customer:'customers',apoLead:'apo'}[route.screen];
    navigate(parent || route.screen, null, null);
  };

  const [cases, setCases] = React.useState(() => D.cases.map(c => ({ ...c })));
  const [meetings, setMeetings] = React.useState(() => D.meetings.map(m => ({ ...m })));
  const [logs, setLogs] = React.useState(() => D.caseLogs.map(l => ({ ...l })));
  const [toast, setToast] = React.useState(null);
  const [currentUserId, setCurrentUserId] = React.useState(D.CURRENT_USER_ID);
  const [usersV, setUsersV] = React.useState(0); // D.users の変更を再描画に反映
  const [integrations, setIntegrations] = React.useState(() => D.userIntegrations.slice());
  const [paletteOpen, setPaletteOpen] = React.useState(false); // ⌘K コマンドパレット
  const [langV, setLangV] = React.useState(0); // 言語切替で全体を再描画
  const currentUser = D.user(currentUserId) || D.users[0] || null;
  // 権限判定。★ログイン前/ブートストラップ前は currentUser が未確定のため role 参照は必ずガード
  const userRole = currentUser && currentUser.role;
  const [permsV, setPermsV] = React.useState(0);
  const can = (action) => canRole(userRole, action);
  const isManager = roleRank(userRole) >= 2;
  const isAdmin = userRole === 'admin';
  const canManageWorkspaces=['workspaceCreate','workspaceEdit','workspaceDelete'].some(can);
  const [skillCatalog,setSkillCatalog]=React.useState(null);
  const [skillCatalogError,setSkillCatalogError]=React.useState('');
  const refreshSkillCatalog=async()=>{try{const data=await API.skillCatalog();setSkillCatalog(data);setSkillCatalogError('');return data;}catch(e){setSkillCatalogError(e.message);throw e;}};
  const createSkill=async data=>{const result=await API.skillCreate(data);await refreshSkillCatalog();return result;};
  const updateSkill=async(id,data)=>{const result=await API.skillUpdate(id,data);await refreshSkillCatalog();return result;};
  const setDefaultSkill=async id=>{const result=await API.skillDefault(id,skillCatalog.revision);await refreshSkillCatalog();return result;};
  const saveProposalSkills=async(id,choices)=>{await API.patchAwait('cases',id,{proposalSkillIds:choices});setCases(rows=>rows.map(c=>c.id===id?{...c,proposalSkillIds:choices}:c));};
  React.useEffect(()=>{if(currentUserId&&route.screen!=='login')refreshSkillCatalog().catch(()=>{});},[currentUserId]);
  const joinedWorkspaces=ASM_WORKSPACES.visibleWorkspaces(workspaceConfig,currentUser);
  const defaultWorkspaceId=joinedWorkspaces.find(w=>w.active)?.id||'';
  // 権限設定の保存（管理者・設定→権限）。D.permissions を更新→サーバー保存→再描画
  const savePermissions = async (cfg) => { await API.savePermissions(cfg); window.APP_DATA.permissions = cfg; setPermsV(v => v + 1); };

  // i18n：言語切替時に再描画をトリガー
  React.useEffect(() => { window.__onLangChange = () => setLangV(v => v + 1); return () => { window.__onLangChange = null; }; }, []);
  const setLang = (l) => { if (window.setLang) window.setLang(l); };

  /* 通知：実案件データから自動生成。既読は localStorage に保持 */
  const loadReadIds = () => { try { return JSON.parse(localStorage.getItem('anken_read_notifs')) || []; } catch (_) { return []; } };
  const [readIds, setReadIds] = React.useState(loadReadIds);
  const persistReadIds = (ids) => { try { localStorage.setItem('anken_read_notifs', JSON.stringify(ids.slice(-400))); } catch (_) {} };
  const derivedNotifs = React.useMemo(() => deriveNotifications(workspaceId === 'all' ? cases : cases.filter(c => ASM_WORKSPACES.contains(c, workspaceId)), D, currentUser), [cases, currentUserId, usersV, workspaceId]);
  const notifs = derivedNotifs.map(n => ({ ...n, at: n._date, read: readIds.includes(n.id) }));

  // ---------- URL パス ↔ route 同期（各画面に専用のクリーンURL・共有/リロード/戻る進む対応） ----------
  // 例: /dashboard /cases /case/k123 /case/k123/mail /apoLead/rcl1 /analytics /customer/c1
  // ※ クリーンURL（# なし）。リロード/直リンクは vercel.json の rewrite で全パス→index.html に。
  const lastPath = React.useRef('');
  const pathFromRoute = (r) => {
    if (!r || !r.screen || r.screen === 'login') return '/';
    let s = '/' + r.screen;
    if (r.id) s += '/' + encodeURIComponent(r.id);
    if (r.tab && typeof r.tab === 'string') s += '/' + encodeURIComponent(r.tab); // フィルタpreset等のオブジェクトtabはURLに含めない
    return s;
  };
  const screenFromPath = () => {
    const segs = ((location.pathname || '/').replace(/^\/+/, '')).split('/').filter(Boolean);
    if (!segs.length) return null;
    if (segs[0] === 'settings') return {screen:'settings',id:null,tab:segs[1] || null};
    return { screen: segs[0], id: segs[1] ? decodeURIComponent(segs[1]) : null, tab: segs[2] ? decodeURIComponent(segs[2]) : null };
  };
  const writePath = (r, replace = false) => {
    const p = pathFromRoute(r);
    lastPath.current = p;
    try { if ((location.pathname || '/') !== p) history[replace ? 'replaceState' : 'pushState'](null, '', p); } catch (_) {}
  };

  // ログイン：サーバーから取得したデータで全ステートを初期化し直す
  const loginAs = (userId) => {
    setCases(D.cases.map(c => ({ ...c })));
    setMeetings(D.meetings.map(m => ({ ...m })));
    setLogs(D.caseLogs.map(l => ({ ...l })));
    setCustomers(D.customers.slice());
    setKnowledge(D.knowledge.slice());
    setQuestions(D.questions.slice());
    setRcLeads(D.rcLeads.slice());
    setEmails(D.emails.slice());
    setAiReports(D.aiReports.slice());
    setIntegrations(D.userIntegrations.slice());
    setUsersV(v => v + 1);
    setCurrentUserId(userId);
    setWorkspaceConfig(D.workspaceConfig); setWorkspaceInbox(D.workspaceInbox);
    let preferred = 'system';
    try { preferred = localStorage.getItem(workspacePrefs(userId)) || 'system'; } catch (_) {}
    const available=ASM_WORKSPACES.visibleWorkspaces(D.workspaceConfig,D.user(userId));
    if (preferred !== 'all' && !available.some(w => w.id === preferred && w.active)) preferred = available.find(w=>w.active)?.id||'all';
    const targetPath = screenFromPath();
    if (targetPath && targetPath.screen === 'case') {
      const target = D.cases.find(c => c.id === targetPath.id);
      if (target && preferred !== 'all' && !ASM_WORKSPACES.contains(target,preferred)) preferred = ASM_WORKSPACES.idOf(target);
    }
    setWorkspaceId(preferred);
    // URLパスに画面指定があればそこへ（共有リンク/リロード対応）。無ければダッシュボード
    const sp = screenFromPath();
    const r = (sp && sp.screen && sp.screen !== 'login') ? sp : { screen: 'dashboard', id: null, tab: null };
    setRoute(r); writePath(r, true); // 初回は replace（履歴を増やさない）
    window.scrollTo(0, 0);
    setTimeout(() => { if (typeof autoSyncGcal === 'function') autoSyncGcal(); }, 0);
  };
  const logout = () => { try { if (window.googleSignOutLocal) window.googleSignOutLocal(); } catch (_) {} API.logout(); setRoute({ screen: 'login', id: null, tab: null }); lastPath.current = '/'; try { history.replaceState(null, '', '/'); } catch (_) {} };

  // セッション復元：保存済みトークンがあれば自動でログイン状態に戻す。
  // ★ 暫時的な失敗（ネットワーク断・サーバ再起動中の5xx等）でトークンを破棄しない。
  //   破棄＝強制再ログインは「リロード時の再認証を抑止」の方針に反するため、
  //   本当にトークンが無効な時（401）だけログアウトし、それ以外は数回リトライする。
  React.useEffect(() => {
    const saved = API.savedUser();
    if (!saved || !API.hasToken()) return;
    let cancelled = false;
    const restore = (attempt = 0) => {
      API.bootstrap()
        .then(data => { if (cancelled) return; window.hydrateAppData(data); loginAs(saved.id); autoSyncGcal(); autoCategorize(); })
        .catch(err => {
          if (cancelled) return;
          if (err && err.status === 401) { API.logout(); return; } // トークン無効＝ログアウト
          if (attempt < 3) { setTimeout(() => restore(attempt + 1), 1500 * (attempt + 1)); return; } // 暫時的失敗＝リトライ（トークンは保持）
          console.warn('[restore] bootstrap failed, session kept for retry'); // リトライ尽きてもトークンは消さない
        });
    };
    restore();
    return () => { cancelled = true; };
  }, []);

  // Google カレンダー：接続済み かつ 有効なキャッシュトークンがある時だけ、起動時にサイレント同期。
  // キャッシュが無い（＝初回 or 期限切れ）時は GIS を呼ばない＝再読込のたびに Google 認証ポップアップを出さない。
  // 再接続はカレンダー画面の「カレンダー取込」からユーザー操作で行う（その時トークンが再キャッシュされる）。
  const autoSyncGcal = () => {
    if (!window.gcalAutoEnabled || !window.gcalAutoEnabled()) return;
    if (!window.gcalHasToken || !window.gcalHasToken()) return; // ★ ポップアップ抑止：トークン未キャッシュなら何もしない
    window.gcalPrepare()
      .then(() => window.syncGoogleCalendar(false)) // キャッシュ済みトークンを使うのでGISは呼ばれない
      .then(n => { if (n != null) API.bootstrap().then(d => { window.hydrateAppData(d); setCases(D.cases.map(c => ({ ...c }))); }); })
      .catch(() => {}); // 失敗時は無視
  };

  // ReadyCrew取込の案件は取得元の「案件カテゴリ」を確定情報として持つ（originalTitle="社名 カテゴリ名/サブ…"）。
  // これをアプリのプリセットへ写像して categories に反映する＝AI推測より確実・即時・無料。詳細取得でも originalTitle は
  // 上書きされないので後からでも安全に拾える。categorizedAt 済み（＝AIが空を返した案件）でも categories が空なら補完し、
  // AIの取りこぼし（例：システム開発/AI 案件が「—」のまま）を一次情報で救済する。
  const RC_CAT_MAP = { 'AI': 'AI・データ活用', 'WEBマーケティング': 'マーケティング・SEO', 'モバイルアプリ開発': 'アプリ開発', 'EC': 'EC構築' };
  const rcCategoryName = (c) => {
    const ot = (c && c.originalTitle) || '';
    const oi = ot.indexOf('/'); if (oi < 0) return '';             // 案件カテゴリは必ず "A/B" 形式（社名に "/" は含まれない前提）
    const sp = ot.lastIndexOf(' ', oi); if (sp < 0) return '';
    return ot.slice(sp + 1).split('/')[0].trim();                  // 先頭トークン＝カテゴリ名
  };
  const seedCategoriesFromSource = () => {
    const ids = new Set();
    D.cases.forEach(c => {
      if (!c || (c.categories && c.categories.length) || c.source !== 'scrape') return; // ReadyCrew取込のみ（元カテゴリが確実に載る）
      const raw = rcCategoryName(c); if (!raw) return;
      patchCase(c.id, { categories: [RC_CAT_MAP[raw] || raw] });   // プリセット名 or 生カテゴリ名
      ids.add(c.id);
    });
    return ids;
  };

  // 起動時：カテゴリ未設定の案件を自動でカテゴリ付け。まず取得元カテゴリ（確定情報）で埋め、残り（Gmail/手動 等で
  // 元カテゴリが無い案件）だけAIで判定（最大60件/回・3並列）。これで開かなくても全件・以後の追加分にもカテゴリが付く。
  const autoCategorize = async () => {
    try {
      const seededIds = seedCategoriesFromSource();
      if (seededIds.size > 0) showToast(t('categories.sourceApplied',{n:seededIds.size}));
      const candidates = Array.from(new Set([...(D.CATEGORIES || []), ...D.cases.flatMap(c => c.categories || [])]));
      if (!candidates.length) return;
      const todo = D.cases.filter(c => c && !(c.categories || []).length && !c.categorizedAt && c.title && !seededIds.has(c.id)).slice(0, 60);
      if (!todo.length) return;
      let n = 0;
      for (let i = 0; i < todo.length; i += 3) {
        await Promise.all(todo.slice(i, i + 3).map(async (c) => {
          const stamp = new Date().toISOString();
          try {
            const r = await API.categorize(c.id, candidates);
            const cats = (r.categories || []).filter(Boolean);
            if (cats.length) { n++; patchCase(c.id, { categories: cats, categorizedAt: stamp }); }
            else patchCase(c.id, { categorizedAt: stamp });
          } catch (_) { patchCase(c.id, { categorizedAt: stamp }); }
        }));
      }
      if (n > 0) showToast(t('categories.aiApplied',{n}));
    } catch (_) {}
  };

  // ⌘K / Ctrl+K でコマンドパレットを開閉
  React.useEffect(() => {
    const h = (e) => {
      if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
        e.preventDefault();
        if (currentUserId) setPaletteOpen(o => !o);
      }
    };
    window.addEventListener('keydown', h);
    return () => window.removeEventListener('keydown', h);
  }, [currentUserId]);
  const openPalette = () => setPaletteOpen(true);
  const closePalette = () => setPaletteOpen(false);
  // モバイル：サイドバー（ドロワー）の開閉
  const [mobileNavOpen, setMobileNavOpen] = React.useState(false);
  const openNav = () => setMobileNavOpen(true);
  const closeNav = () => setMobileNavOpen(false);

  // ブラウザの戻る/進む（popstate）でURLパス変化を route に反映（自分の pushState は無視）
  React.useEffect(() => {
    const onPop = () => {
      if ((location.pathname || '/') === lastPath.current) return;
      if (!API.hasToken()) return; // 未ログイン中は無視（ログイン後に loginAs が反映）
      const r = screenFromPath();
      if (r && r.screen) { if (r.screen === 'case' && workspaceId !== 'all') { const target = cases.find(c => c.id === r.id); if (target && !ASM_WORKSPACES.contains(target,workspaceId)) setWorkspaceId(ASM_WORKSPACES.idOf(target)); } lastPath.current = location.pathname; setRoute(r); window.scrollTo(0, 0); }
    };
    window.addEventListener('popstate', onPop);
    return () => window.removeEventListener('popstate', onPop);
  }, [cases, workspaceId]);

  const showToast = (msg, icon = 'check2') => {
    setToast({ msg, icon, t: Date.now() });
    setTimeout(() => setToast(t => (t && Date.now() - t.t >= 3900 ? null : t)), 4000);
  };

  const navigate = (screen, id = null, tab = null) => { if (screen === 'case' && id && workspaceId !== 'all') { const target = cases.find(c => c.id === id) || D.cases.find(c => c.id === id); if (target && !ASM_WORKSPACES.contains(target,workspaceId)) setWorkspaceId(ASM_WORKSPACES.idOf(target)); } const r = { screen, id, tab }; setRoute(r); writePath(r); setMobileNavOpen(false); window.scrollTo(0, 0); };

  /* セッション失効の検知（2026-08-19）：ログイン済みトークンでの401をapi.jsがイベント通知。
     以前は fire-and-forget の書込みが黙って失敗し続け、「次回商談を何度セットしても戻る」ように見えた。
     検知したら赤バナー（app.jsx）で再ログインを促す＝編集が消えるのを気づかず続ける事故を防ぐ */
  const [sessionExpired, setSessionExpired] = React.useState(false);
  React.useEffect(() => {
    const on401 = () => setSessionExpired(true);
    window.addEventListener('anken:unauthorized', on401);
    return () => window.removeEventListener('anken:unauthorized', on401);
  }, []);

  /* カレンダーURL自動追随は撤去（2026-08-19）：gcalEvents にはシステム自動生成のMeetや
     他メンバーのカレンダー由来の予定が混ざり得るため「カレンダー＝正」を無条件で書き込むのは危険と判明。
     食い違いは警告UI＋手動の「カレンダーのURLを案件に反映」ボタン（calendar.jsx/case-meetings.jsx）に一本化 */

  const nowStamp = () => {
    const d = new Date();
    const p = (n) => String(n).padStart(2, '0');
    return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
  };
  const addLog = (caseId, type, text, userId = currentUser && currentUser.id) => {
    const entry = { id: 'l' + Date.now(), caseId, at: nowStamp(), userId, type, text };
    API.post('logs', entry);
    setLogs(ls => [entry, ...ls]);
  };
  // The deck endpoint already saved these fields. Sync locally without a second stale PATCH.
  const syncProposalResult = (caseId, result) => {
    const fields = {};
    if (result.proposalDesign) fields.proposalDesign = result.proposalDesign;
    if (Array.isArray(result.attachments)) fields.attachments = result.attachments;
    setCases(cs => cs.map(c => c.id === caseId ? { ...c, ...fields } : c));
    const old = D.cases.find(c => c.id === caseId);
    if (old) cacheCase({ ...old, ...fields });
  };
  const patchCase = (caseId, fields) => {
    API.patch('cases', caseId, fields);
    setCases(cs => cs.map(c => c.id === caseId ? { ...c, ...fields } : c));
  };

  /* 次の一手（ToDo）：案件の nextActions[] に保存。商談後のAI提案／手動追加で積み、
     ダッシュボードの ToDo・案件詳細で管理（完了トグル・削除）。patchCase で永続化。 */
  const addNextActions = (caseId, actions) => {
    const k = cases.find(c => c.id === caseId);
    const list = (actions || []).filter(a => (a.text || a.head || '').trim());
    if (!k || !list.length) return;
    const now = nowStamp(); const base = Date.now();
    const items = list.map((a, i) => ({ id: 'na' + base + '_' + i, text: (a.text || a.head || '').trim(), detail: a.detail || '', type: a.type || 'other', due: a.due || null, done: false, createdAt: now }));
    patchCase(caseId, { nextActions: [...(k.nextActions || []), ...items] });
    showToast(t('na.toast.added', { n: items.length }));
  };
  const toggleNextAction = (caseId, actionId) => {
    const k = cases.find(c => c.id === caseId); if (!k) return;
    patchCase(caseId, { nextActions: (k.nextActions || []).map(a => a.id === actionId ? { ...a, done: !a.done, doneAt: !a.done ? nowStamp() : null } : a) });
  };
  const removeNextAction = (caseId, actionId) => {
    const k = cases.find(c => c.id === caseId); if (!k) return;
    patchCase(caseId, { nextActions: (k.nextActions || []).filter(a => a.id !== actionId) });
  };

  // Main owner changes and explicit removal are persisted before showing success.
  const assignOwner = async (caseId, userId) => {
    if(!can('assign'))throw new Error(t("assign.denied"));
    const k=cases.find(c=>c.id===caseId);if(!k)return;
    const target=userId?D.user(userId):null;
    if(userId&&!target)throw new Error(t("assign.notFound"));
    const subIds=(k.subIds||[]).filter(s=>s!==userId);
    if((k.ownerId||null)===(userId||null)&&subIds.length===(k.subIds||[]).length)return;
    const fields={ownerId:userId||null,subIds,updatedAt:nowStamp()};
    await API.patchAwait('cases',caseId,fields);
    setCases(rows=>rows.map(c=>c.id===caseId?{...c,...fields}:c));cacheCase({...k,...fields});
    addLog(caseId,'assign',t('store.log.assignOwner',{from:D.user(k.ownerId)?.name||t('store.unassigned'),to:target?.name||t('store.unassigned')}));
    showToast(target?t('store.toast.ownerAssigned',{name:target.name}):t("assign.cleared"));
  };
  // 自己接続：無担当の案件を自分自身が引き受ける（メンバーも可。他人への指派は assign 権限のまま＝サーバ側でも本人・無担当のみ許可）
  const claimCase = (caseId) => {
    const k = cases.find(c => c.id === caseId);
    if (!k || k.ownerId) return;
    const me = D.user(currentUser.id);
    addLog(caseId, 'assign', t('store.log.assignOwner', { from: t('store.unassigned'), to: me.name }));
    patchCase(caseId, { ownerId: currentUser.id, updatedAt: nowStamp() });
    showToast(t('store.toast.ownerAssigned', { name: me.name }));
  };
  const toggleSub = (caseId, userId) => {
    const k = cases.find(c => c.id === caseId);
    if (!k || k.ownerId === userId) return;
    const has = k.subIds.includes(userId);
    const subIds = has ? k.subIds.filter(s => s !== userId) : [...k.subIds, userId];
    addLog(caseId, 'assign', has ? t('store.log.subRemoved', { name: D.user(userId).name }) : t('store.log.subAdded', { name: D.user(userId).name }));
    patchCase(caseId, { subIds });
  };
  const changeStatus = (caseId, status) => {
    const k = cases.find(c => c.id === caseId);
    if (!k) return;
    addLog(caseId, 'status', t('store.log.statusChanged', { from: D.STATUS[k.status].label, to: D.STATUS[status].label }));
    // 成約日（wonAt）：成約(win)になった時刻を記録・成約から外したら消す。
    // 「成約」は 成約(won)＋受注(done) の両方を含む（2026-07-16 受注が成約日を持たず数字に出ない不具合の修正）。
    // 分析の「その月中に成約した件数」（成約日ベース）の基準（2026-07-15 ユーザー指定）
    const WIN = ['won', 'done'];
    const wasWin = WIN.includes(k.status), nowWin = WIN.includes(status);
    const patch = { status, updatedAt: nowStamp() };
    if (nowWin && !wasWin) patch.wonAt = nowStamp();
    if (!nowWin && wasWin) patch.wonAt = null;
    patchCase(caseId, patch);
    showToast(t('store.toast.statusChanged', { status: D.STATUS[status].label }));
  };
  // アーカイブ（封存）：成件後の継続会議など、アクティブ営業案件として扱わない案件。一覧から既定で除外（フィルタで閲覧可）。
  // 同一顧客/タイトルの会議は従来どおり自動でこの案件に集約され続ける（computeFfTargetsは既存案件に添付するため）。
  const archiveCase = (caseId, on) => {
    const k = cases.find(c => c.id === caseId);
    if (!k) return;
    addLog(caseId, 'status', on ? t("cases.archivedToast") : t("cases.unarchivedToast"));
    patchCase(caseId, { archived: !!on, archivedAt: on ? nowStamp() : null, updatedAt: nowStamp() });
    showToast(on ? t("cases.archivedToast") : t("cases.unarchivedToast"));
  };
  const addMeeting = (caseId, m) => {
    const row = { id: 'm' + Date.now(), caseId, ...m };
    API.post('meetings', row);
    setMeetings(ms => [row, ...ms]);
    addLog(caseId, 'meeting', t('store.log.meetingAdded', { method: D.METHODS[m.method].label }));
    if (m.nextMeeting) patchCase(caseId, { nextMeeting: m.nextMeeting, nextMeetingMethod: m.nextMeetingMethod || null, updatedAt: nowStamp() });
    showToast(m.calendar ? t('store.toast.meetingRecordedWithCal') : t('store.toast.meetingRecorded'));
  };
  // 商談記録の部分更新（顧客評価など）
  const updateMeeting = (meetingId, fields) => {
    API.patch('meetings', meetingId, fields);
    setMeetings(ms => ms.map(m => m.id === meetingId ? { ...m, ...fields } : m));
  };
  // 商談記録の削除（サーバ側で管理者/作成者のみ許可）。予定登録の誤用で出来た空記録の掃除用
  const removeMeeting = async (meetingId) => {
    await API.deleteMeeting(meetingId);
    setMeetings(ms => ms.filter(m => m.id !== meetingId));
    showToast(t('cd.meeting.deleted'), 'x');
  };

  /* 重複案件のソフト統合（顧客統合と同方式）：同じ商談が旧khn+現行khnv等で二重登録された案件を、
     残す案件(primaryId)へ商談記録を付け替え＋重複側に mergedInto を立てて一覧から隠す（可逆・railway不要）。
     表示除外は realCases チョークポイントで一括（個別画面のフィルタ不要）。 */
  const mergeCases = (primaryId, dupIds) => {
    const primary = cases.find(c => c.id === primaryId);
    dupIds = (dupIds || []).filter(id => id && id !== primaryId);
    if (!primary || !dupIds.length) return { moved: 0 };
    // 1) 重複側の商談記録を primary へ付け替え
    let moved = 0;
    meetings.forEach(m => { if (dupIds.includes(m.caseId)) { API.patch('meetings', m.id, { caseId: primaryId }); moved++; } });
    if (moved) setMeetings(ms => ms.map(m => dupIds.includes(m.caseId) ? { ...m, caseId: primaryId } : m));
    // 2) primary の空フィールドを重複側で補完（商談日・本文が重複側にしか無いケース）
    const fill = {};
    dupIds.forEach(did => {
      const d = cases.find(c => c.id === did); if (!d) return;
      if (!primary.appointAt && !primary.appointAtLive && !primary.nextMeeting && (d.appointAt || d.appointAtLive)) { if (!fill.appointAtLive) fill.appointAtLive = d.appointAt || d.appointAtLive; }
      if (String(primary.body || '').length < 50 && String(d.body || '').length >= 50 && !fill.body) fill.body = d.body;
    });
    if (Object.keys(fill).length) patchCase(primaryId, fill);
    // 3) 重複をソフト統合
    dupIds.forEach(did => patchCase(did, { mergedInto: primaryId }));
    showToast(t('case.merge.toastDone', { n: dupIds.length, m: moved }));
    return { moved };
  };
  const unmergeCase = (id) => { patchCase(id, { mergedInto: null }); showToast(t('cust.merge.undone')); };
  const setRank = (caseId, rank) => {
    const k = cases.find(c => c.id === caseId);
    if (!k || k.rank === rank) return;
    addLog(caseId, 'rank', t('store.log.rankChanged', { from: k.rank, to: rank }));
    patchCase(caseId, { rank, updatedAt: nowStamp() });
    showToast(t('store.toast.rankChanged', { rank }));
  };
  // カテゴリ（複数タグ）の更新。タグ追加/削除のたびに呼ばれるためトーストは出さない（静かに保存）
  const setCategories = (caseId, categories) => {
    const k = cases.find(c => c.id === caseId);
    if (!k) return;
    patchCase(caseId, { categories, updatedAt: nowStamp() });
  };
  const saveCase = (data) => {
    if (data.id) {
      patchCase(data.id, { ...data, updatedAt: nowStamp() });
      showToast(t('store.toast.caseUpdated'));
      return data.id;
    } else {
      const id = 'k' + Date.now();
      // ※ data には新規時 id:undefined が含まれるため、...data の「後」に id を置いて上書きさせない（さもないと id 欠落でサーバー400→保存されず消える）
      const row = { workspaceId: workspaceId === 'all' ? defaultWorkspaceId : workspaceId, source: 'manual', sourceUrl: '', sourceNo: 'MAN-' + Date.now().toString().slice(-6),
        subIds: [], categories: [], nextMeeting: null, rank: 'B', proposalDue: null, note: '', ...data, id, createdAt: nowStamp(), updatedAt: nowStamp() };
      API.post('cases', row);
      setCases(cs => [row, ...cs]);
      addLog(id, 'create', t('store.log.manualCreate'));
      showToast(t('store.toast.caseCreated'));
      return id;
    }
  };
  const saveCaseForm = async data => {
    const old = data.id && cases.find(c => c.id === data.id);
    const workspace = data.workspaceId || (workspaceId === 'all' ? defaultWorkspaceId : workspaceId);
    const fields = {...data, updatedAt:nowStamp()};
    delete fields.workspaceId;
    if (old) {
      if (old.ownerId === fields.ownerId) delete fields.ownerId;
      if (JSON.stringify(old.subIds || []) === JSON.stringify(fields.subIds || [])) delete fields.subIds;
      if (workspace !== ASM_WORKSPACES.idOf(old)) throw new Error(t("ws.moveHint"));
      await API.patchAwait('cases', old.id, fields);
      cacheCase({...old,...fields,workspaceId:workspace});
      setCases(rows => rows.map(c => c.id === old.id ? {...c,...fields,workspaceId:workspace} : c));
      showToast(t('store.toast.caseUpdated')); return old.id;
    }
    const id = 'k' + crypto.randomUUID();
    const row = {source:'manual',sourceNo:'MAN-'+Date.now(),sourceUrl:'',subIds:[],categories:[],rank:'B',nextMeeting:null,note:'',...fields,id,workspaceId:workspace,createdAt:nowStamp()};
    await API.postAwait('cases', row);
    cacheCase(row);
    setCases(rows => [row,...rows]);
    if (workspaceId !== 'all') setWorkspaceId(workspace);
    addLog(id,'create',t('store.log.manualCreate')); showToast(t('store.toast.caseCreated')); return id;
  };
  // 案件の削除（管理者＝caseDelete権限）。await＋失敗時throw（呼び出し側で確認ダイアログ）
  const removeCase = async (caseId) => {
    await API.deleteCase(caseId);
    const i = D.cases.findIndex(c => c.id === caseId);
    if (i >= 0) D.cases.splice(i, 1);
    setCases(D.cases.map(c => ({ ...c })));
    showToast(t('store.toast.caseDeleted'), 'x');
  };
  // 会議リンク：案件に追記（二次商談などは上書きせず末尾に追加）
  const addMeetingLink = (caseId, link) => {
    const k = cases.find(c => c.id === caseId);
    if (!k) return;
    const links = [...(k.meetingLinks || []), { id: 'ml' + Date.now(), ...link }];
    patchCase(caseId, { meetingLinks: links });
    showToast(t('store.toast.meetingLinkAdded'));
  };
  const removeMeetingLink = (caseId, linkId) => {
    const k = cases.find(c => c.id === caseId);
    if (!k) return;
    patchCase(caseId, { meetingLinks: (k.meetingLinks || []).filter(l => l.id !== linkId) });
    showToast(t('store.toast.meetingLinkRemoved'), 'x');
  };
  // カレンダー：商談予定の登録・変更・取り消し（案件の nextMeeting を更新）
  const scheduleMeeting = (caseId, datetime, method) => {
    const k = cases.find(c => c.id === caseId);
    if (!k) return;
    const fmt = (dt) => `${fmtDate(dt)} ${fmtTime(dt)}`;
    /* 電話はカレンダー・商談日（次回商談）に入れない＝ToDo（次の一手）として積む（2026-07-08 方針）。
       手動の商談日に入っていた分は外し、nextMeetingMethod='phone' を立てて
       ReadyCrew由来の appointAt が電話案件でカレンダーに出るのも防ぐ（buildEvents が phone を除外）。 */
    if (datetime && method === 'phone') {
      const item = { id: 'na' + Date.now(), text: t('store.phoneTodoText', { datetime: fmt(datetime) }), detail: '', type: 'call', due: String(datetime).slice(0, 10), done: false, createdAt: nowStamp() };
      addLog(caseId, 'meeting', t('store.log.phoneTodo', { datetime: fmt(datetime) }));
      const fields = { nextActions: [...(k.nextActions || []), item], nextMeetingMethod: 'phone', updatedAt: nowStamp() };
      if (k.nextMeeting) { fields.nextMeeting = null; fields.meetingManual = true; }
      patchCase(caseId, fields);
      showToast(t('store.toast.phoneTodo'), 'check2');
      return;
    }
    const cur = k.nextMeeting || k.appointAt; // 表示中の商談日時（appointAt=ReadyCrew商談日も含む）
    addLog(caseId, 'meeting', datetime
      ? (cur ? t('store.log.meetingRescheduled', { from: fmt(cur), to: fmt(datetime) }) : t('store.log.meetingRegistered', { datetime: fmt(datetime) }))
      : t('store.log.meetingCancelled', { time: cur ? fmt(cur) : '' }));
    // 「次回商談」の編集を正とする：appointAt(ReadyCrew商談日)も合わせて更新し、未定にしても残る取り残しを防ぐ
    // method（phone/online/visit）＝次回予定の手段。指定があれば更新、キャンセル時はクリア（電話予定を電話として表示するため）
    patchCase(caseId, { nextMeeting: datetime, nextMeetingMethod: datetime ? (method || k.nextMeetingMethod || null) : null, appointAt: null, appointAtLive: null, meetingManual: true, updatedAt: nowStamp() });
    showToast(datetime ? t('store.toast.meetingScheduledWithCal') : t('store.toast.meetingScheduleCancelled'), datetime ? 'check2' : 'x');
  };
  const markRead = (nid) => setReadIds(ids => {
    if (ids.includes(nid)) return ids;
    const next = [...ids, nid]; persistReadIds(next); return next;
  });
  const markAllRead = () => setReadIds(ids => {
    const next = Array.from(new Set([...ids, ...derivedNotifs.map(n => n.id)])); persistReadIds(next); return next;
  });
  const [customers, setCustomers] = React.useState(() => D.customers.slice());
  const addCustomer = (data) => {
    const id = 'cu' + Date.now();
    const nc = { id, workspaceId: workspaceId === 'all' ? defaultWorkspaceId : workspaceId, shortName: data.shortName || (data.company || '').slice(0, 2), contact: '', tel: '', email: '', address: '', ...data, id };
    API.post('customers', nc);
    D.customers.push(nc);
    setCustomers(D.customers.slice());
    showToast(t('store.toast.customerAdded'));
    return id;
  };
  const updateCustomer = (id, data) => {
    const idx = D.customers.findIndex(c => c.id === id);
    if (idx >= 0) {
      API.patch('customers', id, data);
      D.customers[idx] = { ...D.customers[idx], ...data };
      setCustomers(D.customers.slice());
      showToast(t('store.toast.customerUpdated'));
    }
  };
  // 重複顧客のソフト統合：残す顧客(primaryId)に、重複(dupIds)の案件を付け替え＋空フィールドを補完し、
  // 重複側に mergedInto を立てて一覧・ピッカーから隠す（DBの行は残す＝あとで取り消せる／railway不要）。
  const CUST_FILL_FIELDS = ['shortName', 'contact', 'contactDept', 'contactGender', 'contactRole', 'tel', 'email', 'contactNote', 'address', 'url', 'industry', 'employees', 'founded', 'capital', 'sales', 'fiscalMonth', 'companyOverview', 'note'];
  const mergeCustomers = (primaryId, dupIds) => {
    const primary = D.customers.find(c => c.id === primaryId);
    dupIds = (dupIds || []).filter(id => id && id !== primaryId);
    if (!primary || !dupIds.length) return { moved: 0 };
    // 1) primary の空フィールドを重複側の値で補完（primary の既存値を優先）
    const fill = {};
    dupIds.forEach(did => {
      const d = D.customers.find(c => c.id === did); if (!d) return;
      CUST_FILL_FIELDS.forEach(k => { if (!String(primary[k] || '').trim() && !String(fill[k] || '').trim() && String(d[k] || '').trim()) fill[k] = d[k]; });
    });
    if (Object.keys(fill).length) {
      API.patch('customers', primaryId, fill);
      const pi = D.customers.findIndex(c => c.id === primaryId); if (pi >= 0) D.customers[pi] = { ...D.customers[pi], ...fill };
    }
    // 2) 重複に紐づく案件を primary に付け替え（patchCase＝API＋cases状態を更新）
    let moved = 0;
    cases.forEach(k => { if (dupIds.includes(k.customerId)) { patchCase(k.id, { customerId: primaryId }); moved++; } });
    // 3) 重複顧客をソフト統合（mergedInto＝一覧・ピッカーから除外）
    dupIds.forEach(did => {
      API.patch('customers', did, { mergedInto: primaryId });
      const i = D.customers.findIndex(c => c.id === did); if (i >= 0) D.customers[i] = { ...D.customers[i], mergedInto: primaryId };
    });
    setCustomers(D.customers.slice());
    showToast(t('store.toast.customersMerged', { n: dupIds.length, m: moved }));
    return { moved };
  };
  // 統合の取り消し（mergedInto を外して一覧に戻す。案件の付け替えは残る＝表示上の復活のみ）
  const unmergeCustomer = (id) => {
    const i = D.customers.findIndex(c => c.id === id);
    if (i >= 0) { API.patch('customers', id, { mergedInto: null }); D.customers[i] = { ...D.customers[i], mergedInto: null }; setCustomers(D.customers.slice()); showToast(t('cust.merge.undone')); }
  };
  const visibleCustomers = React.useMemo(() => customers.filter(c => !c.mergedInto), [customers]);

  /* ---------- アポ管理：ReadyCrew リード（取込みはブラウザ取込みスクリプト→サーバー、ここでは表示＋AI評価） ---------- */
  const [rcLeads, setRcLeads] = React.useState(() => D.rcLeads.slice());
  // AI 一括評価（未評価のみ or 指定）。await・失敗時 throw。結果を rcLeads に反映
  const scoreLeads = async (ids) => {
    const r = await API.apoScore(ids);
    (r.updated || []).forEach(u => { const i = D.rcLeads.findIndex(l => l.id === u.id); if (i >= 0) D.rcLeads[i] = Object.assign({}, D.rcLeads[i], u); });
    setRcLeads(D.rcLeads.slice());
    return r;
  };
  const genEntryLetter = (leadId) => API.apoEntryLetter(leadId); // {letter} を返す
  // アポ管理のCRM手動ステータス（応募する/見送り等）。楽観的更新＋fire-and-forget
  const setApoLeadStatus = (leadId, status) => {
    const i = D.rcLeads.findIndex(l => l.id === leadId);
    if (i >= 0) { D.rcLeads[i] = Object.assign({}, D.rcLeads[i], { apoStatus: status }); setRcLeads(D.rcLeads.slice()); }
    API.apoLeadStatus(leadId, status);
  };
  // ブックマークレット取込み後にサーバーから再取得してリードを反映。{total, added} を返す
  const reloadLeads = async () => {
    const before = new Set(D.rcLeads.map(l => l.id));
    const data = await API.bootstrap();
    window.hydrateAppData(data);
    setRcLeads(D.rcLeads.slice());
    setAiReports(D.aiReports.slice()); // bootstrap で AIレポート履歴も最新化されるため state に反映
    const added = D.rcLeads.filter(l => !before.has(l.id)).length;
    return { total: D.rcLeads.length, added };
  };

  /* ---------- メール履歴（Gmail）：顧客単位。取得はブラウザ側 gmail.readonly → サーバー保存 ---------- */
  const [emails, setEmails] = React.useState(() => D.emails.slice());
  const emailsOf = (customerId) => workspaceData.emails.filter(e => e.customerId === customerId).sort((a, b) => (b.date || '').localeCompare(a.date || ''));
  // 顧客の往来メールを Gmail から同期（初回は同意ダイアログ）→ 保存後に再取得して反映
  const syncCustomerGmail = async (customer) => {
    const selfDomain = (currentUser.email || '').split('@')[1] || '';
    await window.gmailPrepare();
    // 未連携 or トークンキャッシュ切れなら interactive（＝この同期ボタンのクリック文脈で認可ポップアップを開く）。
    // 非interactiveのキャッシュ切れは api.js 側で GIS を呼ばず失敗する仕様（勝手にアカウント選択画面を出さない）
    const interactive = !(window.gmailAutoEnabled && window.gmailAutoEnabled()) || !(window.gmailHasToken && window.gmailHasToken());
    const n = await window.syncGmailForCustomer(customer, selfDomain, interactive);
    const data = await API.bootstrap();
    window.hydrateAppData(data);
    setEmails(D.emails.slice());
    setAiReports(D.aiReports.slice()); // bootstrap で AIレポート履歴も最新化されるため state に反映
    showToast(t('store.toast.gmailSynced', { n }));
    return n;
  };

  /* ---------- AIレポート履歴（分析AI総評・エントリ提案信など）。生成成功時に保存し、後から調閲・削除 ---------- */
  const [aiReports, setAiReports] = React.useState(() => D.aiReports.slice());
  // 生成テキストを履歴へ保存（楽観的更新＋fire-and-forget）。{type, title, content, refId?, meta?} を受け取る
  const saveAiReport = ({ type, title, content, refId = null, meta = {} }) => {
    if (!content || !currentUser) return null;
    // id は時刻＋乱数で生成（同一ミリ秒の連続生成でも衝突しないように）
    const row = { id: 'air' + Date.now() + Math.random().toString(36).slice(2, 6), type, title: title || '', content, refId, meta,
      workspaceId:workspaceId==='all'?defaultWorkspaceId:workspaceId,workspaceIds:workspaceId==='all'?joinedWorkspaces.map(w=>w.id):[workspaceId],createdAt: nowStamp(), createdBy: currentUser.id, createdByName: currentUser.name };
    API.post('aiReports', row);
    D.aiReports.unshift(row);
    setAiReports(D.aiReports.slice());
    return row;
  };
  // 履歴の削除（await＋失敗時 throw）。確認は呼び出し側UIで
  const removeAiReport = async (id) => {
    await API.deleteAiReport(id);
    const i = D.aiReports.findIndex(r => r.id === id);
    if (i >= 0) D.aiReports.splice(i, 1);
    setAiReports(D.aiReports.slice());
    showToast(t('store.toast.aiReportRemoved'), 'x');
  };

  /* ---------- ナレッジ（知識庫・管理者のみ）。RAG用の取込みはサーバー側で実行 ---------- */
  const [knowledge, setKnowledge] = React.useState(() => D.knowledge.slice());
  const addKnowledge = async (payload) => { // await＋失敗時 throw（取込みに時間がかかるため呼び出し側でローディング表示）
    const r = await API.addKnowledge(payload);
    D.knowledge.unshift(r.item);
    setKnowledge(D.knowledge.slice());
    showToast(r.item.embedded ? t('store.toast.knowledgeAddedRAG') : t('store.toast.knowledgeAdded'));
    return r.item;
  };
  const removeKnowledge = async (id) => {
    await API.deleteKnowledge(id);
    const i = D.knowledge.findIndex(k => k.id === id);
    if (i >= 0) D.knowledge.splice(i, 1);
    setKnowledge(D.knowledge.slice());
    showToast(t('store.toast.knowledgeRemoved'), 'x');
  };
  const updateKnowledge = async (id, payload) => { // テキスト種別の編集（個人KBの署名メモ等）。await＋失敗時 throw
    const r = await API.updateKnowledge(id, payload);
    const i = D.knowledge.findIndex(k => k.id === id);
    if (i >= 0) D.knowledge[i] = r.item;
    setKnowledge(D.knowledge.slice());
    showToast(t('store.toast.knowledgeUpdated'));
    return r.item;
  };

  /* ---------- 社内FAQ（Q&A）。AIが社内ナレッジから回答＋人が回答・採用でFAQ蓄積 ---------- */
  const [questions, setQuestions] = React.useState(() => D.questions.slice());
  const replaceQuestion = (item) => { const i = D.questions.findIndex(q => q.id === item.id); if (i >= 0) D.questions[i] = item; setQuestions(D.questions.slice()); };
  const askFaq = (question) => API.faqAsk(question); // {answer, sources, enough} を返す（保存なし）。失敗時 throw
  const postQuestion = async (payload) => {
    const r = await API.createQuestion(payload);
    D.questions.unshift(r.item);
    setQuestions(D.questions.slice());
    showToast(t('store.toast.questionPosted'));
    return r.item;
  };
  const answerQuestion = async (id, body) => { const r = await API.addAnswer(id, body); replaceQuestion(r.item); showToast(t('store.toast.answerAdded')); return r.item; };
  const acceptAnswer = async (id, answerId) => { const r = await API.acceptAnswer(id, answerId); replaceQuestion(r.item); showToast(t('store.toast.answerAccepted')); return r.item; };
  const reopenQuestion = async (id) => { const r = await API.reopenQuestion(id); replaceQuestion(r.item); return r.item; };
  const removeQuestion = async (id) => {
    await API.deleteQuestion(id);
    const i = D.questions.findIndex(q => q.id === id);
    if (i >= 0) D.questions.splice(i, 1);
    setQuestions(D.questions.slice());
    showToast(t('store.toast.questionRemoved'), 'x');
  };

  /* ---------- メンバー管理（管理者のみ・サーバー側でも検証） ---------- */
  const MEMBER_COLORS = ['#0891b2', '#16a34a', '#db2777', '#ea580c', '#7c3aed', '#0f766e', '#b45309'];
  const addMember = async ({ name, email, role }) => {
    const id = 'u' + Date.now();
    const user = { id, name: name.trim(), short: name.trim().split(/\s+/)[0].slice(0, 3), email: email.trim(),
      role: ROLE_ORDER.includes(role) ? role : 'member', color: MEMBER_COLORS[D.users.length % MEMBER_COLORS.length] };
    const res = await API.createUser({ ...user, password: 'Alion#2026' }); // 失敗時は throw（呼び出し側で表示）
    D.users.push(user);
    setUsersV(v => v + 1);
    showToast(res && res.emailed ? t('store.toast.memberInvited', { name: user.name }) : t('store.toast.memberAdded', { name: user.name }));
    return res;
  };
  const setMemberRole = (id, role) => {
    const u = D.user(id);
    if (!u) return;
    if (u.role === 'admin' && role !== 'admin' && D.users.filter(x => x.role === 'admin').length <= 1) {
      showToast(t("member.lastDemote"), 'x'); return;
    }
    API.updateUser(id, { role });
    u.role = role;
    setUsersV(v => v + 1);
    showToast(t('store.toast.memberRoleChanged', { name: u.name, role: t('store.role.' + (['admin', 'manager', 'member'].includes(role) ? role : 'member')) }));
  };
  const removeMember = async (id) => {
    const u = D.user(id);
    if (!u || id === currentUser.id) return;
    if (u.role === 'admin' && D.users.filter(x => x.role === 'admin').length <= 1) {
      showToast(t("member.lastDelete"), 'x'); return;
    }
    await API.deleteUser(id);
    D.users.splice(D.users.indexOf(u), 1);
    setUsersV(v => v + 1);
    showToast(t('store.toast.memberRemoved', { name: u.name }), 'x');
  };
  /* 本人のプロフィール写真（クロップ済み data URL／null で削除）。await・失敗時 throw */
  const updateMyAvatar = async (avatar) => {
    await API.setMyAvatar(avatar);
    const u = D.user(currentUser.id);
    if (u) u.avatar = avatar || null;
    setUsersV(v => v + 1);
    showToast(avatar ? t('store.toast.avatarUpdated') : t('store.toast.avatarRemoved'));
  };

  /* ---------- 外部連携 ----------
     Google カレンダーのみ各メンバーが自分のアカウントを連結。
     それ以外（Fireflies/Zoom/Slack/Teams/Drive/案件ソース）はワークスペース共通＝管理者が管理 */
  const USER_SCOPED_INTEGRATIONS = ['gcal', 'gmail'];
  const integScope = (service) => USER_SCOPED_INTEGRATIONS.includes(service) ? 'user' : 'workspace';
  const integKey = (service) => integScope(service) === 'user' ? `${currentUser.id}:${service}` : `workspace:${service}`;
  const integOf = (service) => integrations.find(r => r.id === integKey(service)) || null;
  const connectIntegration = (service, account, name) => {
    const scope = integScope(service);
    const row = { id: integKey(service), service, scope,
      userId: scope === 'user' ? currentUser.id : null, account: account.trim(), connected: true, settings: {}, connectedAt: nowStamp() };
    API.post('integrations', row);
    setIntegrations(rs => [row, ...rs.filter(r => r.id !== row.id)]);
    showToast(t('store.toast.integConnected', { name, account: row.account }));
  };
  const disconnectIntegration = (service, name) => {
    API.patch('integrations', integKey(service), { connected: false });
    setIntegrations(rs => rs.map(r => r.id === integKey(service) ? { ...r, connected: false } : r));
    showToast(t('store.toast.integDisconnected', { name }), 'x');
  };
  const saveIntegrationSettings = (service, settings, name) => {
    API.patch('integrations', integKey(service), { settings });
    setIntegrations(rs => rs.map(r => r.id === integKey(service) ? { ...r, settings } : r));
    showToast(t('store.toast.integSettingsSaved', { name }));
  };

  /* ---------- マスタ管理（ステータス・商談方法）— DB に保存して全員に反映 ---------- */
  const [mastersV, setMastersV] = React.useState(0);
  const saveMasters = () => {
    API.post('masters', { id: 'config', STATUS: D.STATUS, STATUS_ORDER: D.STATUS_ORDER, METHODS: D.METHODS, RANKS: D.RANKS, RANK_ORDER: D.RANK_ORDER, CATEGORIES: D.CATEGORIES });
    setMastersV(v => v + 1);
  };
  // KPIレポートの月次目標＋集計開始日の保存（KPIレポート画面・管理者）。masters.config.kpiTargets にマージ
  const saveKpiTargets = (targets) => {
    window.APP_DATA.kpiTargets = targets;
    API.post('masters', { id: 'config', kpiTargets: targets });
    setMastersV(v => v + 1);
    showToast(t("settings.kpiSaved"));
  };
  // 案件カテゴリ候補の保存（設定→マスタ）。D.CATEGORIES を差し替え→DB保存→全画面に反映
  const saveCategoryMaster = (list) => {
    D.CATEGORIES.length = 0; (list || []).forEach(c => D.CATEGORIES.push(c));
    saveMasters();
    showToast(t("settings.categoriesSaved"));
  };
  // 提案書AI分析の自社方針（ナレッジ→AI分析設定）。config に保存＝サーバーが分析時に最優先で反映
  const saveProposalGuidance = (text) => {
    window.APP_DATA.proposalGuidance = text;
    API.post('masters', { id: 'config', proposalGuidance: text });
    setMastersV(v => v + 1);
    showToast(t("settings.analysisSaved"));
  };
  // 見積もりAI分析の自社方針（ナレッジ→AI分析設定）。config に保存＝サーバーが見積もり分析時に最優先で反映
  const saveQuoteGuidance = (text) => {
    window.APP_DATA.quoteGuidance = text;
    API.post('masters', { id: 'config', quoteGuidance: text });
    setMastersV(v => v + 1);
    showToast(t("settings.quoteAnalysisSaved"));
  };
  // プロトタイプAI分析の自社方針（ナレッジ→AI分析設定）
  const saveProtoGuidance = (text) => {
    window.APP_DATA.prototypeGuidance = text;
    API.post('masters', { id: 'config', prototypeGuidance: text });
    setMastersV(v => v + 1);
    showToast(t("settings.prototypeAnalysisSaved"));
  };
  const saveShodanGuidance = (text) => {
    window.APP_DATA.shodanGuidance = text;
    API.post('masters', { id: 'config', shodanGuidance: text });
    setMastersV(v => v + 1);
    showToast(t("settings.meetingAnalysisSaved"));
  };
  // 全AI機能で使うモデル（設定→AI）。config に保存＝サーバーが全AI呼び出しで参照
  const saveAiModel = (model) => {
    window.APP_DATA.aiModel = model || '';
    API.post('masters', { id: 'config', aiModel: model || '' });
    setMastersV(v => v + 1);
    showToast(t("settings.modelSaved"));
  };
  /* ---------- 機能マスタ（料金カタログ）：見積書から抽出した機能＋平均単価。config に保存＝全員で共有 ---------- */
  const fmNorm = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, '');
  const persistFeatureMaster = (list) => {
    window.APP_DATA.featureMaster = list;
    API.post('masters', { id: 'config', featureMaster: list });
    setMastersV(v => v + 1);
  };
  // 抽出した明細(items)を機能マスタへマージ。同名は平均単価で更新＆出現回数++、新規は追加。{added,updated}を返す
  const mergeFeatureMaster = (items) => {
    const list = (window.APP_DATA.featureMaster || []).slice();
    const byName = {};
    list.forEach(f => { byName[fmNorm(f.name)] = f; });
    let added = 0, updated = 0;
    (items || []).forEach(it => {
      const name = String(it.name || '').trim();
      const price = Math.round(Number(it.unitPrice) || 0);
      if (!name || price <= 0) return;
      const key = fmNorm(name);
      const ex = byName[key];
      const src = String(it.source || '').trim();
      const srcEntry = src ? { q: src.slice(0, 120), price, at: today() } : null; // 出典の見積書（ファイル名・その時の単価・取込日）
      if (ex) {
        const c = ex.count || 1;
        ex.unitPrice = Math.round((ex.unitPrice * c + price) / (c + 1)); // 平均単価で更新
        ex.count = c + 1;
        if (!ex.unit && it.unit) ex.unit = it.unit;
        if (srcEntry) ex.sources = [...(ex.sources || []), srcEntry].slice(-40);
        updated++;
      } else {
        const f = { id: 'fm' + Date.now() + Math.random().toString(36).slice(2, 6), name, unit: it.unit || '式',
          taxRate: [10, 8, 0].includes(Number(it.taxRate)) ? Number(it.taxRate) : 10, category: it.category || '', count: 1, unitPrice: price, sources: srcEntry ? [srcEntry] : [] };
        list.push(f); byName[key] = f; added++;
      }
    });
    persistFeatureMaster(list);
    return { added, updated };
  };
  const updateFeatureRow = (id, patch) => persistFeatureMaster((window.APP_DATA.featureMaster || []).map(f => (f.id === id ? { ...f, ...patch } : f)));
  const addFeatureRow = (row) => persistFeatureMaster([...(window.APP_DATA.featureMaster || []), { id: 'fm' + Date.now() + Math.random().toString(36).slice(2, 6), name: '', unit: '式', taxRate: 10, category: '', count: 1, unitPrice: 0, ...row }]);
  const removeFeatureRow = (id) => persistFeatureMaster((window.APP_DATA.featureMaster || []).filter(f => f.id !== id));
  const addStatus = ({ label, color, closed }) => {
    const key = 'st' + Date.now();
    D.STATUS[key] = { key, label: label.trim(), color, soft: color + '1f', fg: color, closed: !!closed };
    D.STATUS_ORDER.push(key);
    saveMasters();
    showToast(t('store.toast.statusAdded', { label: label.trim() }));
  };
  const updateStatus = (key, { label, color, closed }) => {
    const s = D.STATUS[key];
    if (!s) return;
    Object.assign(s, { label: label.trim(), color, soft: color + '1f', fg: color, closed: !!closed });
    saveMasters();
    showToast(t('store.toast.statusUpdated', { label: label.trim() }));
  };
  const removeStatus = (key) => {
    if (cases.some(c => c.status === key)) { showToast(t('store.err.statusInUse'), 'x'); return; }
    delete D.STATUS[key];
    const i = D.STATUS_ORDER.indexOf(key);
    if (i >= 0) D.STATUS_ORDER.splice(i, 1);
    saveMasters();
    showToast(t('store.toast.statusRemoved'), 'x');
  };
  const addMethod = (label) => {
    const key = 'mt' + Date.now();
    D.METHODS[key] = { key, label: label.trim() };
    saveMasters();
    showToast(t('store.toast.methodAdded', { label: label.trim() }));
  };
  const removeMethod = (key) => {
    if (meetings.some(m => m.method === key)) { showToast(t('store.err.methodInUse'), 'x'); return; }
    delete D.METHODS[key];
    saveMasters();
    showToast(t('store.toast.methodRemoved'), 'x');
  };
  /* ランク（A〜E…）の追加・編集・削除 */
  const addRank = ({ key, desc, color }) => {
    const k = (key || '').trim().toUpperCase();
    if (!k) return;
    if (D.RANKS[k]) { showToast(t('store.err.rankExists', { key: k }), 'x'); return; }
    D.RANKS[k] = { key: k, label: k, desc: (desc || '').trim(), color, soft: color + '1f', fg: color };
    D.RANK_ORDER.push(k);
    saveMasters();
    showToast(t('store.toast.rankAdded', { key: k }));
  };
  const updateRank = (key, { desc, color }) => {
    const r = D.RANKS[key];
    if (!r) return;
    Object.assign(r, { desc: (desc || '').trim(), color, soft: color + '1f', fg: color });
    saveMasters();
    showToast(t('store.toast.rankUpdated', { key }));
  };
  const removeRank = (key) => {
    if (cases.some(c => c.rank === key)) { showToast(t('store.err.rankInUse'), 'x'); return; }
    delete D.RANKS[key];
    const i = D.RANK_ORDER.indexOf(key);
    if (i >= 0) D.RANK_ORDER.splice(i, 1);
    saveMasters();
    showToast(t('store.toast.rankRemoved'), 'x');
  };

  const meetingsOf = (caseId) => meetings.filter(m => m.caseId === caseId).sort((a, b) => b.datetime.localeCompare(a.datetime));
  const logsOf = (caseId) => logs.filter(l => l.caseId === caseId).sort((a, b) => b.at.localeCompare(a.at));

  // 日時変更のお知らせバー：×で閉じた状態は端末ごと localStorage に保持（リロード・遷移でも維持）
  const loadDismissedChg = () => { try { return JSON.parse(localStorage.getItem('anken_dismissed_changes')) || []; } catch (_) { return []; } };
  const [dismissedChg, setDismissedChg] = React.useState(loadDismissedChg);
  const dismissChange = (id) => setDismissedChg(prev => { const next = [...new Set([...prev, id])].slice(-1000); try { localStorage.setItem('anken_dismissed_changes', JSON.stringify(next)); } catch (_) {} return next; });
  const scheduleChanges = (D.scheduleChanges || []).filter(e => e && e.id && !dismissedChg.includes(e.id) && (workspaceId === 'all' || !e.caseId || cases.some(c => c.id === e.caseId && ASM_WORKSPACES.contains(c, workspaceId)))).slice().sort((a, b) => (b.at || '').localeCompare(a.at || ''));

  // ===== 案件/アポ振り分けの単一の真実（これ以降、画面が受け取る cases は常に「正規の案件」だけ） =====
  // 未回答(rcStatus=1)等のリード状態の case レコードは案件管理に出さない＝アポ管理の領分。
  // ここで一度だけ除外することで、一覧・ダッシュボード件数・カレンダー・顧客別件数・分析・新着バッジ・
  // ナレッジ連携など全消費箇所が自動的に一致する（個別画面でフィルタし忘れても二度と漏れない）。
  // 判定は rcIsLeadCase（apo.jsx・唯一の定義）。生データが必要な箇所のみ allCases を使う。
  // studioDraft＝作成スタジオの「案件に紐づけない」単体作成分（提案書/見積書の器）。案件一覧・KPI等には出さない
  const realCases = React.useMemo(() => cases.filter(c => !rcIsLeadCase(c) && !c.mergedInto && !c.studioDraft), [cases]);

  /* 本文重複の検知（データ整合性の安全網）：取込ミスで別案件の相談原文が誤爆すると「同一本文が複数案件」になる。
     相談原文体の長文(>150字・【…】/ご相談/お探し を含む)だけを対象に、同一本文のグループを作り caseId→他の同一本文案件ID[] を返す。
     2026-07-07 の ReadyCrew memo 取違えバグの再発を"目に見える形"で即検知するため（根本原因は修正済み）。 */
  const bodyDupMap = React.useMemo(() => {
    const byBody = {};
    cases.forEach(c => { if (c.mergedInto) return; const b = String(c.body || ''); if (b.length > 150 && /【|ご相談|お探し/.test(b)) { const k = b.replace(/\s+/g, ' ').trim().slice(0, 300); (byBody[k] = byBody[k] || []).push(c.id); } });
    const map = {};
    Object.keys(byBody).forEach(k => { const ids = byBody[k]; if (ids.length > 1) ids.forEach(id => { map[id] = ids.filter(x => x !== id); }); });
    return map;
  }, [cases]);
  const bodyDupCount = Object.keys(bodyDupMap).length;

  const authorizedData=React.useMemo(()=>ASM_WORKSPACES.accessibleData({cases,customers:visibleCustomers,meetings,logs,leads:rcLeads,emails,fireflies:D.firefliesMeetings,gcal:D.gcalEvents,inbox:workspaceInbox},workspaceConfig,currentUser),[cases,visibleCustomers,meetings,logs,rcLeads,emails,workspaceInbox,workspaceConfig,currentUser,usersV]);
  const workspaceData=React.useMemo(()=>ASM_WORKSPACES.scope(authorizedData,workspaceId),[authorizedData,workspaceId]);
  const authorizedIds=new Set(authorizedData.cases.map(c=>c.id));
  const workspaceCases = realCases.filter(c => authorizedIds.has(c.id)&&(workspaceId === 'all' || ASM_WORKSPACES.contains(c, workspaceId)));
  const membershipRef=React.useRef(null);
  React.useEffect(()=>{
    if(!currentUserId||route.screen==='login')return;
    const signature=currentUserId+':'+joinedWorkspaces.map(w=>w.id).sort().join(',');
    const previous=membershipRef.current;membershipRef.current=signature;
    if(previous&&previous!==signature){let live=true;API.bootstrap().then(data=>{if(live){hydrateAppData(data);loginAs(currentUserId);}}).catch(()=>{});return()=>{live=false;};}
  },[currentUserId,workspaceConfig]);
  React.useEffect(() => {
    if (!currentUserId || route.screen === 'login') return;
    let active = true;
    const update = () => API.workspaceState().then(state => { if (active) applyWorkspaceState(state); }).catch(() => {});
    update(); const timer = setInterval(update,60000);
    return () => { active = false; clearInterval(timer); };
  },[currentUserId,route.screen === 'login']);
  React.useEffect(() => {
    if (workspaceId !== 'all' && !workspaceConfig.workspaces.some(w=>w.id===workspaceId&&w.active&&!w.deleted)) {
      setWorkspaceId('all'); setChatOpen(false);
      try { localStorage.setItem(workspacePrefs(currentUser.id),'all'); } catch (_) {}
    }
  },[workspaceConfig,workspaceId]);
  const value = { skillCatalog,skillCatalogError,refreshSkillCatalog,createSkill,updateSkill,setDefaultSkill,loadSkill:API.skillDetail,skillDownloadUrl:API.skillDownloadUrl,saveProposalSkills,canManageWorkspaces, joinedWorkspaces, defaultWorkspaceId, workspaceId, workspaceConfig, workspaceInbox, switchWorkspace, refreshWorkspaceInbox, saveWorkspaceConfig, resolveWorkspaceItem, ignoreWorkspaceItem, importWorkspaceMeeting, createWorkspaceCase, moveCaseWorkspace, previewWorkspace:API.previewWorkspace, saveCaseForm, chatOpen, setChatOpen,
    workspaceCases:workspaceData.cases, firefliesMeetings:workspaceData.fireflies, gcalEvents:workspaceData.gcal,
    route, navigate, sessionExpired, cases: workspaceCases, allCases: authorizedData.cases, setCases, patchCase, syncProposalResult, meetings:workspaceData.meetings, logs:workspaceData.logs, notifs, currentUser, can, isManager, isAdmin, permsV, PERM_GROUPS, permissions: (window.APP_DATA.permissions || {}), savePermissions, kpiTargets: (window.APP_DATA.kpiTargets || {}), saveKpiTargets, loginAs, logout, customers: workspaceData.customers, allCustomers: authorizedData.customers, addCustomer, updateCustomer, mergeCustomers, unmergeCustomer, bodyDupMap, bodyDupCount,
    scheduleChanges, dismissChange,
    assignOwner, claimCase, toggleSub, changeStatus, archiveCase, addMeeting, updateMeeting, removeMeeting, setRank, setCategories, saveCase, removeCase, scheduleMeeting, addMeetingLink, removeMeetingLink, markRead, markAllRead,
    addNextActions, toggleNextAction, removeNextAction, mergeCases, unmergeCase,
    usersV, addMember, setMemberRole, removeMember, updateMyAvatar,
    knowledge, addKnowledge, updateKnowledge, removeKnowledge, rcLeads:workspaceData.leads, scoreLeads, genEntryLetter, reloadLeads, setApoLeadStatus,
    questions, askFaq, postQuestion, answerQuestion, acceptAnswer, reopenQuestion, removeQuestion,
    aiReports, saveAiReport, removeAiReport,
    emails:workspaceData.emails, emailsOf, syncCustomerGmail,
    mastersV, addStatus, updateStatus, removeStatus, addMethod, removeMethod, addRank, updateRank, removeRank, saveCategoryMaster, saveProposalGuidance, saveQuoteGuidance, saveProtoGuidance, saveShodanGuidance, saveAiModel,
    mergeFeatureMaster, updateFeatureRow, addFeatureRow, removeFeatureRow,
    integOf, integScope, connectIntegration, disconnectIntegration, saveIntegrationSettings,
    meetingsOf, logsOf, toast, showToast,
    paletteOpen, openPalette, closePalette,
    mobileNavOpen, openNav, closeNav,
    lang: window.getLang ? window.getLang() : 'ja', setLang, langV };

  return (
    <StoreContext.Provider value={value}>
      {children}
      {toast && (
        <div style={{ position: 'fixed', bottom: 26, left: '50%', transform: 'translateX(-50%)', zIndex: 400,
          background: '#1d2129', color: '#fff', padding: '11px 18px', borderRadius: 10, display: 'flex', alignItems: 'center', gap: 10,
          fontSize: 13.5, fontWeight: 500, boxShadow: '0 10px 30px rgba(0,0,0,.3)', animation: 'toastIn .22s ease' }}>
          <Icon name={toast.icon} size={17} stroke={2.2} style={{ color: '#6ee7a8' }} />
          {localizeMessage(toast.msg)}
        </div>
      )}
    </StoreContext.Provider>
  );
}

Object.assign(window, { StoreContext, useStore, StoreProvider });
