/* ============================================================
   シェル — サイドバー / トップバー / 通知パネル
   ============================================================ */
function Sidebar() {
  const { route, navigate, cases, currentUser, notifs, logout, can, mobileNavOpen, closeNav, workspaceInbox, canManageWorkspaces } = useStore();
  const isMobile = useIsMobile();
  const D = window.APP_DATA;
  const [profileOpen, setProfileOpen] = React.useState(false);
  const sidebarRef = React.useRef(null);
  const [sidebarTop, setSidebarTop] = React.useState(0);
  // 通知バナーの下でもメニュー末尾まで届くよう、画面内の残り高さを使う。
  React.useLayoutEffect(() => {
    if (isMobile) { setSidebarTop(0); return; }
    const aside = sidebarRef.current;
    let frame = 0;
    const measure = () => setSidebarTop(Math.max(0, aside.getBoundingClientRect().top));
    const schedule = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(measure); };
    measure();
    const observer = new ResizeObserver(schedule);
    observer.observe(aside.parentElement.parentElement);
    window.addEventListener('scroll', schedule, { passive: true });
    window.addEventListener('resize', schedule);
    return () => { observer.disconnect(); cancelAnimationFrame(frame); window.removeEventListener('scroll', schedule); window.removeEventListener('resize', schedule); };
  }, [isMobile]);
  // ダッシュボードの「新着案件（未アサイン）」と同じ定義に統一（担当が付いたらバッジから消える）
  const newCount = cases.filter(c => c.status === 'new' && !c.ownerId).length;
  const unread = notifs.filter(n => !n.read).length;
  const nav = [
    { screen: 'dashboard', icon: 'dashboard', label: t('nav.dashboard') },
    { screen: 'apo', icon: 'inbox', label: t('nav.apo') },
    { screen: 'cases', icon: 'cases', label: t('nav.cases'), badge: newCount ? `${t('nav.new')} ${newCount}` : null },
    { screen: 'calendar', icon: 'calendar', label: t('nav.calendar') },
    { screen: 'customers', icon: 'customers', label: t('nav.customers') },
    { screen: 'analytics', icon: 'chart', label: t('nav.analytics') },
    { screen: 'kpi', icon: 'check2', label: t('nav.kpi') },
    { screen: 'proposalStudio', icon: 'edit', label: t('nav.deckStudio') },
    { screen: 'quoteStudio', icon: 'copy', label: t('nav.quoteStudio') },
    { screen: 'triage', icon: 'inbox', label: t("triage.title"), badge: workspaceInbox.filter(i=>i.status==='pending').length || null },
    { screen: 'skills', icon: 'spark', label: t("skill.title") },
    { screen: 'audit', icon: 'clock', label: t('nav.audit') },
    { screen: 'knowledge', icon: 'spark', label: t('nav.knowledge') },
    /* 社内FAQ はメニューから外した（2026-08-13）。AIへの質問は右下のアシスタントに一本化。
       みんなのQ&A（人が質問→同僚が回答）は残っており、下の「サポート」から今までどおり開ける。 */
    { screen: 'integrations', icon: 'link', label: t('nav.integrations') },
  ];
  const active = (s) => route.screen === s || (s === 'cases' && route.screen === 'case') || (s === 'customers' && route.screen === 'customer') || (s === 'apo' && route.screen === 'apoLead');
  const Item = ({ item }) => {
    const [h, setH] = React.useState(false);
    const on = active(item.screen);
    return (
      <button className="asm-nav-item" aria-current={on?"page":undefined} onClick={() => navigate(item.screen)} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
        style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '10px 12px', borderRadius: 11, border: 'none',
          cursor: 'pointer', fontFamily: 'inherit', fontSize: 13.5, fontWeight: on ? 600 : 500, textAlign: 'left',
          color: on ? '#fff' : (h ? '#e7e8f0' : 'rgba(255,255,255,.62)'), background: on ? 'rgba(74,90,240,.28)' : (h ? 'rgba(255,255,255,.05)' : 'transparent'), transition: 'all .12s' }}>
        <Icon name={item.icon} size={18} stroke={2} style={{ color: on ? '#7de3f7' : 'rgba(255,255,255,.55)', flex: '0 0 auto' }} />
        <span style={{ flex: 1 }}>{item.label}</span>
        {item.badge && <span style={{ fontSize: 12, fontWeight: 800, color: '#1a1d29', background: '#7de3f7', padding: '2px 8px', borderRadius: 999 }}>{item.badge}</span>}
      </button>
    );
  };
  const BottomItem = ({ icon, label, onClick }) => {
    const [h, setH] = React.useState(false);
    return (
      <button onClick={onClick} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
        style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '9px 12px', borderRadius: 11, border: 'none', cursor: 'pointer',
          fontFamily: 'inherit', fontSize: 13, fontWeight: 500, textAlign: 'left', color: h ? '#e7e8f0' : 'rgba(255,255,255,.55)', background: h ? 'rgba(255,255,255,.05)' : 'transparent', transition: 'all .12s' }}>
        <Icon name={icon} size={17} stroke={2} style={{ color: 'rgba(255,255,255,.5)', flex: '0 0 auto' }} />{label}
      </button>
    );
  };
  const asideBase = { overflow: 'hidden', width: 244, background: '#191c28', display: 'flex', flexDirection: 'column', padding: '18px 14px 16px' };
  // モバイル：fixed のドロワー（オフキャンバス→スライドイン）。デスクトップ：従来の sticky aside
  const asideStyle = isMobile
    ? { ...asideBase, position: 'fixed', top: 0, left: 0, zIndex: 210, transition: 'transform .22s ease', transform: mobileNavOpen ? 'none' : 'translateX(-110%)', boxShadow: mobileNavOpen ? '0 0 40px rgba(0,0,0,.45)' : 'none' }
    : { ...asideBase, flex: '0 0 244px', position: 'sticky', top: 0, '--asm-sidebar-top': sidebarTop + 'px' };
  return (
    <>
      {isMobile && mobileNavOpen && <div onClick={closeNav} style={{ position: 'fixed', inset: 0, background: 'rgba(18,20,30,.5)', zIndex: 200 }} />}
      <aside className="asm-sidebar" ref={sidebarRef} style={asideStyle}>
      <div className="asm-sidebar-header">
      <button className="asm-logo" onClick={() => navigate('dashboard')} aria-label={t('shell.asmDashboard')}>
        <img src="/assets/asm/asm-mark-white.png" alt="ASM" width="88" height="29" />
      </button>

      <WorkspacePicker />

      {/* プロフィール（クリックでプロフィール設定：写真の変更・パスワード変更） */}
      <div onClick={() => setProfileOpen(true)} title={t('nav.profile')}
        style={{ display: 'flex', alignItems: 'center', gap: 11, margin: '10px 0 8px', padding: '10px', borderRadius: 13, background: 'rgba(255,255,255,.05)', cursor: 'pointer' }}
        onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255,255,255,.09)'}
        onMouseLeave={(e) => e.currentTarget.style.background = 'rgba(255,255,255,.05)'}>
        <Avatar user={currentUser} size={36} />
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 13, fontWeight: 600, color: '#fff', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{currentUser.name}</div>
          <div style={{ fontSize: 12, color: 'rgba(255,255,255,.42)' }}>{ROLE_LABEL[currentUser.role] || t('role.member')}</div>
        </div>
        <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7ee0a6', boxShadow: '0 0 0 3px rgba(126,224,166,.18)' }} />
      </div>
      </div>
      {profileOpen && <ProfileModal onClose={() => setProfileOpen(false)} />}

      <div className="asm-sidebar-scroll">
      <nav style={{ padding: '6px 0 0', display: 'flex', flexDirection: 'column', gap: 2 }}>
        {[
          ['MAIN', ['dashboard', 'apo', 'cases', 'calendar', 'customers']],
          ['ANALYZE', ['analytics', 'kpi']],
          ['CREATE', ['proposalStudio', 'quoteStudio', 'knowledge']],
          ['ADMIN', ['triage', 'skills', 'audit', 'integrations', 'settings']],
        ].map(([label, keys]) => {
          const items = [...nav, ...((can('settings')||canManageWorkspaces) ? [{ screen: 'settings', icon: 'settings', label: t('nav.settings') }] : [])]
            .filter(i => (i.screen!=='skills'||currentUser.role==='admin')&&keys.includes(i.screen) && (i.screen !== 'triage' || can('workspaceTriage')) && (i.screen !== 'analytics' || can('viewAnalytics')) && (i.screen !== 'audit' || can('viewAuditLog')) && (i.screen !== 'kpi' || can('viewKpiReport')))
            .sort((a, b) => keys.indexOf(a.screen) - keys.indexOf(b.screen));
          return items.length ? <React.Fragment key={label}><div className="asm-nav-group">{label}</div>{items.map(i => <Item key={i.screen} item={i} />)}</React.Fragment> : null;
        })}
      </nav>

      <div style={{ marginTop: 'auto', display: 'flex', flexDirection: 'column', gap: 3, paddingTop: 12, borderTop: '1px solid rgba(255,255,255,.08)' }}>
        <BottomItem icon="help" label={t('nav.support')} onClick={() => navigate('faq')} />
        <BottomItem icon="logout" label={t('nav.logout')} onClick={logout} />
      </div>
      </div>
      </aside>
    </>
  );
}

/* パスワード変更モーダル（本人用・全メンバー） */
function ChangePasswordModal({ onClose }) {
  const { showToast } = useStore();
  const [cur, setCur] = React.useState('');
  const [nxt, setNxt] = React.useState('');
  const [nxt2, setNxt2] = React.useState('');
  const [err, setErr] = React.useState('');
  const [busy, setBusy] = React.useState(false);
  const submit = async () => {
    if (busy) return;
    if (nxt.length < 8) { setErr(t('auth.pwMinLength')); return; }
    if (nxt !== nxt2) { setErr(t('auth.pwMismatch')); return; }
    setErr(''); setBusy(true);
    try {
      await API.changePassword(cur, nxt);
      showToast(t('auth.pwChanged'));
      onClose();
    } catch (e) { setErr(e.message); }
    setBusy(false);
  };
  return (
    <Modal open onClose={onClose} title={t('top.changePw')} width={420}
      subtitle={t('auth.pwSubtitle')}
      footer={<><Button variant="subtle" onClick={onClose}>{t('btn.cancel')}</Button>
        <Button variant="primary" icon="check" onClick={submit} disabled={busy || !cur || !nxt || !nxt2}>{busy ? t('auth.pwChanging') : t('btn.update')}</Button></>}>
      <Field label={t('auth.currentPw')} required><TextInput type="password" value={cur} onChange={(e) => { setCur(e.target.value); setErr(''); }} /></Field>
      <Field label={t('auth.newPw')} required hint={t('auth.pwHint')}><TextInput type="password" value={nxt} onChange={(e) => { setNxt(e.target.value); setErr(''); }} /></Field>
      <Field label={t('auth.newPwConfirm')} required><TextInput type="password" value={nxt2} onChange={(e) => { setNxt2(e.target.value); setErr(''); }} onKeyDown={(e) => { if (enterSubmits(e)) submit(); }} /></Field>
      {err && <div style={{ fontSize: 12, color: '#dc2626', marginTop: -6 }}>{err}</div>}
    </Modal>
  );
}

/* プロフィール設定モーダル（本人用）— 写真のアップロード／削除・パスワード変更 */
function ProfileModal({ onClose }) {
  const { currentUser, updateMyAvatar, showToast } = useStore();
  const [pwOpen, setPwOpen] = React.useState(false);
  const [cropSrc, setCropSrc] = React.useState(null);
  const [busy, setBusy] = React.useState(false);
  const fileRef = React.useRef(null);
  const onFile = (e) => {
    const f = e.target.files && e.target.files[0];
    e.target.value = '';
    if (!f) return;
    if (!/^image\//.test(f.type)) { showToast(t('profile.selectImage'), 'x'); return; }
    const reader = new FileReader();
    reader.onload = () => setCropSrc(reader.result);
    reader.readAsDataURL(f);
  };
  const saveCrop = async (dataUrl) => {
    try { await updateMyAvatar(dataUrl); setCropSrc(null); }
    catch (err) { showToast(err.message || t('profile.updateFailed'), 'x'); throw err; }
  };
  const removeAvatar = async () => {
    if (busy) return; setBusy(true);
    try { await updateMyAvatar(null); } catch (err) { showToast(err.message || t('profile.deleteFailed'), 'x'); }
    setBusy(false);
  };
  if (pwOpen) return <ChangePasswordModal onClose={() => setPwOpen(false)} />;
  if (cropSrc) return <AvatarCropper src={cropSrc} onCancel={() => setCropSrc(null)} onSave={saveCrop} />;
  return (
    <Modal open onClose={onClose} title={t('nav.profile')} width={400}
      footer={<Button variant="subtle" onClick={onClose}>{t('btn.close')}</Button>}>
      <input ref={fileRef} type="file" accept="image/*" onChange={onFile} style={{ display: 'none' }} />
      <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14, padding: '6px 0 14px' }}>
        <div style={{ position: 'relative', cursor: 'pointer' }} onClick={() => fileRef.current && fileRef.current.click()} title={t('profile.changePhoto')}>
          <Avatar user={currentUser} size={96} />
          <span style={{ position: 'absolute', right: -2, bottom: -2, width: 30, height: 30, borderRadius: '50%', background: '#4a5af0',
            display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 2px 6px rgba(20,22,40,.25)', border: '2px solid #fff' }}>
            <Icon name="edit" size={14} stroke={2.2} style={{ color: '#fff' }} />
          </span>
        </div>
        <div style={{ textAlign: 'center' }}>
          <div style={{ fontSize: 16, fontWeight: 700, color: '#1f2430' }}>{currentUser.name}</div>
          <div style={{ fontSize: 12, color: '#9aa1ab', marginTop: 2 }}>
            {currentUser.email}{currentUser.email ? ' · ' : ''}{ROLE_LABEL[currentUser.role] || t('role.member')}
          </div>
        </div>
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
        <Button variant="primary" icon="attach" full onClick={() => fileRef.current && fileRef.current.click()}>{t('profile.uploadPhoto')}</Button>
        {currentUser.avatar && <Button variant="default" icon="x" full onClick={removeAvatar} disabled={busy}>{t('profile.removePhoto')}</Button>}
        <Button variant="default" icon="settings" full onClick={() => setPwOpen(true)}>{t('top.changePw')}</Button>
      </div>
      <div style={{ fontSize: 12, color: '#aab0ba', marginTop: 12, lineHeight: 1.6 }}>
        {t('profile.photoInfo')}
      </div>
    </Modal>
  );
}

/* プロフィール写真クロッパー（正方形・ドラッグ移動＋ズーム）。外部ライブラリ不使用、Canvas で 256px に書き出し */
function AvatarCropper({ src, onCancel, onSave }) {
  const V = 260, OUT = 256; // 表示ビューポート / 出力サイズ
  const imgRef = React.useRef(null);
  const drag = React.useRef(null);
  const [nat, setNat] = React.useState(null);
  const [zoom, setZoom] = React.useState(1);
  const [pos, setPos] = React.useState({ x: 0, y: 0 });
  const [busy, setBusy] = React.useState(false);

  const baseScale = nat ? V / Math.min(nat.w, nat.h) : 1;
  const scale = baseScale * zoom;
  const dw = nat ? nat.w * scale : V;
  const dh = nat ? nat.h * scale : V;
  const clamp = (p) => ({ x: Math.min(0, Math.max(V - dw, p.x)), y: Math.min(0, Math.max(V - dh, p.y)) });

  const onImgLoad = (e) => {
    const w = e.target.naturalWidth, h = e.target.naturalHeight;
    const s = V / Math.min(w, h);
    setNat({ w, h });
    setPos({ x: (V - w * s) / 2, y: (V - h * s) / 2 });
  };
  React.useEffect(() => { if (nat) setPos(p => clamp(p)); }, [zoom, nat]); // ズーム後は範囲内に補正

  const start = (e) => { const pt = e.touches ? e.touches[0] : e; drag.current = { sx: pt.clientX, sy: pt.clientY, ox: pos.x, oy: pos.y }; };
  React.useEffect(() => {
    const move = (e) => {
      if (!drag.current) return;
      if (e.touches) e.preventDefault();
      const pt = e.touches ? e.touches[0] : e;
      setPos(clamp({ x: drag.current.ox + (pt.clientX - drag.current.sx), y: drag.current.oy + (pt.clientY - drag.current.sy) }));
    };
    const up = () => { drag.current = null; };
    window.addEventListener('mousemove', move); window.addEventListener('mouseup', up);
    window.addEventListener('touchmove', move, { passive: false }); window.addEventListener('touchend', up);
    return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up);
      window.removeEventListener('touchmove', move); window.removeEventListener('touchend', up); };
  });

  const save = async () => {
    if (!nat || busy) return;
    setBusy(true);
    try {
      const canvas = document.createElement('canvas');
      canvas.width = OUT; canvas.height = OUT;
      const ctx = canvas.getContext('2d');
      ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, OUT, OUT);
      const sx = -pos.x / scale, sy = -pos.y / scale, sSize = V / scale; // ビューポートに対応する元画像領域
      ctx.drawImage(imgRef.current, sx, sy, sSize, sSize, 0, 0, OUT, OUT);
      await onSave(canvas.toDataURL('image/jpeg', 0.85));
    } catch (_) { setBusy(false); } // 失敗時のみ復帰（成功時は親がアンマウント）
  };

  return (
    <Modal open onClose={onCancel} title={t('profile.cropPhoto')} width={360} subtitle={t('profile.cropHint')}
      footer={<><Button variant="subtle" onClick={onCancel}>{t('btn.cancel')}</Button>
        <Button variant="primary" icon="check" onClick={save} disabled={busy || !nat}>{busy ? t('profile.saving') : t('btn.save')}</Button></>}>
      <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16, padding: '4px 0' }}>
        <div onMouseDown={start} onTouchStart={start}
          style={{ position: 'relative', width: V, height: V, borderRadius: '50%', overflow: 'hidden',
            background: '#f0f1f4', cursor: 'grab', touchAction: 'none', boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.08)' }}>
          <img ref={imgRef} src={src} onLoad={onImgLoad} draggable={false} alt=""
            style={{ position: 'absolute', left: pos.x, top: pos.y, width: dw, height: dh, maxWidth: 'none', userSelect: 'none', pointerEvents: 'none' }} />
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, width: V }}>
          <Icon name="search" size={14} stroke={2} style={{ color: '#9aa1ab' }} />
          <input type="range" min="1" max="3" step="0.01" value={zoom} onChange={(e) => setZoom(parseFloat(e.target.value))}
            style={{ flex: 1, accentColor: '#4a5af0' }} />
        </div>
      </div>
    </Modal>
  );
}

/* 言語切替（右上・グローブアイコン） */
function LangSwitcher() {
  const { lang, setLang } = useStore();
  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);
  }, []);
  const cur = (window.I18N_LANGS || []).find(l => l.key === lang) || { label: t('lang.ja') };
  return (
    <div ref={ref} style={{ position: 'relative', flexShrink:0 }}>
      <button className="asm-lang-trigger" aria-label={cur.label} onClick={() => setOpen(o => !o)} title={t('switch.lang')}
        style={{ display: 'inline-flex', alignItems: 'center', gap: 6, height: 34, padding: '0 11px', borderRadius: 8, border: '1px solid #e2e5ea',
          background: open ? '#eef0f3' : '#fff', cursor: 'pointer', fontFamily: 'inherit', fontSize: 12.5, fontWeight: 600, color: '#3b414b' }}>
        <Icon name="globe" size={15} stroke={2} style={{ color: '#7b828d' }} />
        <span>{cur.label}</span>
        <Icon name="chevronDown" size={12} stroke={2.2} style={{ color: '#b4bac3' }} />
      </button>
      {open && (
        <div style={{ position: 'absolute', top: 40, right: 0, zIndex: 100, width: 150, background: '#fff', borderRadius: 10, border: '1px solid #e6e8ec', boxShadow: '0 14px 34px rgba(20,22,40,.16)', padding: 6 }}>
          {(window.I18N_LANGS || []).map(l => (
            <div key={l.key} className="row-hover" onClick={() => { setLang(l.key); setOpen(false); }}
              style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 10px', borderRadius: 7, cursor: 'pointer', fontSize: 13, color: '#3b414b' }}>
              {l.label}
              {lang === l.key && <Icon name="check" size={14} stroke={2.4} style={{ color: '#4a5af0' }} />}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

function Topbar({ title, right }) {
  const { notifs, markAllRead, markRead, navigate, openPalette, openNav, workspaceId, chatOpen, setChatOpen } = useStore();
  const isMobile = useIsMobile();
  const [openN, setOpenN] = React.useState(false);
  const ref = React.useRef(null);
  const unread = notifs.filter(n => !n.read).length;
  React.useEffect(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpenN(false); };
    document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
  }, []);
  return (
    <header className="asm-topbar" style={{ height: 60, borderBottom: '1px solid #ecedf0', background: 'rgba(255,255,255,.85)', backdropFilter: 'blur(8px)',
      position: 'sticky', top: 0, zIndex: 50, display: 'flex', alignItems: 'center', padding: isMobile ? '0 12px' : '0 24px', gap: isMobile ? 8 : 16 }}>
      {isMobile && <IconButton name="menu" size={22} onClick={openNav} title={t('nav.dashboard')} />}
      <div style={{ fontSize: isMobile ? 15 : 16, fontWeight: 700, color: '#1c1f26', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: '0 1 auto' }}>{title}</div>
      <WorkspaceBadge id={workspaceId} className="asm-top-workspace" />
      <div style={{ flex: 1, minWidth: 8 }} />
      {isMobile
        ? <IconButton name="search" size={20} onClick={openPalette} title={t('top.search')} />
        : <div className="row-hover" style={{ display: 'flex', alignItems: 'center', gap: 6, background: '#f4f5f7', borderRadius: 8, padding: '7px 11px', width: 210, color: '#9aa1ab', cursor: 'text' }}
            onClick={openPalette}>
            <Icon name="search" size={15} stroke={2} />
            <span style={{ fontSize: 12.5 }}>{t("extra.common.search")}</span>
            <span style={{ marginLeft: 'auto', fontSize: 12, border: '1px solid #dfe2e8', borderRadius: 4, padding: '0 5px', color: '#a8aeb8' }}>⌘K</span>
          </div>}
      <button className="asm-ai-trigger" aria-label={t('faqchat.title')} aria-expanded={chatOpen} onClick={() => setChatOpen(!chatOpen)}><Icon name="spark" size={15}/><span>{t('faqchat.title')}</span></button>
      <LangSwitcher />
      <div ref={ref} style={{ position: 'relative' }}>
        <div style={{ position: 'relative' }}>
          <IconButton name="bell" size={19} active={openN} onClick={() => setOpenN(o => !o)} title={t('top.notifications')} />
          {unread > 0 && <span style={{ position: 'absolute', top: 4, right: 4, minWidth: 15, height: 15, padding: '0 3px', borderRadius: 8,
            background: '#ef4444', color: '#fff', fontSize: 12, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 0 0 2px #fff' }}>{unread}</span>}
        </div>
        {openN && <NotificationPanel onClose={() => setOpenN(false)} />}
      </div>
    </header>
  );
}

function NotificationPanel({ onClose }) {
  const { notifs, markAllRead, markRead, navigate } = useStore();
  const icons = { new_case: { i: 'inbox', c: '#2563eb' }, assign: { i: 'user', c: '#4a5af0' }, reminder: { i: 'clock', c: '#ea580c' }, due: { i: 'alert', c: '#dc2626' }, scrape_fail: { i: 'alert', c: '#dc2626' }, stale: { i: 'clock', c: '#b45309' } };
  // 整理：未読/すべての切替＋種類フィルタ＋「期限」の未読を最上部にピン（大事な通知が埋もれない）
  const TYPE_LABEL = { due: t("cd.due"), stale: t("notif.idle"), reminder: t("notif.reminder"), new_case: t("notif.newCase"), assign: t("cases.col.owner"), scrape_fail: t("notif.importError") };
  const unreadN = notifs.filter(n => !n.read).length;
  const [scope, setScope] = React.useState(() => (unreadN > 0 ? 'unread' : 'all')); // unread / all
  const [typeF, setTypeF] = React.useState('');
  const pool = notifs.filter(n => (scope === 'all' || !n.read));
  const typeCounts = {};
  pool.forEach(n => { const k = TYPE_LABEL[n.type] ? n.type : 'other'; typeCounts[k] = (typeCounts[k] || 0) + 1; });
  const shown = pool.filter(n => !typeF || (typeF === 'other' ? !TYPE_LABEL[n.type] : n.type === typeF))
    .slice().sort((a, b) => (((b.type === 'due' && !b.read) ? 1 : 0) - ((a.type === 'due' && !a.read) ? 1 : 0)));
  const chip = (key, label, count) => (
    <button key={key} onClick={() => setTypeF(f => (f === key ? '' : key))}
      style={{ border: '1px solid ' + (typeF === key ? '#4a5af0' : '#e2e5ea'), background: typeF === key ? '#eef0fe' : '#fff', color: typeF === key ? '#4a5af0' : '#5b626d',
        borderRadius: 999, padding: '2px 9px', fontSize: 11, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>
      {label} {count}
    </button>
  );
  return (
    <div style={{ position: 'absolute', top: 44, right: 0, width: 384, background: '#fff', borderRadius: 13, border: '1px solid #e6e8ec',
      boxShadow: '0 18px 44px rgba(20,22,40,.18)', overflow: 'hidden', zIndex: 100 }}>
      <div style={{ padding: '12px 16px 10px', borderBottom: '1px solid #f0f1f4' }}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
          <div style={{ fontSize: 14, fontWeight: 700, color: '#1c2027' }}>{t('top.notifications')}{unreadN > 0 && <span style={{ fontSize: 11.5, fontWeight: 700, color: '#4a5af0', marginLeft: 7 }}>{t("extra.common.unread")}{unreadN}</span>}</div>
          <button onClick={markAllRead} style={{ border: 'none', background: 'transparent', color: '#4a5af0', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>{t('notif.markAllRead')}</button>
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 9, flexWrap: 'wrap' }}>
          <div style={{ display: 'inline-flex', background: '#eef0f3', borderRadius: 8, padding: 2, marginRight: 2 }}>
            {[['unread', t("extra.common.unread")], ['all', t("cases.all")]].map(([k, lb]) => (
              <button key={k} onClick={() => setScope(k)} style={{ border: 'none', borderRadius: 6, padding: '3px 11px', fontSize: 11.5, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
                background: scope === k ? '#fff' : 'transparent', color: scope === k ? '#1f2430' : '#7b828d', boxShadow: scope === k ? '0 1px 2px rgba(16,24,40,.12)' : 'none' }}>{lb}</button>
            ))}
          </div>
          {Object.keys(TYPE_LABEL).filter(k => typeCounts[k]).map(k => chip(k, TYPE_LABEL[k], typeCounts[k]))}
          {typeCounts.other ? chip('other', t("na.type.other"), typeCounts.other) : null}
        </div>
      </div>
      <div style={{ maxHeight: 400, overflowY: 'auto' }}>
        {shown.map(n => {
          const ic = icons[n.type] || { i: 'bell', c: '#838a94' };
          return (
            <div key={n.id} className="row-hover" onClick={() => { markRead(n.id); if (n.link.screen === 'case') navigate('case', n.link.id); else navigate(n.link.screen); onClose(); }}
              style={{ display: 'flex', gap: 11, padding: '12px 16px', cursor: 'pointer', borderBottom: '1px solid #f4f5f7', background: n.read ? '#fff' : '#fafaff' }}>
              <div style={{ width: 32, height: 32, borderRadius: 8, flex: '0 0 auto', background: ic.c + '15', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
                <Icon name={ic.i} size={16} stroke={2} style={{ color: ic.c }} />
              </div>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ fontSize: 13, fontWeight: 600, color: '#262b34', display: 'flex', alignItems: 'center', gap: 7 }}>
                  {n.title}
                  {!n.read && <span style={{ width: 7, height: 7, borderRadius: '50%', background: '#4a5af0', flex: '0 0 auto' }} />}
                </div>
                <div style={{ fontSize: 12, color: '#6b727c', marginTop: 2, lineHeight: 1.45 }}>{n.body}</div>
                <div style={{ fontSize: 12, color: n.type === 'due' ? '#dc2626' : '#a8aeb8', fontWeight: n.type === 'due' ? 700 : 400, marginTop: 4 }}>{n.when || relTime(n.at)}</div>
              </div>
              {!n.read && (
                <button onClick={(e) => { e.stopPropagation(); markRead(n.id); }} title={t("notif.markRead")}
                  style={{ border: 'none', background: 'transparent', color: '#b4bac3', cursor: 'pointer', fontFamily: 'inherit', padding: '0 2px', alignSelf: 'flex-start' }}>
                  <Icon name="check" size={14} stroke={2.4} />
                </button>
              )}
            </div>
          );
        })}
        {shown.length === 0 && (
          <div style={{ padding: '38px 16px', textAlign: 'center', color: '#9aa1ab' }}>
            <Icon name="check2" size={26} stroke={1.8} style={{ color: '#cbd0d7' }} />
            <div style={{ fontSize: 13, marginTop: 8 }}>{scope === 'unread' && notifs.length ? t("notif.noUnread") : t('notif.empty')}</div>
            <div style={{ fontSize: 12, marginTop: 3, color: '#b4bac3' }}>{scope === 'unread' && notifs.length ? t("notif.viewAllHint") : t('notif.emptyHint')}</div>
          </div>
        )}
      </div>
    </div>
  );
}

/* ページの外枠 */
function Page({ title, right, children, pad = true }) {
  const isMobile = useIsMobile();
  return (
    <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: '100vh', background: '#f6f7fa' }}>
      {/* スマホは right（操作ボタン群）をトップバーに詰め込むと検索・通知と衝突して溢れるため、直下の専用バーに逃がす */}
      <Topbar title={title} />
      {right && (
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', padding: '9px 12px', borderBottom: '1px solid #ecedf0',
          background: 'rgba(255,255,255,.9)', backdropFilter: 'blur(8px)', justifyContent: 'flex-end' }}>{right}</div>
      )}
      <main className="asm-content" style={{ padding: pad ? (isMobile ? '14px 12px 56px' : '24px 24px 60px') : 0, flex: 1, minWidth: 0 }}>{children}</main>
    </div>
  );
}

/* ============================================================
   コマンドパレット（⌘K）— 案件・顧客をまとめて検索
   ============================================================ */
function CommandPalette() {
  const { paletteOpen, closePalette, cases, customers, navigate } = useStore();
  const D = window.APP_DATA;
  const [q, setQ] = React.useState('');
  const [sel, setSel] = React.useState(0);
  const inputRef = React.useRef(null);
  const listRef = React.useRef(null);

  React.useEffect(() => {
    if (paletteOpen) { setQ(''); setSel(0); setTimeout(() => inputRef.current && inputRef.current.focus(), 20); }
  }, [paletteOpen]);

  const results = React.useMemo(() => {
    const term = q.trim().toLowerCase();
    const items = [];
    cases.forEach(k => {
      const c = D.customer(k.customerId);
      const hay = `${k.title || ''} ${c ? c.company : ''} ${c ? c.shortName : ''} ${k.sourceNo || ''}`.toLowerCase();
      if (!term || hay.includes(term)) {
        items.push({ kind: 'case', id: k.id, title: k.title || t('cases.untitled'),
          sub: (c ? c.company : '') + (k.sourceNo ? ' · ' + k.sourceNo : ''), status: k.status });
      }
    });
    customers.forEach(c => {
      const hay = `${c.company || ''} ${c.shortName || ''} ${c.contact || ''} ${c.industry || ''}`.toLowerCase();
      if (!term || hay.includes(term)) {
        const n = cases.filter(k => k.customerId === c.id).length;
        items.push({ kind: 'customer', id: c.id, title: c.company,
          sub: (c.industry ? c.industry + ' · ' : '') + t('search.caseCount', { n }) });
      }
    });
    // 案件を上に、顧客を下に。検索語があれば前方一致を優先
    return items.slice(0, 50);
  }, [q, cases, customers]);

  React.useEffect(() => { setSel(s => Math.min(s, Math.max(0, results.length - 1))); }, [results.length]);

  if (!paletteOpen) return null;

  const go = (it) => {
    if (!it) return;
    navigate(it.kind === 'case' ? 'case' : 'customer', it.id);
    closePalette();
  };
  const onKey = (e) => {
    if (e.key === 'Escape') { closePalette(); }
    else if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(s + 1, results.length - 1)); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(s - 1, 0)); }
    else if (enterSubmits(e)) { e.preventDefault(); go(results[sel]); }
  };

  return (
    <div onMouseDown={closePalette} style={{ position: 'fixed', inset: 0, background: 'rgba(24,26,32,.42)', backdropFilter: 'blur(2px)',
      zIndex: 300, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: '12vh 20px 40px' }}>
      <div onMouseDown={(e) => e.stopPropagation()} style={{ background: '#fff', borderRadius: 14, width: 600, maxWidth: '100%',
        boxShadow: '0 28px 70px rgba(20,22,40,.34)', overflow: 'hidden', animation: 'modalIn .16s ease' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '15px 18px', borderBottom: '1px solid #eef0f3' }}>
          <Icon name="search" size={18} stroke={2} style={{ color: '#9aa1ab', flex: '0 0 auto' }} />
          <input ref={inputRef} value={q} onChange={(e) => { setQ(e.target.value); setSel(0); }} onKeyDown={onKey}
            placeholder={t('search.palettePlaceholder')}
            style={{ border: 'none', outline: 'none', fontSize: 15, width: '100%', fontFamily: 'inherit', color: '#1f2430', background: 'transparent' }} />
          <span style={{ fontSize: 12, border: '1px solid #e2e5ea', borderRadius: 5, padding: '2px 6px', color: '#a8aeb8', flex: '0 0 auto' }}>ESC</span>
        </div>
        <div ref={listRef} style={{ maxHeight: 420, overflowY: 'auto', padding: 8 }}>
          {results.map((it, i) => (
            <div key={it.kind + it.id} onMouseEnter={() => setSel(i)} onClick={() => go(it)}
              style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 12px', borderRadius: 9, cursor: 'pointer',
                background: i === sel ? '#f0f1fb' : 'transparent' }}>
              <div style={{ width: 30, height: 30, borderRadius: 8, flex: '0 0 auto', display: 'flex', alignItems: 'center', justifyContent: 'center',
                background: it.kind === 'case' ? '#eef0fe' : '#eafaf2', color: it.kind === 'case' ? '#4a5af0' : '#16a34a' }}>
                <Icon name={it.kind === 'case' ? 'cases' : 'customers'} size={15} stroke={2} />
              </div>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ fontSize: 13.5, fontWeight: 600, color: '#1f2430', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{it.title}</div>
                <div style={{ fontSize: 12, color: '#9aa1ab', marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{it.sub}</div>
              </div>
              {it.kind === 'case' && it.status && <StatusBadge status={it.status} size="sm" />}
              <span style={{ fontSize: 12, color: '#b4bac3', flex: '0 0 auto' }}>{it.kind === 'case' ? t('search.case') : t('search.customer')}</span>
            </div>
          ))}
          {results.length === 0 && (
            <div style={{ padding: '40px 16px', textAlign: 'center', color: '#9aa1ab', fontSize: 13 }}>
              {t('search.noResults', { q })}
            </div>
          )}
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '9px 16px', borderTop: '1px solid #f0f1f4', background: '#fbfbfc', fontSize: 12, color: '#a8aeb8' }}>
          <span>{t('search.helpMove')}</span><span>{t('search.helpOpen')}</span><span style={{ marginLeft: 'auto' }}>{t('search.resultCount', { n: results.length })}</span>
        </div>
      </div>
    </div>
  );
}

/* 日時変更のお知らせバー（画面最上部・全員に表示。×で閉じるまで表示し続ける） */
function ChangeBar() {
  const { scheduleChanges, dismissChange } = useStore();
  if (!scheduleChanges.length) return null;
  const ev = scheduleChanges[0];
  const more = scheduleChanges.length - 1;
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 18px', background: '#fef3f2', borderBottom: '1px solid #f7c8be', color: '#9a3412', fontSize: 13, fontWeight: 500 }}>
      <Icon name="calendar" size={15} stroke={2} style={{ color: '#dc2626', flex: '0 0 auto' }} />
      <span style={{ flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
        <b>{t('shell.changeBar.tag')}</b>{t('shell.changeBar.prefix', { target: (ev.customer ? ev.customer + ' ' : '') + ev.title, label: ev.label || t('shell.changeBar.defaultLabel') })} <b>{ev.oldFmt}</b> → <b style={{ color: '#b91c1c' }}>{ev.newFmt}</b> {t('shell.changeBar.suffix', { changer: ev.changer ? t('shell.changeBar.changerNote', { changer: ev.changer }) : '' })}
      </span>
      {more > 0 && <span style={{ flex: '0 0 auto', fontSize: 12, fontWeight: 700, background: '#fde2dc', borderRadius: 999, padding: '1px 9px' }}>{t('notif.others', { more })}</span>}
      <button onClick={() => dismissChange(ev.id)} title={t('btn.close')} style={{ flex: '0 0 auto', border: 'none', background: 'transparent', cursor: 'pointer', color: '#9a3412', display: 'flex', padding: 4, borderRadius: 6 }}>
        <Icon name="x" size={16} stroke={2.2} />
      </button>
    </div>
  );
}

/* カレンダー同期の見張りバー（2026-07-14 恒久対策）：
   Googleのサイレント認可が拒否されると自動同期が「静かに」止まり、予定・会議リンクが古くなる事故が起きた
   （3日間停止→2回目商談に1回目のURLを表示）。開いたときに最終同期が24時間より古ければ、
   まずサイレント再同期を試み、ダメなら目立つバナー＋ワンクリック復旧（クリック＝ユーザー操作なので
   Googleのポップアップが確実に出せる）。連携未設定の人には出さない。 */
function GcalSyncBanner() {
  const { showToast, currentUser, loginAs } = useStore();
  const [bad, setBad] = React.useState(null); // {last, mode:'connect'|'reauth'|'legacy'} を入れたら表示
  const [busy, setBusy] = React.useState(false);
  React.useEffect(() => {
    let alive = true, running = false;
    const check = async () => {
      if (!alive || running) return; running = true;
      try {
        /* ★停止判定の唯一の真実はサーバー（2026-08-27）。以前は localStorage('anken_gcal_last') だけを
           見ていたため、判定がブラウザ・プロファイル単位でぶれ、サーバーが取り込めていても
           「停止中」と出続けた。サーバー側オフライン同期が回っているなら、そもそも出す必要が無い。 */
        let st = null;
        try { st = await API.gcalServerStatus(); } catch (_) {}
        if (!alive) return;
        if (st && st.serverReady) {
          const optedIn = !!(st.connected || st.lastSyncAt || (window.gcalAutoEnabled && window.gcalAutoEnabled()));
          if (!optedIn) { setBad(null); return; }                 // 連携していない人には出さない
          const last = st.lastSyncAt || null;                     // JST 'YYYY-MM-DDTHH:mm'
          const ageH = last ? (Date.now() - new Date(last + '+09:00').getTime()) / 3600000 : 999;
          // サーバーが15分ごとに回っていて実際に最近同期できている＝正常。バナーは出さない
          if (st.serverSync && ageH < 24) { setBad(null); return; }
          // serverSync=true なのに古い＝Google側で認可が取り消された等。どちらも復旧手段は「接続し直す」1手
          // ボタンのクリック文脈でポップアップが確実に開くよう、GIS だけ先に読んでおく
          // （ここではポップアップは開かない。2026-07-24 の非クリック経路禁止に抵触しない）
          try { await window.gcalPrepare(); } catch (_) {}
          if (!alive) return;
          setBad({ last, mode: st.serverSync ? 'reauth' : 'connect' });
          return;
        }
        /* サーバー側同期が使えない環境（GOOGLE_CLIENT_SECRET 未設定など）だけ従来のブラウザ経路で見張る。
           サイレント自己修復は「有効なキャッシュトークンがある時だけ」＝キャッシュ切れで
           syncGoogleCalendar(false) を呼ぶと GIS がアカウント選択ポップアップを開いてしまい、
           タブ復帰のたびに出続ける事故になった（2026-07-24 恒久対策）。 */
        if (!(window.gcalAutoEnabled && window.gcalAutoEnabled())) { setBad(null); return; }
        const last = localStorage.getItem('anken_gcal_last');
        const ageH = last ? (Date.now() - new Date(last).getTime()) / 3600000 : 999;
        if (ageH < 24) { if (alive) setBad(null); return; }
        if (window.gcalHasToken && window.gcalHasToken()) {
          try { await window.gcalPrepare(); await window.syncGoogleCalendar(false); if (alive) setBad(null); return; } catch (_) {}
        } else {
          try { await window.gcalPrepare(); } catch (_) {} // バナーのクリックに備えて GIS だけ先読み（ポップアップは開かない）
        }
        if (alive) setBad({ last: window.gcalLastSyncJst ? window.gcalLastSyncJst(last) : null, mode: 'legacy' });
      } catch (_) {}
      finally { running = false; }
    };
    check();
    // タブを何日も開きっぱなしでも見張れるように：1時間ごと＋タブへ戻った瞬間に再チェック
    const iv = setInterval(check, 60 * 60 * 1000);
    const onVis = () => { if (!document.hidden) check(); };
    document.addEventListener('visibilitychange', onVis);
    return () => { alive = false; clearInterval(iv); document.removeEventListener('visibilitychange', onVis); };
  }, []);
  if (!bad) return null;
  const rehydrate = async () => { try { const d = await API.bootstrap(); window.hydrateAppData(d); loginAs(currentUser.id); } catch (_) {} };
  const resync = () => {
    if (busy) return; setBusy(true);
    // legacy（サーバー側同期が使えない環境）だけ従来の1時間トークン方式。それ以外は一度きりの恒久接続。
    const run = bad.mode === 'legacy'
      ? window.syncGoogleCalendar(true).then((n) => t('gcal.stale.done', { n }))
      : window.gcalConnectOffline().then(() => t('gcal.stale.doneServer'));
    run
      .then(async (msg) => { setBad(null); showToast(msg); await new Promise(r => setTimeout(r, 3000)); await rehydrate(); })
      .catch((e) => showToast((e && e.message) || t('gcal.stale.failed'), 'x'))
      .finally(() => setBusy(false));
  };
  /* サーバー由来の lastSyncAt は既に JST 'YYYY-MM-DDTHH:mm'、legacy 経路は gcalLastSyncJst で正規化済み。
     生の ISO(UTC) を slice すると9時間ずれる（2026-08-10 の修正）ので、ここでは必ず正規化後の値を使う。 */
  const lastJst = bad.last || null;
  const lastFmt = lastJst ? `${fmtDate(String(lastJst).slice(0, 10), true)} ${String(lastJst).slice(11, 16)}` : '—';
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 18px', background: '#fffbeb', borderBottom: '1px solid #f5dfa6', color: '#92400e', fontSize: 13, fontWeight: 500 }}>
      <Icon name="alert" size={15} stroke={2} style={{ color: '#d97706', flex: '0 0 auto' }} />
      <span style={{ flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
        <b>{t('gcal.stale.tag')}</b> {t(bad.mode === 'legacy' ? 'gcal.stale.msg' : 'gcal.stale.msgServer', { when: lastFmt })}
      </span>
      <Button size="sm" variant="primary" icon={busy ? 'refresh' : 'google'} onClick={resync} disabled={busy}>
        {busy ? t('gcal.stale.syncing') : t(bad.mode === 'legacy' ? 'gcal.stale.action' : 'gcal.stale.actionServer')}
      </Button>
    </div>
  );
}

/* ReadyCrew取込の見張り番：リード最終取込(importedAt)が26時間止まったら黄色バナー。
   2026-07-16の障害（RC新バケット取り漏れ＋ChromeがTampermonkeyの「ユーザースクリプトを許可」を
   勝手にOFF→同期が無音で全停止）を機に常設。取込はユーザーのブラウザ（Tampermonkey）でしか
   動かないため自己修復はできない＝「ReadyCrewを開く」の一手を最短で案内する。
   RCリードのid接頭辞は rcl+数字（rclhnv…は発注ナビ由来なので除外）。RCリードが1件も無い
   環境（新規WS等）では出さない。 */
function RcSyncBanner() {
  const [stale, setStale] = React.useState(null); // {last, hours}
  React.useEffect(() => {
    let alive = true, lastFetch = 0;
    const calc = (leads) => {
      let rcMax = '';
      (leads || []).forEach(l => { if (/^rcl\d/.test(l.id || '')) { const ts = l.importedAt || ''; if (ts > rcMax) rcMax = ts; } });
      if (!rcMax) return null; // RC取込を使っていない環境では黙る
      const ageH = (Date.now() - new Date(rcMax).getTime()) / 3600000;
      return ageH > 26 ? { last: rcMax, hours: Math.floor(ageH) } : null;
    };
    try { setStale(calc((window.APP_DATA && window.APP_DATA.rcLeads) || [])); } catch (_) {} // 未初期化（ログイン前等）でも落とさない
    // タブ開きっぱなし対策：表示復帰ごと（30分以上間隔）＋2時間ごとに最新リードで再判定
    const recheck = async () => {
      if (!alive) return; const now = Date.now(); if (now - lastFetch < 30 * 60 * 1000) return; lastFetch = now;
      try { const bs = await API.bootstrap(); if (alive) setStale(calc(bs.rcLeads)); } catch (_) {}
    };
    const iv = setInterval(recheck, 2 * 60 * 60 * 1000);
    const onVis = () => { if (!document.hidden) recheck(); };
    document.addEventListener('visibilitychange', onVis);
    return () => { alive = false; clearInterval(iv); document.removeEventListener('visibilitychange', onVis); };
  }, []);
  if (!stale) return null;
  const lastFmt = `${fmtDate(stale.last.slice(0, 10), true)} ${stale.last.slice(11, 16)}`;
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 18px', background: '#fffbeb', borderBottom: '1px solid #f5dfa6', color: '#92400e', fontSize: 13, fontWeight: 500 }}>
      <Icon name="alert" size={15} stroke={2} style={{ color: '#d97706', flex: '0 0 auto' }} />
      <span style={{ flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={t('rcsync.stale.help')}>
        <b>{t('rcsync.stale.tag')}</b> {t('rcsync.stale.msg', { when: lastFmt, h: stale.hours })}
      </span>
      <Button size="sm" variant="primary" icon="link" onClick={() => window.open('https://alion.partner.readycrew.cloud/matchings?status=IN_PROGRESS_ALL&page=1', '_blank')}>
        {t('rcsync.stale.action')}
      </Button>
    </div>
  );
}

Object.assign(window, { Sidebar, Topbar, NotificationPanel, Page, CommandPalette, ChangePasswordModal, ChangeBar, GcalSyncBanner, RcSyncBanner });
