/* ============================================================
   共有UIプリミティブ — Icon, Avatar, StatusBadge, Button, Modal,
   AssignPopover, フォーマッタ
   window に各コンポーネントを公開
   ============================================================ */
const { useState, useRef, useEffect, useMemo } = React;

/* ---------------- Icon ---------------- */
const ICONS = {
  dashboard: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z',
  cases:     'M3 7h18v13a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7zm5-3h8a1 1 0 0 1 1 1v2H7V5a1 1 0 0 1 1-1z',
  calendar:  'M7 2v3M17 2v3M3 8h18M5 4h14a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z',
  customers: 'M16 11a4 4 0 1 0-8 0M2 21a7 7 0 0 1 20 0',
  bell:      'M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 0 1-3.4 0',
  settings:  'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z',
  search:    'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3',
  plus:      'M12 5v14M5 12h14',
  chevronDown:'M6 9l6 6 6-6',
  chevronRight:'M9 6l6 6-6 6',
  chevronLeft:'M15 6l-6 6 6 6',
  check:     'M20 6L9 17l-5-5',
  copy:      'M9 9h11a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1zM5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1',
  clock:     'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
  user:      'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 21a8 8 0 0 1 16 0',
  phone:     'M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2 4.2 2 2 0 0 1 4 2h3a2 2 0 0 1 2 1.7c.1.9.3 1.8.6 2.7a2 2 0 0 1-.5 2.1L8 9.6a16 16 0 0 0 6 6l1.1-1.1a2 2 0 0 1 2.1-.5c.9.3 1.8.5 2.7.6a2 2 0 0 1 1.7 2z',
  video:     'M23 7l-7 5 7 5V7zM1 5h13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H1z',
  mail:      'M4 4h16a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM3 6l9 6 9-6',
  mapPin:    'M21 10c0 6-9 12-9 12s-9-6-9-12a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
  link:      'M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.5 1.5M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1.5-1.5',
  edit:      'M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7M18.5 2.5a2.1 2.1 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z',
  trash:     'M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7',
  attach:    'M21.4 11.05l-9.2 9.2a5 5 0 0 1-7.1-7.1l9.2-9.2a3.5 3.5 0 0 1 5 5l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5',
  history:   'M3 3v5h5M3.05 13A9 9 0 1 0 6 5.3L3 8M12 7v5l4 2',
  filter:    'M22 3H2l8 9.5V19l4 2v-8.5L22 3z',
  menu:      'M3 6h18M3 12h18M3 18h18',
  alert:     'M12 9v4M12 17h.01M10.3 3.9l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3L13.7 4a2 2 0 0 0-3.4 0z',
  check2:    'M22 11.1V12a10 10 0 1 1-5.9-9.1M22 4L12 14.5l-3-3',
  x:         'M18 6L6 18M6 6l12 12',
  logout:    'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
  google:    'GOOGLE',
  dots:      'M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM19 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM5 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z',
  arrowRight:'M5 12h14M13 5l7 7-7 7',
  refresh:   'M23 4v6h-6M1 20v-6h6M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15',
  grid:      'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z',
  inbox:     'M22 12h-6l-2 3h-4l-2-3H2M5.5 5h13l3.5 7v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6z',
  spark:     'M12 2l1.9 5.4L19 9l-5.1 1.6L12 16l-1.9-5.4L5 9l5.1-1.6zM19 14l.9 2.6L22 18l-2.1.4L19 21l-.9-2.6L16 18l2.1-.4z',
  play:      'M6 4l14 8-14 8z',
  help:      'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM9.2 9.2a3 3 0 0 1 5.6 1c0 2-2.8 2.5-2.8 4M12 17h.01',
  download:  'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3',
  globe:     'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18',
  chart:     'M3 3v18h18M8 17v-5M13 17V8M18 17v-9',
  eye:       'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
};
function Icon({ name, size = 18, stroke = 2, fill = 'none', className = '', style = {} }) {
  if (name === 'google') {
    return (
      <svg width={size} height={size} viewBox="0 0 24 24" className={className} style={style}>
        <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.76h3.57c2.08-1.92 3.27-4.74 3.27-8.09z"/>
        <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.76c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23z"/>
        <path fill="#FBBC05" d="M5.84 14.1a6.6 6.6 0 0 1 0-4.2V7.06H2.18a11 11 0 0 0 0 9.88l3.66-2.84z"/>
        <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1A11 11 0 0 0 2.18 7.06l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z"/>
      </svg>
    );
  }
  const isFilled = fill !== 'none';
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill={isFilled ? fill : 'none'} stroke={isFilled ? 'none' : 'currentColor'}
      strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" className={className} style={style}>
      <path d={ICONS[name] || ''} />
    </svg>
  );
}

/* ---------------- Avatar ---------------- */
function Avatar({ user, size = 28, ring = false }) {
  if (!user) {
    return (
      <div style={{ width: size, height: size, borderRadius: '50%', background: '#eceef1', color: '#9aa1ab',
        display: 'flex', alignItems: 'center', justifyContent: 'center', flex: '0 0 auto', border: '1px dashed #cfd4db' }}>
        <Icon name="user" size={size * 0.5} stroke={2} />
      </div>
    );
  }
  const ringShadow = ring ? '0 0 0 2px #fff, 0 0 0 3px ' + (user.color || '#4a5af0') + '44' : 'none';
  // プロフィール写真（data URL）があれば画像、なければ頭文字
  if (user.avatar) {
    return (
      <div title={user.name} style={{ width: size, height: size, borderRadius: '50%', flex: '0 0 auto',
        backgroundImage: `url(${user.avatar})`, backgroundSize: 'cover', backgroundPosition: 'center', backgroundColor: '#eceef1',
        boxShadow: ringShadow }} />
    );
  }
  const initial = user.short ? user.short.charAt(0) : user.name.charAt(0);
  return (
    <div title={user.name} style={{ width: size, height: size, borderRadius: '50%', background: user.color, color: '#fff',
      display: 'flex', alignItems: 'center', justifyContent: 'center', flex: '0 0 auto',
      fontSize: size * 0.42, fontWeight: 600, letterSpacing: '.02em',
      boxShadow: ringShadow }}>
      {initial}
    </div>
  );
}
function AvatarGroup({ users, size = 26 }) {
  return (
    <div style={{ display: 'flex' }}>
      {users.map((u, i) => (
        <div key={u.id} style={{ marginLeft: i === 0 ? 0 : -8, borderRadius: '50%', boxShadow: '0 0 0 2px #fff' }}>
          <Avatar user={u} size={size} />
        </div>
      ))}
    </div>
  );
}

/* ---------------- StatusBadge ---------------- */
function StatusBadge({ status, dot = true, size = 'md' }) {
  const s = window.APP_DATA.STATUS[status];
  if (!s) return null;
  const pad = size === 'sm' ? '2px 8px' : '3px 10px';
  const fs = size === 'sm' ? 12 : 12.5;
  return (
    <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: s.soft, color: s.fg,
      padding: pad, borderRadius: 7, fontSize: fs, fontWeight: 700, lineHeight: 1.4, whiteSpace: 'nowrap' }}>
      {dot && <span aria-hidden="true" style={{ fontSize: 10 }}>{({new:'●', working:'◐', negotiating:'◑', won:'✓', lost:'✕', done:'■'})[status] || '●'}</span>}
      {s.label}
    </span>
  );
}

/* ---------------- Button ---------------- */
function Button({ children, variant = 'default', size = 'md', icon, iconRight, onClick, style = {}, disabled, full }) {
  const [hover, setHover] = useState(false);
  const sizes = {
    sm: { padding: '5px 10px', fontSize: 12.5, height: 30 },
    md: { padding: '7px 14px', fontSize: 13.5, height: 36 },
    lg: { padding: '10px 18px', fontSize: 14.5, height: 44 },
  }[size];
  const variants = {
    primary: { background: hover ? '#3a49d8' : '#4a5af0', color: '#fff', border: '1px solid transparent', boxShadow: '0 1px 2px rgba(40,30,120,.18)' },
    default: { background: hover ? '#f6f7fa' : '#fff', color: '#2b2f38', border: '1px solid #e2e5ea' },
    subtle:  { background: hover ? '#eef0f3' : 'transparent', color: '#454b56', border: '1px solid transparent' },
    danger:  { background: hover ? '#fdecec' : '#fff', color: '#c0392b', border: '1px solid #f1cdcd' },
    ghost:   { background: hover ? 'rgba(255,255,255,.14)' : 'transparent', color: '#fff', border: '1px solid rgba(255,255,255,.22)' },
  }[variant];
  return (
    <button onClick={onClick} disabled={disabled}
      onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
      style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 7, cursor: disabled ? 'not-allowed' : 'pointer',
        borderRadius: 8, fontWeight: 600, fontFamily: 'inherit', transition: 'all .12s', whiteSpace: 'nowrap',
        opacity: disabled ? 0.5 : 1, width: full ? '100%' : 'auto', ...sizes, ...variants, ...style }}>
      {icon && <Icon name={icon} size={sizes.fontSize + 2.5} stroke={2} />}
      {children}
      {iconRight && <Icon name={iconRight} size={sizes.fontSize + 2} stroke={2} />}
    </button>
  );
}

/* ---------------- IconButton ---------------- */
function IconButton({ name, size = 18, onClick, title, active, style = {} }) {
  const [hover, setHover] = useState(false);
  return (
    <button title={title} onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
      style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 34, height: 34,
        borderRadius: 8, border: 'none', cursor: 'pointer', color: active ? '#4a5af0' : '#5b626d',
        background: active ? '#eef0fe' : (hover ? '#eef0f3' : 'transparent'), transition: 'all .12s', ...style }}>
      <Icon name={name} size={size} stroke={2} />
    </button>
  );
}

/* ---------------- Card / linkBtn（共有レイヤ：複数画面で使用。元 dashboard.jsx から移設） ---------------- */
function Card({ title, action, children, pad = 18, style = {} }) {
  return (
    <div className="asm-card" style={{ background: '#fff', border: '1px solid #e9eaee', borderRadius: 16, boxShadow: '0 1px 2px rgba(20,22,40,.04)', overflow: 'hidden', ...style }}>
      {title && (
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '15px 18px', borderBottom: '1px solid #f0f1f4' }}>
          <div style={{ fontSize: 14, fontWeight: 700, color: '#1c2027' }}>{title}</div>
          {action}
        </div>
      )}
      <div style={{ padding: pad }}>{children}</div>
    </div>
  );
}
const linkBtn = { display: 'inline-flex', alignItems: 'center', gap: 4, border: 'none', background: 'transparent', color: '#4a5af0', fontSize: 12.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' };

/* ---------------- Modal ---------------- */
function Modal({ open, onClose, children, width = 560, title, subtitle, footer, className = '' }) {
  useEffect(() => {
    const h = (e) => { if (e.key === 'Escape') onClose(); };
    if (open) window.addEventListener('keydown', h);
    return () => window.removeEventListener('keydown', h);
  }, [open, onClose]);
  if (!open) return null;
  // body 直下へポータル：サイドバー等のスタッキングコンテキストに閉じ込められて
  // トップバー/フィルタが前面に透ける不具合を防ぐ（モーダルは常に最前面）
  return ReactDOM.createPortal(
    <div onMouseDown={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(24,26,32,.42)', backdropFilter: 'blur(2px)',
      zIndex: 200, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: '7vh 20px 40px', overflowY: 'auto' }}>
      <div className={className} onMouseDown={(e) => e.stopPropagation()} style={{ background: '#fff', borderRadius: 14, width, maxWidth: '100%',
        boxShadow: '0 24px 60px rgba(20,22,40,.28)', animation: 'modalIn .18s ease', overflow: 'hidden' }}>
        {(title || subtitle) && (
          <div style={{ padding: '20px 24px 16px', borderBottom: '1px solid #eef0f3', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
            <div>
              <div style={{ fontSize: 16.5, fontWeight: 700, color: '#1c2027' }}>{title}</div>
              {subtitle && <div style={{ fontSize: 13, color: '#7b828d', marginTop: 3 }}>{subtitle}</div>}
            </div>
            <IconButton name="x" size={18} onClick={onClose} />
          </div>
        )}
        <div style={{ padding: '20px 24px' }}>{children}</div>
        {footer && <div style={{ padding: '14px 24px', borderTop: '1px solid #eef0f3', display: 'flex', justifyContent: 'flex-end', gap: 10, background: '#fbfbfc' }}>{footer}</div>}
      </div>
    </div>,
    document.body
  );
}

/* ---------------- Field ---------------- */
function Field({ label, required, children, hint }) {
  return (
    <div style={{ marginBottom: 16 }}>
      {label && (
        <label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#4b515c', marginBottom: 6 }}>
          {label}{required && <span style={{ color: '#dc2626', marginLeft: 4 }}>*</span>}
        </label>
      )}
      {children}
      {hint && <div style={{ fontSize: 12, color: '#9aa1ab', marginTop: 5 }}>{hint}</div>}
    </div>
  );
}
const inputStyle = {
  width: '100%', padding: '9px 12px', borderRadius: 8, border: '1px solid #dfe2e8', fontSize: 13.5,
  fontFamily: 'inherit', color: '#1f2430', background: '#fff', outline: 'none', boxSizing: 'border-box', transition: 'border .12s, box-shadow .12s',
};
function TextInput(props) {
  const [f, setF] = useState(false);
  return <input {...props} style={{ ...inputStyle, border: '1px solid ' + (f ? '#4a5af0' : '#dfe2e8'), boxShadow: f ? '0 0 0 3px #4a5af022' : 'none', ...(props.style || {}) }}
    onFocus={(e) => { setF(true); props.onFocus && props.onFocus(e); }} onBlur={(e) => { setF(false); props.onBlur && props.onBlur(e); }} />;
}

/* ---------------- AssignPopover (インクリメンタル検索で担当指定) ---------------- */
/* 担当ポップオーバー：明示的なボタンで主担当を変更・解除し、副担当も追加・解除する */
function AssignPopover({ ownerId, subIds = [], onPick, onToggleSub, onClose, anchorStyle = {} }) {
  const [q,setQ]=useState(''),[busy,setBusy]=useState(false),[error,setError]=useState('');
  const ref=useRef(null),D=window.APP_DATA;
  useEffect(()=>{const h=e=>{if(ref.current&&!ref.current.contains(e.target)&&!busy)onClose();};document.addEventListener('mousedown',h);return()=>document.removeEventListener('mousedown',h);},[onClose,busy]);
  const pick=async id=>{if(busy)return;setBusy(true);setError('');try{await onPick(id);}catch(e){setError(e.message);}finally{setBusy(false);}};
  const list=D.users.filter(u=>[u.name,u.short,u.email].some(v=>String(v||'').toLowerCase().includes(q.toLowerCase())));
  return <div ref={ref} className="asm-assignee-popover" style={{position:'absolute',zIndex:200,width:'min(316px, calc(100vw - 24px))',maxWidth:316,background:'#fff',borderRadius:12,border:'1px solid #e6e8ec',boxShadow:'0 16px 40px rgba(20,22,40,.18)',overflow:'hidden',...anchorStyle}}>
    <div style={{padding:10,borderBottom:'1px solid #f0f1f4'}}><div style={{display:'flex',alignItems:'center',gap:8,background:'#f5f6f8',borderRadius:7,padding:'6px 9px'}}><Icon name="search" size={15} style={{color:'#9aa1ab'}}/><input autoFocus aria-label={window.t("extra.assign.search")} value={q} onChange={e=>setQ(e.target.value)} placeholder={t('ui.searchMembers')} style={{border:0,background:'transparent',minWidth:0,width:'100%',fontSize:13}}/></div>
      {ownerId&&<button className="asm-owner-clear" disabled={busy} onClick={()=>pick(null)}>{window.t("extra.assign.clear")}</button>}
      {error&&<div role="alert" className="asm-error" style={{marginTop:8,marginBottom:0}}>{localizeMessage(error)}</div>}
    </div>
    <div style={{maxHeight:310,overflowY:'auto',padding:6}}>{list.map(u=>{
      const isOwner=u.id===ownerId,isSub=subIds.includes(u.id);
      return <div key={u.id} className="asm-assignee-option"><Avatar user={u} size={28}/><div style={{flex:1,minWidth:0}}><div className="asm-assignee-name"><strong title={u.name}>{u.name}</strong>{isOwner&&<span className="asm-count">{t('ui.mainAssignee')}</span>}{isSub&&<span className="asm-count">{t('ui.supportAssignee')}</span>}</div><small className="asm-muted">{t('role.'+(['admin','manager','member'].includes(u.role)?u.role:'member'))}</small><div className="asm-assignee-actions">{!isOwner&&<button disabled={busy} aria-label={u.name+window.t("extra.assign.mainSuffix")} onClick={()=>pick(u.id)}>{window.t("extra.assign.makeMain")}</button>}{onToggleSub&&!isOwner&&<button disabled={busy} aria-label={u.name+(isSub?window.t("extra.assign.removeSupportSuffix"):window.t("extra.assign.addSupportSuffix"))} onClick={()=>onToggleSub(u.id)}>{isSub?t('btn.removeSupport'):t('btn.addSupport')}</button>}</div></div></div>;
    })}{!list.length&&<div className="asm-muted" style={{padding:16,textAlign:'center'}}>{t('ui.noMatch')}</div>}</div>
  </div>;
}

/* ---------------- フォーマッタ ---------------- */
const WD = ['日', '月', '火', '水', '木', '金', '土'];
// 曜日ラベル（言語連動）。WD[i] 直参照ではなく wd(i)/wdAll() を使う（言語切替に追従）
const wd = (i) => t('fmt.wd.' + i);
const wdAll = () => [0, 1, 2, 3, 4, 5, 6].map(i => t('fmt.wd.' + i));
function parseDT(s) { if (!s) return null; s = String(s); if (/^\d{4}-\d{2}-\d{2}$/.test(s)) s += 'T00:00'; const d = new Date(s.replace(' ', 'T')); return isNaN(d) ? null : d; } // 日付のみ(時間未定)は現地0時として解釈＝UTCズレ防止
function fmtDate(s, withWd = false) {
  const d = parseDT(s); if (!d) return '—';
  const base = (d.getMonth() + 1) + '/' + d.getDate();
  return withWd ? `${base}(${wd(d.getDay())})` : base;
}
function fmtDateFull(s) { const d = parseDT(s); if (!d) return '—'; return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`; }
// 年付きの短縮表記：YYYY/M/D（曜日オプション）。一覧の商談日など、年が分かると年跨ぎで混乱しない箇所で使う
function fmtDateY(s, withWd = false) {
  const d = parseDT(s); if (!d) return '—';
  const base = d.getFullYear() + '/' + (d.getMonth() + 1) + '/' + d.getDate();
  return withWd ? `${base}(${wd(d.getDay())})` : base;
}
function fmtTime(s) { if (!s || /^\d{4}-\d{2}-\d{2}$/.test(String(s))) return ''; const d = parseDT(s); if (!d) return ''; return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } // 日付のみ(時間未定)は空
function fmtDateTime(s) { const d = parseDT(s); if (!d) return '—'; return `${fmtDate(s, true)} ${fmtTime(s)}`; }
// 「今日」(JST, YYYY-MM-DD)。毎回現在日を計算する関数（旧 TODAY は読み込み時に固定され、
// タブを開きっぱなしで日付が変わると「本日の商談」「期限」等の判定が過去になる問題があった #5）
function today() { const d = new Date(); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; }
function daysUntil(s) { const d = parseDT(s); if (!d) return null; const t = parseDT(today()); return Math.round((d - t) / 86400000); }
function relTime(s) {
  const d = parseDT(s); if (!d) return '';
  const now = new Date(); const diff = (now - d) / 60000;
  if (diff < 1) return t('fmt.reltime.now');
  if (diff < 60) return t('fmt.reltime.minutesAgo', { n: Math.floor(diff) });
  if (diff < 1440) return t('fmt.reltime.hoursAgo', { n: Math.floor(diff / 60) });
  return t('fmt.reltime.daysAgo', { n: Math.floor(diff / 1440) });
}

/* Enter で送信してよいか（IME変換中は false）。
   日本語入力では「変換を確定する Enter」も keydown として届くため、これを見ないと
   未確定・変換途中の文字がそのまま送信される（2026-08-13 報告：AIチャットで頻発）。
   isComposing が正式な判定。keyCode 229 は composition イベントを出さない環境向けの保険。
   Shift+Enter（改行）を許すかは呼び出し側の責務＝ここでは見ない。 */
function enterSubmits(e) {
  if (!e || e.key !== 'Enter') return false;
  const ne = e.nativeEvent || e;
  return !ne.isComposing && e.keyCode !== 229;
}

/* ---------------- 案件の派生値 ---------------- */
const _normCo = (s) => (s || '').replace(/株式会社|（株）|\(株\)|・(レディクル|発注ナビ).*$|[（(].*$|\s+/g, '');
/* この案件の既知の商談日時（次回商談・ReadyCrew商談日・会議記録・Googleカレンダー）を昇順で。
   nextMeeting/appointAt が食い違う場合は casePlanPick の「正」の側だけを含める
   ＝一覧・詳細の商談日表示とカレンダー登録日（サーバー側 caseMeetingAnchorSrv）が一致する（2026-07-13） */
function caseMeetingDates(c) {
  if (!c) return [];
  const ds = [];
  const pick = casePlanPick(c);
  if (c.nextMeeting && pick.nm) ds.push(c.nextMeeting);
  if (c.appointAt && pick.ap) ds.push(c.appointAt);
  (c.meetingDocs || []).forEach(d => { if (d.datetime) ds.push(d.datetime); });
  // Googleカレンダー同期の予定（会社名一致）も商談日として含める
  const D = window.APP_DATA;
  if (D && D.gcalEvents && D.gcalEvents.length) {
    const cust = D.customer(c.customerId);
    const core = cust ? _normCo(cust.company) : '';
    if (core.length >= 2) {
      D.gcalEvents.forEach(g => {
        const gt = _normCo(g.title);
        // 会社名照合は他5箇所と統一：フルコア部分一致(>=3)＋2文字社名は完全一致。
        // 先頭4文字の緩いフォールバックは共通接頭辞の別会社を誤爆させるため使わない（パナソニックHVAC⇔EW）
        if (g.datetime && ((core.length >= 3 && gt.includes(core)) || (core.length >= 2 && gt === core))) ds.push(g.datetime);
      });
    }
  }
  return [...new Set(ds)].sort();
}
/* 商談回数：実際に「実施済み」の商談を日単位で数えた回数（＝商談日が今日以前のもの。未来の予約はまだ実施していないので除外）。
   一覧・詳細・分析で共通利用。次回予約の表示は caseMeetingAt（予約を含む）が担当。 */
function caseMeetingCount(c) {
  return new Set(caseMeetingDates(c).map(d => (d || '').slice(0, 10)).filter(d => d && d <= today())).size;
}
/* 商談日：これから予定があればその直近、無ければ最後に実施した商談日 */
function caseMeetingAt(c) {
  /* 明示の次回予定（nextMeeting/appointAt）が今日以降にあれば常にそれが正。
     以前は全日付ソートの「今日以降の最初」だったため、当日に取り込まれた会議記録の時刻が
     9/1の予定より先に並び「次回商談」欄を乗っ取った（2026-08-19 山形屋 11:51事件） */
  const pick = casePlanPick(c || {});
  const plan = [(pick.nm && c && c.nextMeeting), (pick.ap && c && (c.appointAtLive || c.appointAt))]
    .filter(Boolean).filter(d => String(d).slice(0, 10) >= today()).sort()[0];
  if (plan) return plan;
  const ds = caseMeetingDates(c);
  if (!ds.length) return null;
  const upcoming = ds.find(d => d.slice(0, 10) >= today());
  return upcoming || ds[ds.length - 1];
}
/* 「成約（勝ち）」の統一判定：クローズ済みかつ失注でないステータス＝成約(won)＋受注(done)、
   および将来クローズ扱いにした独自ステータス。分析・ダッシュボードの成約集計はすべてこれを使い、
   「受注(done)にしたのに数字に出ない」ズレを無くす（2026-07-16）。closed 既定＝won/lost/done。 */
function isCaseWonStatus(status) {
  const st = window.APP_DATA && window.APP_DATA.STATUS && window.APP_DATA.STATUS[status];
  const closed = (st && st.closed != null) ? !!st.closed : ['won', 'lost', 'done'].includes(status);
  return closed && status !== 'lost';
}
/* 成約日（YYYY-MM-DD）：成約扱いの案件のみ、wonAt（記録があれば）→無ければ updatedAt で近似。
   非成約は '' を返す（期間フィルタで自然に除外される）。 */
function caseWonYmd(c) {
  return c && isCaseWonStatus(c.status) ? (((c.wonAt || c.updatedAt) || '').slice(0, 10)) : '';
}
/* 期限：手動設定があればそれ、無ければ「最後の商談日 + 5日」で自動計算
   （2回目商談が終われば期限もその日から数え直される） */
function caseDue(c) {
  if (!c) return null;
  if (c.due) return c.due;
  const ds = caseMeetingDates(c);
  if (!ds.length) return null;
  const d = parseDT(ds[ds.length - 1]);
  if (!d) return null;
  d.setDate(d.getDate() + 5);
  const p = (n) => String(n).padStart(2, '0');
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
/* 提案メール期限：手動(proposalMailDue)があればそれ、無ければ「商談日 + 7日」で自動計算 */
function caseMailDue(c) {
  if (!c) return null;
  if (c.proposalMailDue) return c.proposalMailDue;
  const d = parseDT(caseMeetingAt(c));
  if (!d) return null;
  d.setDate(d.getDate() + 7);
  const p = (n) => String(n).padStart(2, '0');
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
/* 本文（相談内容）から会議URLを自動抽出。担当者用（ホスト）/参加者用を文脈とドメインで判別。
   readycrew.cloud（案件ページのURL）は会議リンクではないため対象外 */
function extractMeetingLinks(text) {
  if (!text) return [];
  const out = [];
  const re = /https?:\/\/[^\s<>"'）)\]、。]+/g;
  let m;
  while ((m = re.exec(text))) {
    const url = m[0].replace(/[.,、。]+$/, '');
    if (!/zoom\.us\/|meet\.google\.com\/|meeting\.readycrew\.jp\/|teams\.microsoft\.com\/|webex\.com\//i.test(url)) continue;
    if (out.some(l => l.url === url)) continue;
    const ctx = text.slice(Math.max(0, m.index - 120), m.index);
    // 直前の見出し（□担当者様用URL / □参加者用URL）のうち近い方。meeting.readycrew.jp は常にホスト用
    const ip = Math.max(ctx.lastIndexOf('参加者用'), ctx.lastIndexOf('参加者様用'));
    const ih = Math.max(ctx.lastIndexOf('担当者用'), ctx.lastIndexOf('担当者様用'), ctx.toLowerCase().lastIndexOf('hosturl'));
    let label;
    if (/meeting\.readycrew\.jp/i.test(url)) label = t('ui.meetingLink.hostUrl');
    else if (ip > ih) label = t('ui.meetingLink.guestUrl');
    else if (ih > ip) label = t('ui.meetingLink.hostUrl');
    else label = /meet\.google/i.test(url) ? t('ui.meetingLink.googleMeet') : t('ui.meetingLink.generic');
    out.push({ label, url, auto: true });
  }
  return out;
}
/* 手動登録＋本文から自動抽出した会議リンク（重複URL除外）。
   filterReal=true で案件ページ系URLを除外（予定行の参加ボタン用） */
/* 手入力の「今回の会議URL・場所」(meetingPlace) が会議URLとして使える値なら返す。
   住所・会議室名は null。readycrew.cloud（案件ページURL）は会議URLではないので他の抽出経路と同じく除外。
   2回目以降は会議URLが毎回変わるのに meetingLinks/本文は追記なので、これが最優先（2026-08-12） */
function casePlaceUrl(c) {
  const p = String((c && c.meetingPlace) || '').trim();
  return (/^https?:\/\//i.test(p) && !/readycrew\.cloud|\/matchings\//i.test(p)) ? p : null;
}
function caseAllMeetingLinks(c, filterReal) {
  if (!c) return [];
  const placeUrl = casePlaceUrl(c);
  const head = placeUrl ? [{ label: t('ui.meetingLink.thisTime'), url: placeUrl, place: true }] : []; // id 無し＝カードから削除できない（欄を空にして消す）
  const manual = (c.meetingLinks || []).filter(l => l && l.url !== placeUrl);
  const auto = extractMeetingLinks(c.body).filter(a => a.url !== placeUrl && !manual.some(l => l.url === a.url));
  const all = [...head, ...manual, ...auto];
  return filterReal ? all.filter(l => l.url && !/readycrew\.cloud|\/matchings\//i.test(l.url)) : all;
}
/* 提案書・見積書・プロトタイプの提出ステータス */
const PROPOSAL_STATUSES = {
  none:      { key: 'none',      get label(){return window.t("label.extra151");},  color: '#6b7280', soft: '#eff1f3' },
  preparing: { key: 'preparing', get label(){return window.t("label.extra152");},  color: '#d97706', soft: '#fdf0db' },
  review:    { key: 'review',    get label(){return window.t("label.extra153");},  color: '#0891b2', soft: '#e0f4f8' },
  submitted: { key: 'submitted', get label(){return window.t("label.extra154");}, color: '#16a34a', soft: '#e3f5e9' },
  na:        { key: 'na',        get label(){return window.t("label.extra155");},    color: '#9aa1ab', soft: '#f4f5f7' },
};
const PROPOSAL_ORDER = ['none', 'preparing', 'review', 'submitted', 'na'];
/* メール送信ステータス（会議お礼メール・提案メール 等） */
const MAIL_STATUSES = {
  none:      { key: 'none',      get label(){return window.t("label.extra156");},  color: '#6b7280', soft: '#eff1f3' },
  preparing: { key: 'preparing', get label(){return window.t("label.extra157");},  color: '#d97706', soft: '#fdf0db' },
  sent:      { key: 'sent',      get label(){return window.t("cd.calInvite.done");}, color: '#16a34a', soft: '#e3f5e9' },
  na:        { key: 'na',        get label(){return window.t("label.extra155");},    color: '#9aa1ab', soft: '#f4f5f7' },
};
const MAIL_ORDER = ['none', 'preparing', 'sent', 'na'];

/* 顧客の往来メールから返信状態を判定（最新メールの方向）。案件一覧バッジ／メール履歴で共用。
   in（先方から受信が最新）＝こちらが返信すべき＝要返信／out（こちらの送信が最新）＝相手の返信待ち。 */
function mailStatusOf(emails, customerId) {
  const ms = (emails || []).filter(e => e && e.customerId === customerId);
  if (!ms.length) return null;
  let latest = ms[0];
  for (const e of ms) { if ((e.date || '') > (latest.date || '')) latest = e; }
  return latest.direction === 'in'
    ? { key: 'in', label: t('ui.mailStatus.needsReply'), full: t('ui.mailStatus.needsReplyFull'), color: '#dc2626', bg: '#fdecec' }
    : { key: 'out', label: t('ui.mailStatus.waitingReply'), full: t('ui.mailStatus.waitingReplyFull'), color: '#b45309', bg: '#fdf0db' };
}

/* 発注者満足度（ReadyCrew 初回商談アンケート 1〜4）の表示メタ＋コンパクトバッジ。
   一覧・詳細・分析で共通利用。回答が無ければ null（何も出さない）。 */
const SAT_META = {
  1: { color: '#dc2626', bg: '#fdecec' },
  2: { color: '#d97706', bg: '#fdf0db' },
  3: { color: '#15803d', bg: '#e7f4ec' },
  4: { color: '#0f766e', bg: '#dcf3f0' },
};
function satMeta(v) {
  if (typeof v !== 'number' || !(v >= 1 && v <= 4)) return null;
  const k = Math.round(v);
  return { value: k, label: t('cd.sat.' + k), color: SAT_META[k].color, bg: SAT_META[k].bg };
}
function SatBadge({ value, size = 'sm' }) {
  const m = satMeta(value);
  if (!m) return null;
  const fs = size === 'xs' ? 10 : 11;
  return (
    <span title={`${t('cd.ordererReview')}：${m.label}`}
      style={{ flex: '0 0 auto', display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: fs, fontWeight: 700, color: m.color, background: m.bg, padding: '1px 7px', borderRadius: 999, whiteSpace: 'nowrap' }}>
      <span style={{ width: 5, height: 5, borderRadius: '50%', background: m.color, flex: '0 0 auto' }} />{m.label}
    </span>
  );
}

/* 担当窓口の最高役職を1つ抽出する。
   ReadyCrew取込で別保存した contactTitle があればそれを優先し、無ければ contactDept
   （＝「部署名 役職」を連結した自由記述）から辞書語の包含で最上位の役職を返す。
   ・複数役職が並んでも最上位だけ返す（要件「最大のみ」）
   ・先頭の「役職」ラベルや記号は除去
   ・辞書に無い語（出身地/性別 等スクレイプのゴミ）は拾わない＝誤検出しない
   ・上位ほど先に、複合形（専務取締役 等）を単独形（専務）より前に並べ、より正確な肩書を返す */
const TITLE_RANKS = [
  'CEO', 'COO', 'CTO', 'CFO', 'CIO', 'CMO', 'CXO',
  '会長', '代表取締役社長', '代表取締役', '代表執行役', '代表', '社長', '副社長',
  '専務取締役', '専務', '常務取締役', '常務', '執行役員',
  '取締役', '理事', '監査役',
  '本部長', '統括部長', '事業部長', '部長代理', '副部長', '部長', '次長',
  '室長', '担当課長', '課長代理', '課長', '係長', '主任',
  'マネージャー', 'マネジャー', 'チームリーダー', 'リーダー', '主幹', '主査', '担当',
];
function highestTitle(s) {
  if (!s || typeof s !== 'string') return '';
  const clean = s.replace(/^\s*役職[\s:：]*/, '').trim();
  if (!clean) return '';
  for (let i = 0; i < TITLE_RANKS.length; i++) { if (clean.indexOf(TITLE_RANKS[i]) >= 0) return TITLE_RANKS[i]; }
  return '';
}

/* 画面右下のフローティング・アクションボタン（FAB）。主要作成アクション用 */
function Fab({ icon = 'plus', label, onClick }) {
  return (
    <button onClick={onClick} title={label}
      style={{ position: 'fixed', right: 30, bottom: 30, zIndex: 60, display: 'inline-flex', alignItems: 'center', gap: 8, height: 54, padding: '0 24px', borderRadius: 999, border: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 14.5, fontWeight: 700, color: '#fff', background: '#4a5af0', boxShadow: '0 10px 28px rgba(91,87,216,.42)' }}
      onMouseEnter={(e) => { e.currentTarget.style.background = '#4b47c8'; }}
      onMouseLeave={(e) => { e.currentTarget.style.background = '#4a5af0'; }}>
      <Icon name={icon} size={20} stroke={2.6} />{label}
    </button>
  );
}

/* ---------------- AIレポート履歴（生成テキストの調閲・削除） ----------------
   分析AI総評・エントリ提案信など、生成したAIテキストはリロードで消えるため
   生成成功時に store.saveAiReport で DB へ保存する。本コンポーネントは type
   （任意で refId）で絞り込んだ履歴を、折りたたみ可能な小UIで一覧→調閲（Modal）
   →削除（確認つき）できるようにする。分析画面・アポ詳細など複数画面で再利用。 */
function AiReportHistory({ type, refId = null, title = t('ui.aiReport.historyTitle'), emptyHint, style = {} }) {
  const { aiReports, removeAiReport, showToast } = useStore();
  const [open, setOpen] = React.useState(false);
  const [view, setView] = React.useState(null);   // 調閲中のレポート
  const [busyId, setBusyId] = React.useState(null); // 削除中のレポートID
  const list = (aiReports || [])
    .filter(r => r.type === type && (refId == null || r.refId === refId))
    .sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));

  const stamp = (s) => (s || '').replace('T', ' ');
  const del = async (r) => {
    if (!window.confirm(t('ui.aiReport.confirmDelete', { title: r.title || t('ui.untitled'), timestamp: stamp(r.createdAt) }))) return;
    setBusyId(r.id);
    try { await removeAiReport(r.id); if (view && view.id === r.id) setView(null); }
    catch (e) { showToast(t('ui.aiReport.deleteFailed', { error: e.message || '' }), 'x'); }
    setBusyId(null);
  };
  const copy = (text) => { navigator.clipboard.writeText(text || '').then(() => showToast(t('ui.aiReport.copiedSuccess'))).catch(() => showToast(t('ui.aiReport.copyFailed'), 'x')); };

  const linkBtn = (label, onClick, opts = {}) => (
    <button onClick={onClick} disabled={opts.disabled} style={{ border: 'none', background: 'transparent', cursor: opts.disabled ? 'default' : 'pointer',
      fontFamily: 'inherit', fontSize: 12, fontWeight: 700, padding: '4px 8px', borderRadius: 7, flex: '0 0 auto',
      color: opts.disabled ? '#c4c9d0' : (opts.danger ? '#c0392b' : '#4a5af0') }}>{label}</button>
  );

  return (
    <div style={{ marginTop: 14, border: '1px solid #ecedf0', borderRadius: 12, overflow: 'hidden', background: '#fff', ...style }}>
      <button onClick={() => setOpen(o => !o)} style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
        padding: '12px 16px', border: 'none', background: open ? '#fafafb' : '#fff', cursor: 'pointer', fontFamily: 'inherit' }}>
        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, fontWeight: 700, color: '#454b56' }}>
          <Icon name="history" size={15} stroke={2} style={{ color: '#9aa1ab' }} />{title}
          <span style={{ minWidth: 18, height: 18, padding: '0 6px', borderRadius: 999, background: list.length ? '#eef0fe' : '#f0f1f4',
            color: list.length ? '#4a5af0' : '#9aa1ab', fontSize: 11.5, fontWeight: 800, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>{list.length}</span>
        </span>
        <Icon name={open ? 'chevronDown' : 'chevronRight'} size={16} stroke={2} style={{ color: '#9aa1ab' }} />
      </button>
      {open && (
        <div style={{ borderTop: '1px solid #f0f1f4', maxHeight: 360, overflowY: 'auto' }}>
          {list.length === 0
            ? <div style={{ padding: '14px 16px', fontSize: 12.5, color: '#9aa1ab' }}>{emptyHint || t('ui.aiReport.emptyHint')}</div>
            : list.map(r => (
              <div key={r.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px 10px 16px', borderTop: '1px solid #f5f6f8' }}>
                <div onClick={() => setView(r)} style={{ flex: 1, minWidth: 0, cursor: 'pointer' }}>
                  <div style={{ fontSize: 12.5, fontWeight: 700, color: '#2b2f38', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title || t('ui.untitled')}</div>
                  <div style={{ fontSize: 11.5, color: '#9aa1ab', marginTop: 2 }}>
                    {stamp(r.createdAt)}{r.createdByName ? '・' + r.createdByName : ''}
                  </div>
                </div>
                {linkBtn(t('ui.aiReport.inspect'), () => setView(r))}
                {linkBtn(busyId === r.id ? t('ui.aiReport.deleting') : t('btn.delete'), () => del(r), { danger: true, disabled: busyId === r.id })}
              </div>
            ))}
        </div>
      )}
      <Modal open={!!view} onClose={() => setView(null)} width={680}
        title={view ? (view.title || t('ui.aiReport.generatedReport')) : ''}
        subtitle={view ? stamp(view.createdAt) + (view.createdByName ? '・' + view.createdByName : '') : ''}
        footer={view ? (
          <>
            <Button variant="default" size="sm" onClick={() => copy(view.content)}>{t('btn.copyContent')}</Button>
            <Button variant="default" size="sm" onClick={() => setView(null)}>{t('btn.close')}</Button>
          </>
        ) : null}>
        {view && <div style={{ fontSize: 13.5, color: '#2b2f38', lineHeight: 1.85, whiteSpace: 'pre-wrap', maxHeight: '60vh', overflowY: 'auto' }}>{view.content}</div>}
      </Modal>
    </div>
  );
}

/* レスポンシブ：画面幅が breakpoint 未満（スマホ/タブレット）かを返すフック。resize に追従 */
function useIsMobile(bp = 860) {
  const [m, setM] = React.useState(() => (typeof window !== 'undefined' && window.innerWidth < bp));
  React.useEffect(() => {
    const on = () => setM(window.innerWidth < bp);
    on();
    window.addEventListener('resize', on);
    return () => window.removeEventListener('resize', on);
  }, [bp]);
  return m;
}

/* InfoRow / Toggle / SelectInput（共有プリミティブ。元 case-detail/settings/customers から移設） */
/* 取込用の長期トークン（userscript/ブックマークレットへ埋め込む）。cases の取込モーダルと
   integrations の Fireflies 録画ブックマークレットで共用のため L0 に配置（2026-08-02 cases.jsx から移設） */
function useImportToken() {
  const [token, setToken] = React.useState('');
  React.useEffect(() => {
    let alive = true;
    API.importToken()
      .then(r => { if (alive && r && r.token) setToken(r.token); })
      .catch(() => { try { if (alive) setToken(localStorage.getItem('anken_token') || ''); } catch (_) {} });
    return () => { alive = false; };
  }, []);
  return token;
}

function InfoRow({ icon, label, children }) {
  return (
    <div style={{ display: 'flex', gap: 11, alignItems: 'flex-start', padding: '10px 0' }}>
      <Icon name={icon} size={16} stroke={2} style={{ color: '#a8aeb8', flex: '0 0 auto', marginTop: 1 }} />
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 12, color: '#9aa1ab', fontWeight: 600, marginBottom: 3 }}>{label}</div>
        <div style={{ fontSize: 13, color: '#2b2f38' }}>{children}</div>
      </div>
    </div>
  );
}
function Toggle({ on, onClick }) {
  return (
    <button onClick={onClick} style={{ width: 42, height: 24, borderRadius: 999, border: 'none', cursor: 'pointer', padding: 2,
      background: on ? '#4a5af0' : '#d2d6dd', transition: 'background .15s', display: 'flex', justifyContent: on ? 'flex-end' : 'flex-start' }}>
      <span style={{ width: 20, height: 20, borderRadius: '50%', background: '#fff', boxShadow: '0 1px 2px rgba(0,0,0,.2)', transition: 'all .15s' }} />
    </button>
  );
}
function SelectInput({ value, onChange, options, placeholder }) {
  return (
    <select value={value} onChange={onChange}
      style={{ ...inputStyle, appearance: 'none', WebkitAppearance: 'none', cursor: 'pointer', color: value ? '#2b2f38' : '#9aa1ab',
        backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'12\' height=\'12\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'%23b4bac3\' stroke-width=\'2.4\' stroke-linecap=\'round\' stroke-linejoin=\'round\'%3E%3Cpath d=\'m6 9 6 6 6-6\'/%3E%3C/svg%3E")',
        backgroundRepeat: 'no-repeat', backgroundPosition: 'right 10px center' }}>
      <option value="">{placeholder || t('cust2.selectDefault')}</option>
      {options.map(o => <option key={o} value={o}>{o}</option>)}
    </select>
  );
}

/* 案件カテゴリのタグ入力：プリセット候補（APP_DATA.CATEGORIES）から選択＋自由入力（複数可）。editable=false は表示のみ */
function CategoryTags({ value, onChange, editable = false, size = 'md', options, onAiSuggest }) {
  const cats = value || [];
  // 候補＝options（呼び出し側で「マスタ候補∪既存案件で使用中」を渡す）。未指定ならマスタ候補のみ
  const presets = options || (window.APP_DATA.CATEGORIES) || [];
  const [open, setOpen] = React.useState(false);
  const [input, setInput] = React.useState('');
  const [aiBusy, setAiBusy] = React.useState(false);
  const ref = React.useRef(null);
  const aiSuggest = async () => { if (aiBusy || !onAiSuggest) return; setAiBusy(true); try { await onAiSuggest(); } catch (_) {} setAiBusy(false); };
  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 add = (c) => { const v = (c || '').trim(); if (v && !cats.includes(v)) onChange([...cats, v]); setInput(''); };
  const remove = (c) => onChange(cats.filter(x => x !== c));
  const sugg = presets.filter(p => !cats.includes(p) && (!input || p.toLowerCase().includes(input.toLowerCase())));
  const fs = size === 'sm' ? 11 : 12;
  const chip = { display: 'inline-flex', alignItems: 'center', gap: 5, background: '#eef0fe', color: '#5048c8', borderRadius: 999, padding: size === 'sm' ? '2px 8px' : '3px 10px', fontSize: fs, fontWeight: 600, whiteSpace: 'nowrap' };
  return (
    <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
      {cats.map(c => (
        <span key={c} style={chip}>{c}{editable && <span onClick={(e) => { e.stopPropagation(); remove(c); }} style={{ cursor: 'pointer', opacity: .55, fontSize: fs + 3, lineHeight: 1 }}>×</span>}</span>
      ))}
      {!editable && cats.length === 0 && <span style={{ color: '#c4c9d0', fontSize: fs }}>—</span>}
      {editable && (
        <div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
          <button type="button" onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}
            style={{ display: 'inline-flex', alignItems: 'center', gap: 4, border: '1px dashed #c9cff8', background: '#fff', color: '#4a5af0', borderRadius: 999, padding: '3px 10px', fontSize: fs, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
            <Icon name="plus" size={12} stroke={2.4} />{t('cat.add')}
          </button>
          {open && (
            <div onClick={(e) => e.stopPropagation()} style={{ position: 'absolute', top: '112%', left: 0, zIndex: 300, width: 224, background: '#fff', borderRadius: 10, border: '1px solid #e6e8ec', boxShadow: '0 14px 34px rgba(20,22,40,.16)', padding: 6 }}>
              <input value={input} autoFocus onChange={(e) => setInput(e.target.value)}
                onKeyDown={(e) => { if (enterSubmits(e)) { e.preventDefault(); add(input); } }}
                placeholder={t('cat.inputPlaceholder')}
                style={{ width: '100%', border: '1px solid #e2e5ea', borderRadius: 8, padding: '7px 9px', fontSize: 12.5, fontFamily: 'inherit', outline: 'none', marginBottom: 4, boxSizing: 'border-box' }} />
              <div style={{ maxHeight: 200, overflowY: 'auto' }}>
                {input.trim() && !presets.some(p => p === input.trim()) && !cats.includes(input.trim()) &&
                  <div className="row-hover" onClick={() => add(input)} style={{ padding: '7px 9px', borderRadius: 7, cursor: 'pointer', fontSize: 12.5, color: '#4a5af0', fontWeight: 600 }}>{t('cat.addNew', { v: input.trim() })}</div>}
                {sugg.map(s => (
                  <div key={s} className="row-hover" onClick={() => add(s)} style={{ padding: '7px 9px', borderRadius: 7, cursor: 'pointer', fontSize: 12.5, color: '#3b414b' }}>{s}</div>
                ))}
                {sugg.length === 0 && !input.trim() && <div style={{ padding: '7px 9px', fontSize: 12, color: '#9aa1ab' }}>{t('cat.allAdded')}</div>}
              </div>
            </div>
          )}
        </div>
      )}
      {editable && onAiSuggest && (
        <button type="button" onClick={aiSuggest} disabled={aiBusy} title={t('cat.aiJudgeHint')}
          style={{ display: 'inline-flex', alignItems: 'center', gap: 4, border: '1px dashed #d9caf5', background: aiBusy ? '#f6f2fe' : '#fff', color: '#7c3aed', borderRadius: 999, padding: '3px 10px', fontSize: fs, fontWeight: 600, cursor: aiBusy ? 'wait' : 'pointer', fontFamily: 'inherit' }}>
          <Icon name={aiBusy ? 'refresh' : 'spark'} size={12} stroke={2} fill={aiBusy ? 'none' : '#7c3aed'} />{aiBusy ? t('cat.aiJudging') : t('cat.aiJudge')}
        </button>
      )}
    </div>
  );
}

/* 添付ファイルがページ内プレビューできる種類か判定（pdf / image / null） */
function attPreviewKind(att) {
  const mime = ((att && att.mime) || '').toLowerCase();
  const name = ((att && att.name) || '').toLowerCase();
  if (mime === 'application/pdf' || name.endsWith('.pdf')) return 'pdf';
  if (mime.indexOf('image/') === 0 || /\.(png|jpe?g|gif|webp|svg|bmp)$/.test(name)) return 'image';
  return null;
}
/* 貼られたURLを枠内プレビューできる埋め込みURLに変換（Google Drive/Docs/Sheets/Slides のみ。不可なら null） */
function embedUrl(url) {
  const u = (url || '').trim();
  let m = u.match(/^(https?:\/\/(?:docs|drive)\.google\.com\/(?:document|spreadsheets|presentation|file)\/d\/[\w-]+)/);
  if (m) return m[1] + '/preview';
  m = u.match(/^https?:\/\/drive\.google\.com\/(?:open|uc)\?(?:[^#]*&)?id=([\w-]+)/);
  if (m) return 'https://drive.google.com/file/d/' + m[1] + '/preview';
  // Canva（デザインの共有URL）: .../design/<id>/<token>/(view|edit|watch) → .../view?embed
  m = u.match(/^(https?:\/\/(?:www\.)?canva\.com\/design\/[\w-]+(?:\/[\w-]+)?)\/(?:view|edit|watch)/);
  if (m) return m[1] + '/view?embed';
  // YouTube
  m = u.match(/^https?:\/\/(?:www\.|m\.)?youtube\.com\/(?:watch\?(?:[^#]*&)?v=|shorts\/|live\/|embed\/)([\w-]+)/);
  if (m) return 'https://www.youtube.com/embed/' + m[1];
  m = u.match(/^https?:\/\/youtu\.be\/([\w-]+)/);
  if (m) return 'https://www.youtube.com/embed/' + m[1];
  // Vimeo
  m = u.match(/^https?:\/\/(?:www\.)?vimeo\.com\/(\d+)/);
  if (m) return 'https://player.vimeo.com/video/' + m[1];
  // Loom
  m = u.match(/^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([\w-]+)/);
  if (m) return 'https://www.loom.com/embed/' + m[1];
  // Figma（公式の埋め込みエンドポイント。共有設定が前提）
  if (/^https?:\/\/(?:www\.)?figma\.com\/(?:file|design|proto|board|slides|deck)\//.test(u))
    return 'https://www.figma.com/embed?embed_host=share&url=' + encodeURIComponent(u);
  // Dropbox（直リンク化：raw=1。rlkey 等のパラメータは保持）
  if (/^https?:\/\/(?:www\.)?dropbox\.com\//.test(u))
    return /[?&](raw|dl)=/.test(u) ? u.replace(/([?&])(?:raw|dl)=\d/, '$1raw=1') : u + (u.indexOf('?') >= 0 ? '&' : '?') + 'raw=1';
  if (canvaShortCode(u)) return null; // canva.link 短縮はサーバーで展開（非同期）に任せる
  // 上記以外の一般 http(s) URL（自社プロトタイプ/デプロイ済みアプリ等）はそのまま埋め込み。http→https に格上げ（混在コンテンツ回避）。framing拒否のサイトはブラウザ側で表示されない場合あり
  if (/^https?:\/\/[^\s"'<>]+$/.test(u)) return u.replace(/^http:\/\//i, 'https://');
  return null;
}
/* Canva 短縮リンク（canva.link/<code>）の <code> を取り出す。サーバーで展開してプレビューする用 */
function canvaShortCode(url) {
  const m = (url || '').match(/^https?:\/\/canva\.link\/([\w-]+)/i);
  return m ? m[1] : null;
}
/* インラインのプレビュー枠。kind='image'→<img>、それ以外（pdf/embed）→<iframe> */
function PreviewFrame({ kind, src, height = 460 }) {
  if (!src) return null;
  return (
    <div style={{ marginTop: 8, marginBottom: 2, border: '1px solid #e7e9ee', borderRadius: 10, overflow: 'hidden', background: '#f6f7fa' }}>
      {kind === 'image'
        ? <div style={{ padding: 10, textAlign: 'center' }}>
            <img src={src} alt="preview" style={{ display: 'block', maxWidth: '100%', maxHeight: height, margin: '0 auto', borderRadius: 6 }} />
          </div>
        : <iframe src={src} title="preview" loading="lazy" style={{ display: 'block', width: '100%', height, border: 0, background: '#fff' }} />}
    </div>
  );
}

/* ページネーション用：現在ページを URL のクエリ(?page=N)に保持する共通フック。
   詳細画面へ遷移→ブラウザの戻るで一覧に戻っても、同じページ番号が復元される（1ページ目に戻らない）。
   resetDeps（絞り込み等）が変わったら1ページ目へ。ただし初回マウント＝URL由来のページ復元時は発火させない。 */
function useUrlPage(resetDeps) {
  const readPage = () => { try { const n = parseInt(new URLSearchParams(location.search).get('page'), 10); return n > 1 ? n : 1; } catch (_) { return 1; } };
  const [page, setPage] = React.useState(readPage);
  const first = React.useRef(true);
  React.useEffect(() => { if (first.current) { first.current = false; return; } setPage(1); }, resetDeps);
  return [page, setPage];
}
/* 表示中の有効ページ(safePage)を URL のクエリへ反映（履歴は増やさず replaceState、1ページ目はパラメータ除去）。 */
function syncUrlPage(safePage) {
  React.useEffect(() => {
    try {
      const sp = new URLSearchParams(location.search);
      if (safePage > 1) sp.set('page', String(safePage)); else sp.delete('page');
      const qs = sp.toString();
      const url = location.pathname + (qs ? '?' + qs : '');
      if (url !== location.pathname + location.search) history.replaceState(null, '', url);
    } catch (_) {}
  }, [safePage]);
}
/* CSVダウンロード（案件/顧客一覧のエクスポートで共用）：UTF-8 BOM付き＝Excelで文字化けしない。
   値はダブルクォート囲み・内部の"は""へ。rows は配列の配列（headers と同順）。 */
function csvDownload(filename, headers, rows) {
  const esc = (v) => '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"';
  const lines = [headers.map(esc).join(','), ...rows.map(r => r.map(esc).join(','))];
  const blob = new Blob(['﻿' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = filename;
  document.body.appendChild(a); a.click();
  setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 800);
}

/* 次回予定の手段（電話/オンライン/訪問）の実効値：カレンダー・タイムラインの「電話予定」表示の単一真実。
   ★電話/訪問は「明示された時だけ」：①商談記録の次回アクション文言（電話/訪問）②予定編集モーダルでの選択。
   自動推定はしない——ReadyCrewのURLだけでは電話か商談か区別できず誤爆した（2026-07-08、遠藤照明=電話 vs 他案件=商談が同条件）。
   Zoom/Meet等のURLがあれば online（表示上は従来の「商談予定」のまま＝無害な補助情報）。 */
function caseNextMethod(c, gcalMeetUrl) {
  if (c && c.nextMeetingMethod) return c.nextMeetingMethod;
  const urls = ((c && c.meetingLinks) || []).map(l => (l && l.url) || '').join(' ');
  if (gcalMeetUrl || /zoom\.us|meet\.google|teams\.microsoft|webex\.com/i.test(urls)) return 'online';
  return null;
}

/* 次回予定の単一真実：nextMeeting（次回商談）と appointAt(Live)（ReadyCrew商談日）が
   両方「未来」にあるとき、表示する正は一つだけ（手動編集済み meetingManual なら nextMeeting、
   未編集なら RC 側）。片方だけ・同一日時ならそのまま両方許可（各画面の既存フィルタに任せる）。
   タイムライン(case-detail)とカレンダー(buildEvents)で共用。
   2026-07-08 ヤギ社の二重表示（RC残存 7/8 ＋ 手動変更 7/9 が両方「これからの商談予定」）対策。 */
function casePlanPick(c) {
  const cut = Date.now() - 60 * 60000; // 開始1時間前までは「未来」扱い
  const fut = (d) => { const x = parseDT(d); return !!x && x.getTime() >= cut; };
  const ap = c && (c.appointAtLive || c.appointAt);
  const nmF = fut(c && c.nextMeeting), apF = fut(ap);
  if (c && c.nextMeeting && ap && c.nextMeeting !== ap) {
    // 手動で組んだ次回商談（未来）が正：食い違うRC商談日は「変更前の残骸」＝時刻が過ぎていても隠す
    if (nmF && c.meetingManual) return { nm: true, ap: false };
    // 未編集で両方未来：RC側（自動同期される方）が正＝古いミラーの nextMeeting を隠す
    if (nmF && apF) return { nm: false, ap: true };
  }
  return { nm: true, ap: true };
}

/* 会議URLの種別ラベル（Zoom/Google Meet/Teams/Webex/ReadyCrew/会議URL） */
function meetUrlLabel(u) {
  return /zoom\.us/.test(u || '') ? 'Zoom'
    : /meet\.google/.test(u || '') ? 'Google Meet'
    : /teams\.microsoft/.test(u || '') ? 'Teams'
    : /webex/.test(u || '') ? 'Webex'
    : /readycrew/.test(u || '') ? 'ReadyCrew'
    : t('cd.meetUrl.linkLabel');
}

/* 予定の参加リンクの単一真実：Google予定の会議URL（Meet/Zoom等）優先、無ければ案件の会議リンク
   （担当者用・参加者用の上位2件）。タイムライン(case-detail)と商談履歴タブ(case-meetings)で共用
   ＝同じ予定なのに画面によって違うリンクが出る混乱を防ぐ（2026-07-08）。
   Google予定側のURLが正＝その回の実際の入り口（1回目RC・2回目Zoomのように回ごとに変わる。2026-07-15）。 */
function casePlanLinkList(c, gcal) {
  // 手入力の「今回の会議URL」が最優先。gcal.meetUrl は予定の説明欄から抽出した値で、こちらが location を
  // 書き換えても更新されない（api.js の抽出は description を先に見る）＝古いURLのまま固定されるため（2026-08-12）
  const placeUrl = casePlaceUrl(c);
  if (placeUrl) return [{ label: t('ui.meetingLink.thisTime'), url: placeUrl }];
  // Google予定のURLが正＝その回の実際の入口。ReadyCrew は「担当者用(ホスト)」「参加者用」の2URLがあり gcal は片方しか持たないため、
  // gcalのURLが ReadyCrew のときは案件側のRCリンク（両方）を優先。Meet/Zoom/Teams等は gcal の1本が正。
  if (gcal && gcal.meetUrl && !/readycrew/i.test(gcal.meetUrl)) return [{ label: meetUrlLabel(gcal.meetUrl), url: gcal.meetUrl }];
  // 手動リスケ等で「次回商談」が元のRC商談日(appointAt/Live)と違う日になっているのに当日のカレンダー予定が無い場合、
  // RC取込の担当者用/参加者用は「元の回」のリンク＝この日には無効の恐れ。流用して断定表示せず除外し「リンクなし（要登録）」にする。
  // ただしユーザーが後から登録したリンク（RC取込ラベル以外）は残す＝正しいURLを登録すればそれが出る。
  const rcDate = c && (c.appointAtLive || c.appointAt);
  const staleReschedule = !!(c && c.nextMeeting && rcDate && c.nextMeeting !== rcDate && !gcal);
  let all = caseAllMeetingLinks(c, true);
  if (staleReschedule) all = all.filter(l => !/担当者用URL|参加者用URL/.test(l.label || ''));
  const caseLinks = all.slice(0, 2).map(l => ({ label: l.label || t('cd.meetingLink'), url: l.url }));
  // RC の gcal だが案件側にリンクが無い場合は gcal の RC URL を出す（リンク0本を防ぐ＝退化させない）
  if (caseLinks.length === 0 && gcal && gcal.meetUrl) return [{ label: meetUrlLabel(gcal.meetUrl), url: gcal.meetUrl }];
  return caseLinks;
}

/* カレンダー予定のURLと案件の登録リンクの「食い違い」検知（Zoom/Meet違い＝入れない事故の元）。
   同一サービスでも部屋が違えば食い違い扱い。ReadyCrew系は例外（RC案件はgcalに自動Meetが付きがちで誤検知するため）。
   c.meetUrlConflictOk に部屋キーが入っていれば「確認済み」としてスキップ（誤検知の逃げ道）。 */
function confRoomKey(u) {
  u = String(u || '');
  const z = u.match(/zoom\.us\/(?:[a-z]+\/)?j\/?(\d{8,12})/i); if (z) return 'zoom:' + z[1];
  const g = u.match(/meet\.google\.com\/([a-z]{3}-[a-z]{4}-[a-z]{3})/i); if (g) return 'meet:' + g[1];
  return u.toLowerCase().replace(/^https?:\/\//, '').replace(/[?#].*$/, '').replace(/\/$/, '');
}
function planUrlConflict(c, gcal) {
  if (!c || !gcal || !gcal.meetUrl || /readycrew/i.test(gcal.meetUrl)) return null;
  const first = caseAllMeetingLinks(c, true)[0];
  if (!first || !first.url || /readycrew/i.test(first.url)) return null;
  const gk = confRoomKey(gcal.meetUrl), ck = confRoomKey(first.url);
  if (gk === ck) return null;
  if (String(c.meetUrlConflictOk || '') === gk) return null;
  return { gcalUrl: gcal.meetUrl, gcalKind: meetUrlLabel(gcal.meetUrl), caseUrl: first.url, caseKind: meetUrlLabel(first.url), key: gk };
}

/* 「詳細未取込」判定：ReadyCrew案件で、未来の商談予定があるのに会議リンクが1つも無い。
   RCの会議URL（担当者用/参加者用）は一覧取込では入らず、詳細取込（完全取得＝RC詳細ページをuserscriptで開く）で保存される。
   →true の案件は詳細ページを取り込めばリンクが埋まる。バッジ表示＋取込導線に使う。 */
function caseNeedsRcDetailImport(c) {
  if (!c || c.source !== 'scrape') return false;
  if (!/\/matchings\//.test(String(c.sourceUrl || ''))) return false;
  if (caseAllMeetingLinks(c, true).length > 0) return false; // 既にリンクあり
  const mt = c.nextMeeting || c.appointAt;
  return !!(mt && mt.slice(0, 10) >= today() && !['won', 'lost', 'done'].includes(c.status));
}

/* 案件に一致したGoogle予定の重複除去：同じ「n回目」を名乗る未来の予定が複数あれば1つに絞る
   （リスケ後の消し忘れが典型。2026-07-15 ナンバ社：「ナンバ(2)」7/29 と「ナンバ・レディクル(2)」7/15 が併存）。
   優先＝レディクル/発注ナビ表記（プラットフォームの正式予定）＞Meetリンク付き＞早い日時。
   回数表記が無い予定・過去の予定はそのまま残す（実在の別予定を隠さない）。
   タイムライン(case-detail)と会議記録タブ(case-meetings)の両方で共用。 */
function gcalPlanDedupe(events) {
  const seqOf = (title) => {
    const s = (title || '').replace(/[０-９]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xFEE0));
    if (/初回/.test(s)) return 1;
    const kanji = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9 };
    const m = s.match(/(?:第)?([0-9一二三四五六七八九])\s*(?:回|次)/) || s.match(/[（(](\d{1,2})[）)]\s*$/);
    return m ? (kanji[m[1]] || parseInt(m[1], 10) || null) : null;
  };
  const score = (g) => (/(レディクル|発注ナビ)/.test(g.title || '') ? 4 : 0) + (g.meetUrl ? 2 : 0);
  const td = today();
  const out = []; const bySeq = {};
  (events || []).forEach(g => {
    const sq = ((g.datetime || '').slice(0, 10) >= td) ? seqOf(g.title) : null;
    if (!sq) { out.push(g); return; }
    const cur = bySeq['s' + sq];
    if (!cur || score(g) > score(cur) || (score(g) === score(cur) && (g.datetime || '') < (cur.datetime || ''))) bySeq['s' + sq] = g;
  });
  Object.keys(bySeq).forEach(k => out.push(bySeq[k]));
  // 同日ピックの既存ルール（Meet付き優先＝先勝ち/後勝ち差の吸収）を保つため meetUrl 先頭で返す
  return out.sort((a, b) => (b.meetUrl ? 1 : 0) - (a.meetUrl ? 1 : 0));
}

/* クリップボードへコピー。成否を返すだけ＝トースト表示は呼び出し側（showToast は store 由来のため）。
   同期の execCommand を先行：クリック直後ならユーザー操作の権限が生きていて確実に通る。
   navigator.clipboard は権限プロンプトで永久 pending になる環境があるため、後段＋タイムアウト付き。 */
async function copyText(text) {
  try {
    const ta = document.createElement('textarea');
    ta.value = text || ''; ta.style.cssText = 'position:fixed;opacity:0';
    document.body.appendChild(ta); ta.select();
    const ok = document.execCommand('copy'); ta.remove();
    if (ok) return true;
  } catch (e) {}
  try {
    await Promise.race([
      navigator.clipboard.writeText(text || ''),
      new Promise((_, rj) => setTimeout(() => rj(new Error('timeout')), 1500)),
    ]);
    return true;
  } catch (e) { return false; }
}

/* 次の一手（ToDo）の種別メタ：ラベル・色（見積/メール/提案書/日程/電話/資料/社内/その他）。
   商談モーダル・案件詳細・ダッシュボードToDo で共用。 */
function naTypeMeta(type) {
  const M = { quote: { c: '#15803d', b: '#e3f5e9' }, mail: { c: '#4a5af0', b: '#eef0fe' }, proposal: { c: '#7c3aed', b: '#f1e9fd' }, schedule: { c: '#0891b2', b: '#e0f5fa' }, call: { c: '#ea580c', b: '#fdeee2' }, doc: { c: '#b45309', b: '#fdf0db' }, internal: { c: '#5b6472', b: '#eef1f5' }, other: { c: '#7b828d', b: '#f0f1f4' } };
  const key = M[type] ? type : 'other';
  return { label: t('na.type.' + key), color: M[key].c, bg: M[key].b };
}

/* 見積書（quoteSheet）の合計金額（text行は除く）＝案件の金額の単一算出。分析・顧客等で共用（L0）。 */
function caseQuoteAmount(c) {
  return (((c && c.quoteSheet && c.quoteSheet.items) || []).reduce((s, it) => it.type === 'text' ? s : s + (Number(it.qty) || 0) * (Number(it.unitPrice) || 0), 0));
}
/* 受注金額（成約金額）：手入力の c.wonAmount があれば最優先、無ければ見積書合計で近似。
   見積額と実受注額は減額・一部発注で乖離するため、成約案件は案件詳細サイドバーで手入力できる。
   KPIレポート・顧客集計の受注金額はこれを使う（2026-07-29） */
function caseWonAmount(c) {
  const v = c && c.wonAmount;
  return (v != null && v !== '') ? (Number(v) || 0) : caseQuoteAmount(c);
}
/* 失注理由（チョークポイント・2026-07-30）：カテゴリ＋自由記述を c.lostReason / c.lostReasonNote に保存。
   入力導線は2つ＝①ステータスを失注へ変えた瞬間のモーダル（case-detail/cases 両方のメニューでインターセプト）
   ②失注案件サイドバーの「失注理由」行から後追い編集。失注分析AI（analytics）のペイロードにも載る */
const LOST_REASONS = [
  { key: 'price', get label(){return window.t("label.extra158");} },
  { key: 'competitor', get label(){return window.t("label.extra159");} },
  { key: 'timing', get label(){return window.t("label.extra160");} },
  { key: 'budget', get label(){return window.t("label.extra161");} },
  { key: 'fit', get label(){return window.t("label.extra162");} },
  { key: 'contact', get label(){return window.t("label.extra163");} },
  { key: 'inhouse', get label(){return window.t("label.extra164");} },
  { key: 'other', get label(){return window.t("na.type.other");} },
];
function lostReasonLabel(k) { const r = LOST_REASONS.find(x => x.key === k); return r ? r.label : ''; }
const WON_REASONS = [
  { key: 'price', get label(){return window.t("label.extra165");} },
  { key: 'proposal', get label(){return window.t("label.extra166");} },
  { key: 'tech', get label(){return window.t("label.extra167");} },
  { key: 'track', get label(){return window.t("label.extra168");} },
  { key: 'speed', get label(){return window.t("label.extra169");} },
  { key: 'trust', get label(){return window.t("label.extra170");} },
  { key: 'relation', get label(){return window.t("label.extra171");} },
  { key: 'other', get label(){return window.t("na.type.other");} },
];
function wonReasonLabel(k) { const r = WON_REASONS.find(x => x.key === k); return r ? r.label : ''; }
/* 受注/失注 共通の理由入力モーダル。applyStatus＝true なら既定ステータス（lost/won）、文字列ならそのステータスを適用。
   保存先：lost→c.lostReason/lostReasonNote、won→c.wonReason/wonReasonNote */
function CaseReasonModal({ kind, caseId, initialReason, initialNote, applyStatus, onClose }) {
  const cfg = kind === 'won'
    ? { get title(){return window.t("extra.case.winReason");}, reasons: WON_REASONS, field: 'wonReason', noteField: 'wonReasonNote', defStatus: 'won', on: '#e3f5e9', onBd: '#16a34a', onFg: '#15803d', get ph(){return window.t("label.extra172");}, get subApply(){return window.t("label.extra173");}, get subEdit(){return window.t("label.extra174");}, get skip(){return window.t("label.extra175");} }
    : { get title(){return window.t("extra.case.lossReason");}, reasons: LOST_REASONS, field: 'lostReason', noteField: 'lostReasonNote', defStatus: 'lost', on: '#fdecec', onBd: '#dc2626', onFg: '#b91c1c', get ph(){return window.t("label.extra176");}, get subApply(){return window.t("label.extra177");}, get subEdit(){return window.t("label.extra178");}, get skip(){return window.t("label.extra179");} };
  const { changeStatus, saveCase } = useStore();
  const [reason, setReason] = React.useState(initialReason || '');
  const [note, setNote] = React.useState(initialNote || '');
  const applyTo = applyStatus === true ? cfg.defStatus : applyStatus; // true=既定 / 文字列=そのstatus（例：done）
  const ghostBtn = { padding: '8px 14px', borderRadius: 8, border: '1px solid #e2e4e9', background: '#fff', color: '#6b7280', fontFamily: 'inherit', fontSize: 13, fontWeight: 600, cursor: 'pointer' };
  const save = () => {
    if (applyTo) changeStatus(caseId, applyTo);
    saveCase({ id: caseId, [cfg.field]: reason || null, [cfg.noteField]: note || '' });
    onClose();
  };
  return (
    <Modal open onClose={onClose} width={500} title={cfg.title}
      subtitle={applyTo ? cfg.subApply : cfg.subEdit}
      footer={(
        <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
          {applyTo && <button onClick={() => { changeStatus(caseId, applyTo); onClose(); }} style={ghostBtn}>{cfg.skip}</button>}
          <button onClick={onClose} style={ghostBtn}>{window.t("btn.cancel")}</button>
          <button onClick={save} style={{ padding: '8px 18px', borderRadius: 8, border: 'none', background: '#4a5af0', color: '#fff', fontFamily: 'inherit', fontSize: 13, fontWeight: 700, cursor: 'pointer' }}>{window.t("btn.save")}</button>
        </div>
      )}>
      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
        {cfg.reasons.map(r => (
          <button key={r.key} onClick={() => setReason(reason === r.key ? '' : r.key)}
            style={{ padding: '7px 13px', borderRadius: 999, border: '1px solid ' + (reason === r.key ? cfg.onBd : '#e2e4e9'), background: reason === r.key ? cfg.on : '#fff', color: reason === r.key ? cfg.onFg : '#4b5563', fontFamily: 'inherit', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>{r.label}</button>
        ))}
      </div>
      <textarea value={note} onChange={e => setNote(e.target.value)} rows={3}
        placeholder={cfg.ph}
        style={{ width: '100%', padding: '10px 12px', borderRadius: 10, border: '1px solid #e2e4e9', fontFamily: 'inherit', fontSize: 13, color: '#2b2f38', resize: 'vertical', boxSizing: 'border-box' }} />
    </Modal>
  );
}
function LostReasonModal(props) { return <CaseReasonModal kind="lost" {...props} />; }
function WonReasonModal(props) { return <CaseReasonModal kind="won" {...props} />; }
/* 失注/受注理由の大型カード（商談履歴タブ上部・2026-07-30）：カテゴリピルをその場でクリック→即保存、
   詳細テキストはフォーカスを外した時に保存。サイドバーの小さい行と同じデータ（c.lostReason 等）を編集する別ビュー */
function CaseReasonCard({ c, kind, canEdit }) {
  const { saveCase } = useStore();
  const cfg = kind === 'won'
    ? { get title(){return window.t("extra.case.winReason");}, reasons: WON_REASONS, field: 'wonReason', noteField: 'wonReasonNote', bd: '#cde8d6', bg: '#f4faf6', fg: '#15803d', on: '#e3f5e9', onBd: '#16a34a', icon: 'check2', get ph(){return window.t("label.extra172");}, get hint(){return window.t("label.extra180");} }
    : { get title(){return window.t("extra.case.lossReason");}, reasons: LOST_REASONS, field: 'lostReason', noteField: 'lostReasonNote', bd: '#f2d4d4', bg: '#fdf7f7', fg: '#b91c1c', on: '#fdecec', onBd: '#dc2626', icon: 'x', get ph(){return window.t("label.extra176");}, get hint(){return window.t("label.extra181");} };
  const cur = c[cfg.field] || '';
  const [note, setNote] = React.useState(c[cfg.noteField] || '');
  React.useEffect(() => { setNote(c[cfg.noteField] || ''); }, [c.id, c[cfg.noteField]]);
  const pick = (k) => { if (!canEdit) return; saveCase({ id: c.id, [cfg.field]: cur === k ? null : k }); };
  const saveNote = () => { if (canEdit && note !== (c[cfg.noteField] || '')) saveCase({ id: c.id, [cfg.noteField]: note }); };
  return (
    <div style={{ border: '1px solid ' + cfg.bd, background: cfg.bg, borderRadius: 12, padding: '16px 18px', marginBottom: 22 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
        <Icon name={cfg.icon} size={16} stroke={2.2} style={{ color: cfg.fg }} />
        <span style={{ fontSize: 14, fontWeight: 800, color: cfg.fg }}>{cfg.title}</span>
        <span style={{ fontSize: 11.5, color: '#9aa1ab' }}>{cfg.hint}{window.t("extra.assign.autoSave")}</span>
      </div>
      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
        {cfg.reasons.map(r => (
          <button key={r.key} onClick={() => pick(r.key)} disabled={!canEdit}
            style={{ padding: '9px 16px', borderRadius: 999, border: '1px solid ' + (cur === r.key ? cfg.onBd : '#e2e4e9'), background: cur === r.key ? cfg.on : '#fff', color: cur === r.key ? cfg.fg : '#4b5563', fontFamily: 'inherit', fontSize: 13.5, fontWeight: 700, cursor: canEdit ? 'pointer' : 'default' }}>{r.label}</button>
        ))}
      </div>
      <textarea value={note} onChange={e => setNote(e.target.value)} onBlur={saveNote} rows={2} disabled={!canEdit}
        placeholder={cfg.ph}
        style={{ width: '100%', padding: '10px 12px', borderRadius: 10, border: '1px solid #e2e4e9', background: '#fff', fontFamily: 'inherit', fontSize: 13, color: '#2b2f38', resize: 'vertical', boxSizing: 'border-box' }} />
    </div>
  );
}

/* ▼ 共用部品（分析/ダッシュボード/案件一覧で共用）を L0 に集約。2026-07-19 dashboard.jsx / cases.jsx から移設（SDD R2）。 */
/* 経由（取得元）バッジのメタ：scrape=ReadyCrew / hnavi=発注ナビ / gmail=Gmail / それ以外=手動 */
function caseViaMeta(c) {
  const s = (c && c.source) || '';
  if (s === 'scrape' || /ReadyCrew|レディクル/.test((c && c.sourceName) || '')) return { key: 'readycrew', order: 0, label: 'ReadyCrew', color: '#4a5af0', bg: '#eef0fe', icon: 'link' };
  if (s === 'hnavi' || /発注ナビ/.test((c && c.sourceName) || '')) return { key: 'hnavi', order: 1, label: '発注ナビ', color: '#c2410c', bg: '#fde8db', icon: 'link' };
  if (s === 'gmail' || /Gmail|メール/.test((c && c.sourceName) || '')) return { key: 'gmail', order: 2, label: 'Gmail', color: '#b91c1c', bg: '#fdecec', icon: 'mail' };
  if (s === 'fireflies') return { key:'fireflies', order:3, label:'Fireflies', color:'#7c3aed', bg:'#f3e8ff', icon:'video' };
  return { key: 'manual', order: 4, get label(){return window.t("ws.manual");}, color: '#7b828d', bg: '#f0f1f4', icon: 'edit' };
}

/* 案件リストのドリルダウン・モーダル（会社｜案件名＋経由チップ＋クリックで案件詳細へ）。
   月別テーブル・担当者パフォーマンス表など、数字クリックの内訳表示で共用（2026-07-16）。 */
function CaseListModal({ title, cases, records, onClose }) {
  const { navigate } = useStore();
  const D = window.APP_DATA;
  // records（[{c, day}]）が渡されたら「1商談＝1行」（同一案件でも商談日ごとに行を分ける
  // ＝セルの数字と行数が常に一致）。cases モードは従来どおり1案件1行（取得・受注・失注用）
  const rows = records
    ? records.slice().sort((a, b) => a.day.localeCompare(b.day)).map(r => ({ key: r.c.id + r.day, c: r.c, day: r.day }))
    : (cases || []).map(c => ({ key: c.id, c, day: null }));
  if (!records && !cases) return null;
  return (
    <Modal open onClose={onClose} title={title} width={560}>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: '60vh', overflowY: 'auto' }}>
        {rows.map(({ key, c, day }) => { const cu = D.customer(c.customerId); const v = caseViaMeta(c); return (
          <div key={key} className="row-hover" onClick={() => { onClose(); navigate('case', c.id); }}
            style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', border: '1px solid #f0f1f4', borderRadius: 9, cursor: 'pointer' }}>
            {/* 商談日（record モード）＝この行がどの商談かをひと目で */}
            {day && <span style={{ fontFamily: 'var(--mono)', fontSize: 12, fontWeight: 700, color: '#4a5af0', minWidth: 38, textAlign: 'center', flex: '0 0 auto' }}>{fmtDate(day)}</span>}
            <StatusBadge status={c.status} size="sm" />
            <div style={{ flex: 1, minWidth: 0, fontSize: 13, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
              <span style={{ fontWeight: 700, color: '#1f2430' }}>{cu ? (cu.shortName || cu.company) : '—'}</span>
              <span style={{ color: '#9aa1ab' }}> ｜ {c.title}</span>
            </div>
            {/* メイン担当（誰の商談かひと目で分かるように） */}
            {c.ownerId && D.user(c.ownerId) && <Avatar user={D.user(c.ownerId)} size={20} />}
            {/* 経由（取得元）：ReadyCrew / 発注ナビ / Gmail / 手動 */}
            <span title={c.sourceName || v.label} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 10.5, fontWeight: 700, color: v.color, background: v.bg, padding: '2px 8px', borderRadius: 999, whiteSpace: 'nowrap', flex: '0 0 auto' }}>
              <Icon name={v.icon} size={10} stroke={2.2} style={{ flex: '0 0 auto' }} />{v.label}
            </span>
            <Icon name="chevronRight" size={14} stroke={2} style={{ color: '#cbd0d7', flex: '0 0 auto' }} />
          </div>
        ); })}
        {rows.length === 0 && <div style={{ padding: '18px 0', textAlign: 'center', color: '#9aa1ab', fontSize: 13 }}>{t('an.drill.empty')}</div>}
      </div>
    </Modal>
  );
}

/* 実施済み商談を「何回目か」で分類したレコード [{c, day, stage:1|2|3}] を返す。
   月別テーブルと担当者別内訳（分析）で共用＝集計規則のズレを防ぐ。規則：
   ・非アーカイブ案件の実施済み商談＋（アーカイブでも）成約(=成約won/受注done)案件は成約日までの面談
   ・実施済み（今日以前）のみ／成約後の継続会議は商談ではない
   ※mergedInto ガードは防御的（store の cases は除外済みだが、window 公開関数なので生データにも耐える） */
function heldStageRecords(cases) {
  const out = [];
  (cases || []).forEach(c => {
    if (c.mergedInto) return;
    const won = isCaseWonStatus(c.status);
    if (c.archived && !won) return;
    const wonCut = won ? caseWonYmd(c) : null;
    const days = [...new Set(caseMeetingDates(c).map(d => (d || '').slice(0, 10)))].sort();
    days.forEach((day, i) => {
      if (day > today()) return;
      if (wonCut && day > wonCut) return;
      out.push({ c, day, stage: i + 1 }); // stage＝実際の何回目（1..N）。3以上へ丸めるかは消費側で（KPIは3/4/5+を区別・2026-07-30）
    });
  });
  return out;
}
// 内訳モーダル用：案件単位の重複排除（3回目以上は同一案件が商談日ごとに複数レコードになるため）
const uniqCases = (arr) => { const seen = new Set(); return arr.filter(c => !seen.has(c.id) && seen.add(c.id)); };

/* 月別 商談回数・受注（成約）/失注テーブル（ドリルダウン付き）＝ダッシュボードと分析画面で共用（2026-07-16）。
   引数 cases はアーカイブ込みの母集団（呼び出し側でスコープ絞り込み済み）。集計ロジックは1箇所に統一し、
   二画面でのズレを防ぐ。列＝案件数（取得月・1案件=1件）／初回・2回目・3回目以上の商談回数（実施済みを何回目かで分類）／
   受注(成約=won・成約日ベース)／失注(updatedAtの月)。各セルはクリックで内訳（会社・案件）を開き案件詳細へ遷移。 */
function MonthlyStatsTable({ cases, months = 6, wonLabel }) {
  const { navigate } = useStore();
  const D = window.APP_DATA; // 顧客名の解決（D.customer）に使用。この部品は独立関数なのでスコープに持たせる
  const [drill, setDrill] = React.useState(null); // {title, cases}
  const rows = React.useMemo(() => {
    const base = parseDT(today() + 'T00:00');
    const mos = [];
    for (let i = months - 1; i >= 0; i--) { const d = new Date(base.getFullYear(), base.getMonth() - i, 1); mos.push({ key: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`, get label(){return window.t("label.extra129",{v0:(d.getMonth() + 1)});} }); }
    const stat = {}; mos.forEach(mo => (stat[mo.key] = { acq: [], m1: [], m2: [], m3: [], won: [], lost: [] }));
    (cases || []).forEach(c => {
      if (c.mergedInto) return; // 防御的ガード（store の cases は除外済み）
      const acqK = (c.createdAt || '').slice(0, 7);
      if (stat[acqK]) stat[acqK].acq.push(c);
      const won = isCaseWonStatus(c.status);
      // 受注列＝成約(won)＋受注(done)を成約日の月に集計／失注は updatedAt の月
      if (won || c.status === 'lost') {
        const k = (won ? caseWonYmd(c) : String(c.updatedAt || '').slice(0, 10)).slice(0, 7);
        if (stat[k]) stat[k][won ? 'won' : 'lost'].push(c);
      }
    });
    // 商談回数（1回目/2回目/3回目以上）＝共用の heldStageRecords（担当者別内訳と同一規則）。
    // m1/m2/m3 は {c, day} レコードのまま持つ＝ドリルダウンで案件ごとの商談日を出せる
    heldStageRecords(cases).forEach(({ c, day, stage }) => {
      const k = day.slice(0, 7);
      if (stat[k]) stat[k][stage === 1 ? 'm1' : stage === 2 ? 'm2' : 'm3'].push({ c, day }); // この表は3回目以上をまとめる
    });
    return mos.map(mo => ({ label: mo.label, ...stat[mo.key] }));
  }, [cases, months]);
  const wonHead = wonLabel || t('dash2.table.won');
  return (
    <>
      <div style={{ overflowX: 'auto' }}>
        <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, minWidth: 560 }}>
          <thead>
            <tr style={{ color: '#9aa1ab', fontSize: 12, fontWeight: 600 }}>
              <th style={{ textAlign: 'left', padding: '8px 10px' }}>{t('dash2.table.month')}</th>
              <th style={{ textAlign: 'right', padding: '8px 10px', color: '#4a5af0' }}>{t('dash2.table.acquired')}</th>
              <th style={{ textAlign: 'right', padding: '8px 10px' }}>{t('dash2.table.firstMeeting')}</th>
              <th style={{ textAlign: 'right', padding: '8px 10px' }}>{t('dash2.table.secondMeeting')}</th>
              <th style={{ textAlign: 'right', padding: '8px 10px' }}>{t('dash2.table.thirdOrMore')}</th>
              <th style={{ textAlign: 'right', padding: '8px 10px', color: '#16a34a' }}>{wonHead}</th>
              <th style={{ textAlign: 'right', padding: '8px 10px', color: '#9aa1ab' }}>{t('dash2.table.lost')}</th>
            </tr>
          </thead>
          <tbody>
            {rows.map(r => {
              // セル＝件数＋クリックで案件リスト（会社・案件名）を開くドリルダウン
              const btn = (count, onClick) => (
                <button onClick={onClick}
                  style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, fontWeight: 'inherit', color: 'inherit', textDecoration: 'underline', textDecorationColor: '#d3d7de', textUnderlineOffset: 3, padding: 0 }}>{count}</button>
              );
              // 案件配列のセル（取得・受注・失注＝1案件1件）
              const cell = (arr, label, c, bold) => (
                <td style={{ padding: '9px 10px', textAlign: 'right', fontWeight: bold ? 700 : 400, color: arr.length ? c : '#c4c9d0' }}>
                  {arr.length ? btn(arr.length, () => setDrill({ title: `${r.label} ${label}（${arr.length}${t('unit.count')}）`, cases: uniqCases(arr) })) : '–'}
                </td>
              );
              // 商談レコードのセル（1回目/2回目/3回目以上＝商談回数）。モーダルは1商談=1行（日付つき）
              // ＝セルの数字と行数が常に一致（同一案件の複数商談は行が分かれる）
              const cellRecs = (recs, label) => (
                <td style={{ padding: '9px 10px', textAlign: 'right', color: recs.length ? '#2b2f38' : '#c4c9d0' }}>
                  {recs.length ? btn(recs.length, () => setDrill({ title: `${r.label} ${label}（${recs.length}${t('unit.count')}）`, records: recs })) : '–'}
                </td>
              );
              return (
                <tr key={r.label} style={{ borderTop: '1px solid #f0f1f4' }}>
                  <td style={{ padding: '9px 10px', fontWeight: 600, color: '#1f2430' }}>{r.label}</td>
                  {cell(r.acq, t('dash2.table.acquired'), '#4a5af0', true)}
                  {cellRecs(r.m1, t('dash2.table.firstMeeting'))}
                  {cellRecs(r.m2, t('dash2.table.secondMeeting'))}
                  {cellRecs(r.m3, t('dash2.table.thirdOrMore'))}
                  {cell(r.won, wonHead, '#16a34a', true)}
                  {cell(r.lost, t('dash2.table.lost'), '#6b7280', true)}
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
      {/* ドリルダウン：セルの内訳＝会社・案件リスト（共有部品・クリックで案件詳細へ） */}
      {drill && <CaseListModal title={drill.title} cases={drill.cases} records={drill.records} onClose={() => setDrill(null)} />}
    </>
  );
}

/* 商談回数の担当者別内訳（分析画面用）：1回目/2回目/3回目以上の商談を「誰が」何件行ったか。
   会議ごとの出席者は未記録のため、商談は案件のメイン担当（未割当含む）に帰属して集計（担当者パフォーマンス表と同じ前提）。
   集計規則は月別テーブルと同一（heldStageRecords 共用）。月セレクタで特定月に絞り込み、数字クリックで内訳を開く。 */
function StageOwnerTable({ cases, months = 12 }) {
  const D = window.APP_DATA;
  const [month, setMonth] = React.useState('all'); // 'all'（直近N ヶ月合計）| 'YYYY-MM'
  const [drill, setDrill] = React.useState(null);
  const base = parseDT(today() + 'T00:00');
  const monthOpts = [];
  for (let i = months - 1; i >= 0; i--) {
    const d = new Date(base.getFullYear(), base.getMonth() - i, 1);
    monthOpts.push({ key: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`, get label(){return window.t("label.extra182",{v0:(d.getFullYear()),v1:(d.getMonth() + 1)});} });
  }
  const winStart = monthOpts[0].key; // 表示ウィンドウ（直近N ヶ月）
  const rows = React.useMemo(() => {
    const recs = heldStageRecords(cases).filter(r => {
      const k = r.day.slice(0, 7);
      if (k < winStart) return false;
      return month === 'all' || k === month;
    });
    /* 役割別に集計：st＝メイン担当として（合計は重複なし＝商談総数と一致）、
       sub＝サポート担当として関与（1商談に複数サポートがいれば各自に計上）。
       サポートとしてだけ関与した人も行に出す */
    const byOwner = {};
    const ensure = (k) => byOwner[k] || (byOwner[k] = { st: { 1: [], 2: [], 3: [] }, sub: { 1: [], 2: [], 3: [] } });
    recs.forEach(({ c, day, stage }) => {
      const sg = Math.min(3, stage); // この表は 1/2/3+ の3分類
      ensure(c.ownerId || '_none').st[sg].push({ c, day }); // レコードのまま持つ＝ドリルダウンで商談日を出せる
      (c.subIds || []).forEach(uid => { if (uid && uid !== c.ownerId) ensure(uid).sub[sg].push({ c, day }); });
    });
    return Object.keys(byOwner).map(k => {
      const u = k === '_none' ? null : D.user(k);
      const s = byOwner[k];
      // 客数ベースの指標：期間内に商談したユニーク案件（メイン担当分）と、そのうち現時点で成約している件数
      // ＝「その期間に会った客のうち何件が成約したか」（成約日が期間外でも数える＝CVRの分子）。
      // subCases/subWonCases＝サポート担当として関与したユニーク案件とその成約（+n 表示用）
      const ownCases = uniqCases([...s.st[1], ...s.st[2], ...s.st[3]].map(r => r.c));
      const wonCases = ownCases.filter(c => isCaseWonStatus(c.status));
      const subCases = uniqCases([...s.sub[1], ...s.sub[2], ...s.sub[3]].map(r => r.c));
      const subWonCases = subCases.filter(c => isCaseWonStatus(c.status));
      return { id: k, user: u, color: u ? (u.color || '#4a5af0') : '#c4c9d0', st: s.st, sub: s.sub,
        total: s.st[1].length + s.st[2].length + s.st[3].length,
        subTotal: s.sub[1].length + s.sub[2].length + s.sub[3].length,
        ownCases, wonCases, subCases, subWonCases };
    }).sort((a, b) => (b.total - a.total) || (b.subTotal - a.subTotal));
  }, [cases, month, winStart]);
  // 表示名は memo の外で毎レンダー解決（言語切替 langV の再レンダーで「未アサイン」も追従させる）
  const nameOf = (r) => r.user ? (r.user.short || r.user.name) : t('an.unassigned');
  const grand = { 1: 0, 2: 0, 3: 0, total: 0, cases: 0, won: 0 };
  const grandSub = { 1: 0, 2: 0, 3: 0, total: 0, cases: 0, won: 0 };
  rows.forEach(r => {
    grand[1] += r.st[1].length; grand[2] += r.st[2].length; grand[3] += r.st[3].length; grand.total += r.total;
    grand.cases += r.ownCases.length; grand.won += r.wonCases.length; // 案件はメイン担当ごとに一意＝行の合算がユニーク総数
    grandSub[1] += r.sub[1].length; grandSub[2] += r.sub[2].length; grandSub[3] += r.sub[3].length; grandSub.total += r.subTotal;
    grandSub.cases += r.subCases.length; grandSub.won += r.subWonCases.length; // サポ関与は延べ（1案件に複数サポートで重複あり）
  });
  const cvr = (won, n) => n ? Math.round(won / n * 100) + '%' : '—';
  const avgOf = (st) => { const active = rows.filter(r => r.st[st].length > 0).length; return active ? (grand[st] / active).toFixed(1) : '—'; };
  const monthLb = month === 'all' ? `直近${months}ヶ月` : (monthOpts.find(m => m.key === month) || {}).label || month;
  const thS = { textAlign: 'right', fontSize: 11.5, color: '#9aa1ab', fontWeight: 700, padding: '0 10px 8px', whiteSpace: 'nowrap' };
  const tdS = { fontSize: 13, color: '#2b2f38', padding: '9px 10px', borderTop: '1px solid #f4f5f7', whiteSpace: 'nowrap', textAlign: 'right' };
  const subChip = { marginLeft: 5, border: 'none', background: '#eef0fe', color: '#4a5af0', borderRadius: 6, padding: '1px 6px', fontSize: 11, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit' };
  const cell = (mainRecs, subRecs, stageLb, opts = {}) => {
    // 数字＝メイン担当として／+n＝サポート担当として関与。モーダルは1商談=1行（日付つき）
    // ＝セルの数字と行数が常に一致（同一案件の複数商談は行が分かれる）
    const m = mainRecs || [];
    const sub = subRecs || [];
    const titleOf = (recs, roleLb) => `${monthLb} ${stageLb}${roleLb}（${recs.length}${t('unit.count')}）`;
    return (
      <td style={{ ...tdS, ...(opts.style || {}) }}>
        {m.length
          ? <button onClick={() => setDrill({ title: titleOf(m, ''), records: m })}
              style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, fontWeight: 'inherit', color: 'inherit', textDecoration: 'underline', textDecorationColor: '#d3d7de', textUnderlineOffset: 3, padding: 0 }}>{m.length}</button>
          : <span style={{ color: '#c4c9d0' }}>–</span>}
        {sub.length > 0 && (
          <button title={window.t("extra.meetings.support")} onClick={() => setDrill({ title: titleOf(sub, '・サポートとして'), records: sub })} style={subChip}>+{sub.length}</button>
        )}
      </td>
    );
  };
  // 案件（客数）ベースのセル：1案件=1行の cases モードでドリルダウン。
  // subArr（サポート担当として関与したユニーク案件）があれば +n チップを併記（商談回数セルと同じ流儀）
  const sepL = { borderLeft: '1px solid #eceef1' }; // 商談回数（延べ）と案件（客数）の単位の切れ目
  const caseCell = (arr, subArr, label, opts = {}) => (
    <td style={{ ...tdS, ...(opts.style || {}) }}>
      {arr.length
        ? <button onClick={() => setDrill({ title: `${monthLb} ${label}（${arr.length}${t('unit.count')}）`, cases: arr })}
            style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, fontWeight: 'inherit', color: 'inherit', textDecoration: 'underline', textDecorationColor: '#d3d7de', textUnderlineOffset: 3, padding: 0 }}>{arr.length}</button>
        : <span style={{ color: '#c4c9d0' }}>–</span>}
      {(subArr || []).length > 0 && (
        <button title={window.t("extra.kpi.support")} onClick={() => setDrill({ get title(){return window.t("label.extra183",{v0:(monthLb),v1:(label),v2:(subArr.length),v3:(t('unit.count'))});}, cases: subArr })} style={subChip}>+{subArr.length}</button>
      )}
    </td>
  );
  return (
    <>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
        <select value={month} onChange={(e) => setMonth(e.target.value)}
          style={{ border: '1px solid #e2e5ea', borderRadius: 8, padding: '6px 10px', fontSize: 12.5, fontFamily: 'inherit', color: '#3b414b', background: '#fff', outline: 'none' }}>
          <option value="all">{window.t("extra.meetings.recent")}{months}{window.t("extra.meetings.months")}</option>
          {monthOpts.slice().reverse().map(m => <option key={m.key} value={m.key}>{m.label}</option>)}
        </select>
        <span style={{ fontSize: 12, color: '#9aa1ab' }}>{window.t("extra.meetings.label")}{grand.total} {t('unit.count')}{window.t("extra.meetings.clientsPrefix")}{grand.cases}{window.t("extra.meetings.wonPrefix")}{grand.won}{window.t("extra.meetings.countOpen")}{cvr(grand.won, grand.cases)}{window.t("extra.meetings.involvedPrefix")}{rows.length}{window.t("extra.meetings.mainDefinition")}<span style={{ margin: '0 3px 0 6px', background: '#eef0fe', color: '#4a5af0', borderRadius: 6, padding: '1px 6px', fontSize: 11, fontWeight: 700 }}>+n</span>{window.t("extra.meetings.supportDefinition")}</span>
      </div>
      {rows.length === 0
        ? <div style={{ padding: '20px 4px', color: '#9aa1ab', fontSize: 13 }}>{window.t("extra.meetings.empty")}</div>
        : (
          <div style={{ overflowX: 'auto' }}>
            <table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 680 }}>
              <thead>
                <tr>
                  <th style={{ ...thS, textAlign: 'left' }}>{t('an.col.owner')}</th>
                  <th style={thS}>{t('dash2.table.firstMeeting')}</th>
                  <th style={thS}>{t('dash2.table.secondMeeting')}</th>
                  <th style={thS}>{t('dash2.table.thirdOrMore')}</th>
                  <th style={thS}>{window.t("cd.qs.total")}</th>
                  <th style={{ ...thS, ...sepL }}>{window.t("extra.meetings.clients")}</th>
                  <th style={thS}>{window.t("an.col.won")}</th>
                  <th style={thS}>{window.t("an.col.winRate")}</th>
                  <th style={thS}>{window.t("extra.meetings.share")}</th>
                </tr>
              </thead>
              <tbody>
                {rows.map(r => (
                  <tr key={r.id}>
                    <td style={{ ...tdS, textAlign: 'left', fontWeight: 600 }}>
                      <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
                        {r.user ? <Avatar user={r.user} size={20} /> : <span style={{ width: 9, height: 9, borderRadius: 3, background: r.color }} />}
                        {nameOf(r)}
                      </span>
                    </td>
                    {cell(r.st[1], r.sub[1], `${nameOf(r)}・${t('dash2.table.firstMeeting')}`)}
                    {cell(r.st[2], r.sub[2], `${nameOf(r)}・${t('dash2.table.secondMeeting')}`)}
                    {cell(r.st[3], r.sub[3], `${nameOf(r)}・${t('dash2.table.thirdOrMore')}`)}
                    {cell([...r.st[1], ...r.st[2], ...r.st[3]], [...r.sub[1], ...r.sub[2], ...r.sub[3]], `${nameOf(r)}・商談`, { style: { fontWeight: 700 } })}
                    {caseCell(r.ownCases, r.subCases, `${nameOf(r)}・商談した案件`, { style: { ...sepL, fontWeight: 700 } })}
                    {caseCell(r.wonCases, r.subWonCases, `${nameOf(r)}・成約`, { style: { color: r.wonCases.length ? '#15803d' : '#c4c9d0', fontWeight: 700 } })}
                    <td style={{ ...tdS, fontWeight: 700, color: r.wonCases.length ? '#15803d' : '#9aa1ab' }}>{cvr(r.wonCases.length, r.ownCases.length)}</td>
                    <td style={{ ...tdS, color: '#7b828d' }}>{r.total && grand.total ? Math.round(r.total / grand.total * 100) + '%' : '—'}</td>
                  </tr>
                ))}
              </tbody>
              <tfoot>
                <tr style={{ background: '#fafbfc' }}>
                  <td style={{ ...tdS, textAlign: 'left', fontWeight: 700, color: '#1f2430' }}>{window.t("cd.qs.total")}</td>
                  {[1, 2, 3, 'total'].map(k => (
                    <td key={k} style={{ ...tdS, fontWeight: 700 }}>
                      {grand[k] || '–'}
                      {grandSub[k] > 0 && <span style={{ marginLeft: 5, color: '#4a5af0', fontSize: 11, fontWeight: 700 }}>+{grandSub[k]}</span>}
                    </td>
                  ))}
                  <td style={{ ...tdS, ...sepL, fontWeight: 700 }}>
                    {grand.cases || '–'}
                    {grandSub.cases > 0 && <span style={{ marginLeft: 5, color: '#4a5af0', fontSize: 11, fontWeight: 700 }}>+{grandSub.cases}</span>}
                  </td>
                  <td style={{ ...tdS, fontWeight: 700, color: grand.won ? '#15803d' : '#c4c9d0' }}>
                    {grand.won || '–'}
                    {grandSub.won > 0 && <span style={{ marginLeft: 5, color: '#4a5af0', fontSize: 11, fontWeight: 700 }}>+{grandSub.won}</span>}
                  </td>
                  <td style={{ ...tdS, fontWeight: 700, color: grand.won ? '#15803d' : '#9aa1ab' }}>{cvr(grand.won, grand.cases)}</td>
                  <td style={tdS}>100%</td>
                </tr>
                <tr>
                  <td style={{ ...tdS, textAlign: 'left', color: '#9aa1ab', fontSize: 12 }}>{window.t("extra.meetings.average")}</td>
                  <td style={{ ...tdS, color: '#7b828d', fontSize: 12 }}>{avgOf(1)}</td>
                  <td style={{ ...tdS, color: '#7b828d', fontSize: 12 }}>{avgOf(2)}</td>
                  <td style={{ ...tdS, color: '#7b828d', fontSize: 12 }}>{avgOf(3)}</td>
                  <td style={{ ...tdS, color: '#7b828d', fontSize: 12 }}>{rows.length ? (grand.total / rows.length).toFixed(1) : '—'}</td>
                  <td style={{ ...tdS, ...sepL, color: '#7b828d', fontSize: 12 }}>{(() => { const a = rows.filter(r => r.ownCases.length > 0); return a.length ? (grand.cases / a.length).toFixed(1) : '—'; })()}</td>
                  <td style={tdS} colSpan={2} />
                  <td style={tdS} />
                </tr>
              </tfoot>
            </table>
          </div>
        )}
      <div style={{ fontSize: 11, color: '#aab0ba', marginTop: 10, lineHeight: 1.6 }}>{window.t("extra.meetings.mainHint")}<b style={{ color: '#4a5af0' }}>+n</b>{window.t("extra.meetings.supportHint")}<b>{window.t("extra.meetings.clients")}</b>{window.t("extra.meetings.uniqueHint")}<b style={{ color: '#4a5af0' }}>+n</b>{window.t("extra.meetings.supportClients")}<b>{window.t("an.col.won")}</b>{window.t("extra.meetings.wonHint")}<b style={{ color: '#4a5af0' }}>+n</b>{window.t("extra.meetings.supportWon")}<b>{window.t("extra.meetings.cvr")}</b>{window.t("extra.meetings.cvrHint")}</div>
      {drill && <CaseListModal title={drill.title} cases={drill.cases} records={drill.records} onClose={() => setDrill(null)} />}
    </>
  );
}

Object.assign(window, {
  caseViaMeta, CaseListModal, heldStageRecords, uniqCases, MonthlyStatsTable, StageOwnerTable, useImportToken,
  useUrlPage, syncUrlPage, naTypeMeta, csvDownload, caseNextMethod, casePlanPick, copyText, casePlanLinkList, gcalPlanDedupe, meetUrlLabel, planUrlConflict, confRoomKey, enterSubmits,
  Card, linkBtn, InfoRow, Toggle, SelectInput, CategoryTags, Icon, Avatar, AvatarGroup, StatusBadge, Button, IconButton, Modal, Field, TextInput, inputStyle, AssignPopover, Fab, mailStatusOf, satMeta, SatBadge, highestTitle, AiReportHistory, useIsMobile,
  attPreviewKind, embedUrl, canvaShortCode, PreviewFrame,
  WD, wd, wdAll, parseDT, fmtDate, fmtDateFull, fmtDateY, fmtTime, fmtDateTime, today, daysUntil, relTime,
  caseMeetingDates, caseMeetingAt, caseMeetingCount, caseDue, caseMailDue, caseQuoteAmount, caseWonAmount, LOST_REASONS, lostReasonLabel, LostReasonModal, WON_REASONS, wonReasonLabel, WonReasonModal, CaseReasonCard, PROPOSAL_STATUSES, PROPOSAL_ORDER,
  extractMeetingLinks, caseAllMeetingLinks, casePlaceUrl, caseNeedsRcDetailImport,
});
