/* ============================================================
   案件詳細 — 商談記録モーダル + 会議記録タブ。case-detail.jsx から分離。
   依存(InfoRow等の共用部品/ui/store)はグローバル＝render時解決。
   ============================================================ */

/* Fireflies の署名URL（CloudFront の ?Expires=）がまだ有効かを判定する。
   期限切れURLを <video> に渡すと CDN が 403 を返し onError が発火 → 再生不能として固定されてしまうため、
   渡す前にここで弾く。署名パラメータを持たないURL（手動アップロード等）は判定不能＝そのまま使う。 */
function signedUrlAlive(u) {
  if (!u) return false;
  try {
    const exp = Number(new URL(u).searchParams.get('Expires'));
    return !exp || exp * 1000 > Date.now();
  } catch (_) { return true; }
}

/* Fireflies の CDN は録画を application/octet-stream で返す。Chrome は中身を見て再生するが、
   Safari / iOS は Content-Type だけで判断して再生を拒む。拡張子から MIME を補って明示する。
   判定できない時は空文字＝指定せず、ブラウザの判断に任せる（誤った type を渡す方が有害）。 */
/* 再分析の実行。大きい録画（40MB超）はサーバーが {queued:true} を即返して裏で処理するので、
   完了するまで status を見に行く。1時間の商談は ffmpeg 抽出＋文字起こし＋分析で5分以上かかり、
   同期で待つとプロキシが300秒で502を返して「失敗」に見えてしまう（2026-08-13 実測）。
   戻り値は完了した doc、または null（時間内に終わらなかった＝処理は継続中）。 */
async function runReanalyze(caseId, docId, onProgress) {
  const r = await API.reanalyzeMeetingDoc(caseId, docId, true);
  if (!r || !r.queued) return (r && r.doc) ? r.doc : null;
  for (let i = 0; i < 60; i++) {                       // 15秒 × 60 ＝ 最長15分
    await new Promise(s => setTimeout(s, 15000));
    if (onProgress) onProgress(i);
    let st = null;
    try { st = await API.reanalyzeStatus(caseId, docId); } catch (_) { continue; } // 一時的な失敗は待って再試行
    if (st && st.done) return st.doc;
    if (st && !st.running && i > 2) return null;       // 走っていない＝落ちた（ログはサーバー側）
  }
  return null;
}

function mediaTypeOf(u) {
  const path = String(u || '').split('?')[0].toLowerCase();
  if (/\.(mp4|m4v)$/.test(path)) return 'video/mp4';
  if (/\.mov$/.test(path)) return 'video/quicktime';
  if (/\.webm$/.test(path)) return 'video/webm';
  if (/\.mp3$/.test(path)) return 'audio/mpeg';
  if (/\.m4a$/.test(path)) return 'audio/mp4';
  if (/\.wav$/.test(path)) return 'audio/wav';
  return '';
}
function MeetingModal({ caseId, onClose }) {
  const { addMeeting, addNextActions, currentUser, showToast, scheduleMeeting } = useStore();
  const D = window.APP_DATA;
  const [naSug, setNaSug] = React.useState(null); // AIの次の一手 提案配列 or null
  const [naSugLoading, setNaSugLoading] = React.useState(false);
  const [naSugPick, setNaSugPick] = React.useState({}); // idx -> 選択
  const [method, setMethod] = React.useState('visit');
  const [date, setDate] = React.useState(today());
  const [time, setTime] = React.useState('14:00');
  const [summary, setSummary] = React.useState('');
  const [feedback, setFeedback] = React.useState('');
  const [prob, setProb] = React.useState(''); // 成約確率（任意・0〜100）
  const [naDate, setNaDate] = React.useState(today());
  const [naTime, setNaTime] = React.useState('10:00'); // 旧実装は T10:00 固定＝実際の商談時刻とズレてカレンダー登録される元だった
  const [naText, setNaText] = React.useState('');
  // 次回商談（③）＝次の一手(ToDo)とは別に「次の“商談”」を明示設定。空なら予定を作らない＝非商談アクションが商談予定化する幽霊予定を防ぐ
  const [nextMtgDate, setNextMtgDate] = React.useState('');
  const [nextMtgTime, setNextMtgTime] = React.useState('10:00');
  const [nextMtgMethod, setNextMtgMethod] = React.useState('online');
  const [toCal, setToCal] = React.useState(true);
  const [ffOpen, setFfOpen] = React.useState(false);
  const [imported, setImported] = React.useState(null);
  const [autoMatched, setAutoMatched] = React.useState(false);
  const [suggestDismissed, setSuggestDismissed] = React.useState(false); // 一致通話サジェストを閉じたか
  const methods = Object.values(D.METHODS);
  const methodIcon = { visit: 'mapPin', phone: 'phone', online: 'video', email: 'mail' };
  const FF = '#ef5a3c';

  // この案件に一致する Fireflies 通話を自動判定（会社名がタイトルに含まれる／参加者メールのドメイン一致）
  const matchScore = (ff) => {
    const c = D.caseById(caseId); const cust = c && D.customer(c.customerId);
    if (!cust) return 0;
    const tokens = [cust.company, cust.shortName, (cust.company || '').replace(/株式会社|有限会社|合同会社|\(株\)/g, '').trim()].filter(t => t && t.length >= 2);
    const title = (ff.title || '');
    const attendees = (ff.attendees || []).join(' ');
    const domain = (cust.email || '').split('@')[1];
    let s = 0;
    if (tokens.some(t => title.includes(t))) s += 3;
    if (tokens.some(t => attendees.includes(t))) s += 2;
    if (domain && (ff.attendees || []).some(a => String(a).includes(domain))) s += 2;
    return s;
  };
  const bestMatch = React.useMemo(() => {
    const scored = (D.firefliesMeetings || []).map(ff => ({ ff, s: matchScore(ff) })).filter(x => x.s >= 3).sort((a, b) => b.s - a.s);
    return scored[0] ? scored[0].ff : null;
  }, [caseId]);

  const importFireflies = (ff, auto) => {
    const d = parseDT(ff.datetime);
    setMethod(ff.method || 'online');
    setDate(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
    setTime(fmtTime(ff.datetime));
    setSummary(ff.summary);
    setNaText((ff.actions && ff.actions[0]) || '');
    setImported(ff);
    setAutoMatched(!!auto);
    setFfOpen(false);
  };
  // ※以前は開いた瞬間に一致通話を自動取込していたが、「電話等を手入力したいだけ」なのに
  //   選んでもいないAI記録が勝手に付く（重複や意図しないsource:fireflies化の温床）ので廃止。
  //   一致通話は下の“サジェストバナー”／Fireflies欄から、ユーザーが任意で取り込む。
  // 今日から n 日後の YYYY-MM-DD（次の一手の期限目安）
  const dueFromDays = (n) => { const d = new Date(); d.setDate(d.getDate() + (Number(n) || 0)); const p = (x) => String(x).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; };
  // 商談メモ＋顧客の反応＋案件からAIが「次の一手」を提案（保存時に選択分をToDo化）
  const genNextActions = async () => {
    if (!summary.trim() && !feedback.trim()) { showToast(t('na.needSummary'), 'x'); return; }
    setNaSugLoading(true);
    try {
      const r = await API.nextActions(caseId, summary, feedback);
      const acts = r.actions || [];
      setNaSug(acts);
      const pick = {}; acts.forEach((_, i) => { pick[i] = true; }); setNaSugPick(pick);
    } catch (e) { showToast(t('na.genFailed', { msg: (e && e.message) || '' }), 'x'); }
    setNaSugLoading(false);
  };
  // 次回アクションの文言から予定の手段を推定（電話→ToDo / 訪問→visit / それ以外→online）。
  // 電話はカレンダー・商談日（次回商談）に入れない方針（2026-07-08）＝ToDo（次の一手）として積む。
  // 今回の商談自体が電話（method='phone'）なら、「連絡」等の曖昧な文言でも次も電話とみなす
  // （2026-07-09 ティ・ケイエンジ：電話記録の「2週間後に連絡」が商談予定＋URLで登録された誤り対策。
  //   オンライン/訪問の明示があればそちらを優先）。
  const naIsPhone = !!naText && (
    /電話|架電|TEL|ＴＥＬ|tel/i.test(naText) ||
    (method === 'phone' && !/訪問|来社|お伺い|往訪|オンライン|商談|ミーティング|MTG|zoom|meet|teams|web/i.test(naText))
  );
  const naMethod = /訪問|来社|お伺い|往訪/.test(naText) ? 'visit' : 'online';
  const submit = () => {
    /* 未来日時の「記録」は予定登録の誤用（空記録の残骸になる・2026-07-15 富士ピー・エスで発覚）。
       確認のうえ「商談予定」への登録に振り替える（記録は作らない。招待は案件詳細の招待ボタンから） */
    const p2 = (n) => String(n).padStart(2, '0');
    const nw = new Date();
    const nowS = `${nw.getFullYear()}-${p2(nw.getMonth() + 1)}-${p2(nw.getDate())}T${p2(nw.getHours())}:${p2(nw.getMinutes())}`;
    if (`${date}T${time}` > nowS) {
      if (!window.confirm(t('cd.futureMeetingConfirm', { when: `${fmtDate(date)} ${time}` }))) return;
      scheduleMeeting(caseId, `${date}T${time}`, method);
      onClose();
      return;
    }
    // 次回商談（③）：明示設定があれば最優先。無ければ従来どおり次の一手（非電話）から推定＝後方互換
    const explicitNext = nextMtgDate ? `${nextMtgDate}T${nextMtgTime || '10:00'}` : null;
    const nextMtg = explicitNext || (naText && !naIsPhone ? `${naDate}T${naTime || '10:00'}` : null);
    const nextMtgMeth = explicitNext ? nextMtgMethod : (naText && !naIsPhone ? naMethod : null);
    addMeeting(caseId, { datetime: `${date}T${time}`, method, authorId: currentUser.id, summary: summary || t('cd.summaryEmpty'),
      nextAction: naText ? `${fmtDate(naDate)} ${naText}` : '', nextMeeting: nextMtg, nextMeetingMethod: nextMtgMeth, calendar: toCal,
      customerFeedback: feedback.trim(), probability: prob !== '' ? Math.max(0, Math.min(100, Number(prob) || 0)) : null, source: imported ? 'fireflies' : 'manual', firefliesUrl: imported ? imported.url : null });
    // 電話の次回アクションは商談日ではなく ToDo（次の一手）へ
    if (naIsPhone) addNextActions(caseId, [{ text: naText, type: 'call', due: naDate }]);
    // AI提案から選んだ「次の一手」を案件のToDoに追加
    if (naSug && naSug.length) {
      const picked = naSug.filter((_, i) => naSugPick[i]).map(a => ({ text: a.head, detail: a.detail, type: a.type, due: dueFromDays(a.dueInDays) }));
      if (picked.length) addNextActions(caseId, picked);
    }
    onClose();
  };
  return (
    <Modal open onClose={onClose} title={t('btn.addMeeting')} subtitle={t('case-detail.meetingImportDesc')} width={580}
      footer={<><Button variant="subtle" onClick={onClose}>{t('btn.cancel')}</Button><Button variant="primary" icon="check" onClick={submit}>{t('cd.recordIt')}</Button></>}>

      {/* Fireflies.ai 連携 */}
      {!imported ? (
        <>
        {/* 一致する通話があれば“提案”するだけ。自動では埋めず、取り込むかはユーザーが選ぶ（手入力の邪魔をしない） */}
        {bestMatch && !suggestDismissed && (
          <div style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '11px 13px', marginBottom: 10, borderRadius: 11, border: '1px solid ' + FF + '40', background: FF + '0c' }}>
            <div style={{ width: 30, height: 30, borderRadius: 8, background: FF, display: 'flex', alignItems: 'center', justifyContent: 'center', flex: '0 0 auto' }}><Icon name="spark" size={15} fill="#fff" /></div>
            <div style={{ flex: 1, minWidth: 0 }}>
              <div style={{ fontSize: 12.5, fontWeight: 700, color: '#1c1f26' }}>{t('case-detail.matchedCallFound')}</div>
              <div style={{ fontSize: 12, color: '#7b828d', marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{bestMatch.title} · {fmtDate(bestMatch.datetime, true)} {fmtTime(bestMatch.datetime)}</div>
            </div>
            <button onClick={() => importFireflies(bestMatch, true)} style={{ border: 'none', background: FF, color: '#fff', borderRadius: 7, padding: '6px 13px', fontSize: 12.5, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit', flex: '0 0 auto' }}>{t('btn.import')}</button>
            <button onClick={() => setSuggestDismissed(true)} aria-label="close" style={{ border: 'none', background: 'transparent', color: '#9aa1ab', cursor: 'pointer', fontSize: 16, lineHeight: 1, fontFamily: 'inherit', flex: '0 0 auto', padding: '2px 4px' }}>×</button>
          </div>
        )}
        <div style={{ border: '1px solid ' + (ffOpen ? FF + '66' : '#eceef1'), borderRadius: 11, overflow: 'hidden', marginBottom: 18, transition: 'border .15s' }}>
          <button onClick={() => setFfOpen(o => !o)} style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '13px 15px', border: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
            background: 'linear-gradient(90deg,' + FF + '0e,' + FF + '04)' }}>
            <div style={{ width: 34, height: 34, borderRadius: 9, background: FF, display: 'flex', alignItems: 'center', justifyContent: 'center', flex: '0 0 auto', boxShadow: '0 2px 6px ' + FF + '55' }}>
              <Icon name="spark" size={18} fill="#fff" />
            </div>
            <div style={{ flex: 1, minWidth: 0 }}>
              <div style={{ fontSize: 13, fontWeight: 700, color: '#1c1f26', display: 'flex', alignItems: 'center', gap: 7 }}>{t('case-detail.importFromFireflies')}
                <span style={{ fontSize: 12, fontWeight: 700, color: FF, background: FF + '18', padding: '1px 6px', borderRadius: 4, letterSpacing: '.03em' }}>AI</span>
              </div>
              <div style={{ fontSize: 12, color: '#7b828d', marginTop: 2 }}>{t('case-detail.selectFirefliesDesc')}</div>
            </div>
            <Icon name="chevronDown" size={16} stroke={2} style={{ color: '#a8aeb8', transform: ffOpen ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }} />
          </button>
          {ffOpen && (
            <div style={{ borderTop: '1px solid #f0f1f4', padding: 8, display: 'flex', flexDirection: 'column', gap: 6, background: '#fcfcfd' }}>
              <div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 6px 6px', fontSize: 12, color: '#9aa1ab' }}>
                <span style={{ width: 7, height: 7, borderRadius: '50%', background: '#16a34a' }} />{t('case-detail.connectedRecentCalls')}
              </div>
              {D.firefliesMeetings.map(ff => (
                <div key={ff.id} className="row-hover" onClick={() => importFireflies(ff)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 11px', borderRadius: 9, cursor: 'pointer', border: '1px solid #f0f1f4', background: '#fff' }}>
                  <div style={{ width: 36, height: 36, borderRadius: 8, background: FF + '12', display: 'flex', alignItems: 'center', justifyContent: 'center', flex: '0 0 auto' }}>
                    <Icon name="play" size={14} fill={FF} />
                  </div>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontSize: 12.5, fontWeight: 600, color: '#1f2430', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{ff.title}</div>
                    <div style={{ fontSize: 12, color: '#9aa1ab', marginTop: 2 }}><span style={{ color: '#4a5af0' }}>{fmtDate(ff.datetime, true)} {fmtTime(ff.datetime)}</span> · {t('cd.minutesCount', { n: ff.duration })} · {t('cd.attendeesCount', { n: ff.attendees.length })}</div>
                  </div>
                  <span style={{ fontSize: 12, fontWeight: 600, color: FF, display: 'inline-flex', alignItems: 'center', gap: 3, flex: '0 0 auto' }}>{t('btn.import')}<Icon name="arrowRight" size={12} stroke={2.2} /></span>
                </div>
              ))}
            </div>
          )}
        </div>
        </>
      ) : (
        <div style={{ border: '1px solid ' + FF + '40', borderRadius: 11, overflow: 'hidden', marginBottom: 18 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '11px 14px', background: FF + '0c' }}>
            <div style={{ width: 30, height: 30, borderRadius: 8, background: FF, display: 'flex', alignItems: 'center', justifyContent: 'center', flex: '0 0 auto' }}><Icon name="spark" size={16} fill="#fff" /></div>
            <div style={{ flex: 1, minWidth: 0 }}>
              <div style={{ fontSize: 12.5, fontWeight: 700, color: '#1c1f26', display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>{imported.title}
                <span style={{ fontSize: 12, fontWeight: 700, color: FF, background: FF + '1c', padding: '1px 5px', borderRadius: 4 }}>Fireflies AI</span>
                {autoMatched && <span style={{ fontSize: 12, fontWeight: 700, color: '#16a34a', background: '#e3f5e9', padding: '1px 6px', borderRadius: 4 }}>{t('case-detail.autoDetected')}</span>}</div>
              <div style={{ fontSize: 12, color: '#7b828d', marginTop: 2 }}>{t('cd.minutesCount', { n: imported.duration })} · {t('cd.attendeesList', { names: (imported.attendees || []).join('、') })}</div>
            </div>
            <button onClick={() => { setImported(null); setAutoMatched(false); setSummary(''); setNaText(''); setMethod('visit'); setDate(today()); setTime('14:00'); }} style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: '#9aa1ab', fontSize: 12, fontWeight: 600, fontFamily: 'inherit', flex: '0 0 auto' }}>{t('btn.remove')}</button>
          </div>
          <div style={{ padding: '10px 14px', display: 'flex', gap: 6, flexWrap: 'wrap', borderTop: '1px solid ' + FF + '22' }}>
            {imported.keywords.map(k => <span key={k} style={{ fontSize: 12, color: '#5b626d', background: '#f4f5f7', padding: '3px 9px', borderRadius: 999 }}>#{k}</span>)}
          </div>
          <div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '9px 14px', borderTop: '1px solid ' + FF + '22', background: '#fff' }}>
            <Icon name="link" size={14} stroke={2} style={{ color: FF, flex: '0 0 auto' }} />
            <span style={{ fontSize: 12, fontWeight: 600, color: '#9aa1ab', flex: '0 0 auto' }}>Fireflies URL</span>
            <a href={imported.url} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()} style={{ flex: 1, minWidth: 0, fontSize: 12, fontFamily: 'var(--mono)', color: FF, textDecoration: 'none', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{imported.url}</a>
            <button onClick={() => { navigator.clipboard && navigator.clipboard.writeText(imported.url); showToast(t('case-detail.firefliesUrlCopied')); }} style={{ border: '1px solid #e2e5ea', background: '#fff', borderRadius: 6, padding: '4px 9px', fontSize: 12, fontWeight: 600, color: '#5b626d', cursor: 'pointer', fontFamily: 'inherit', flex: '0 0 auto' }}>{t('btn.copy')}</button>
          </div>
        </div>
      )}

      <Field label={t('case-detail.meetingMethod')} required>
        <div style={{ display: 'flex', gap: 8 }}>
          {methods.map(m => (
            <button key={m.key} onClick={() => setMethod(m.key)} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, padding: '11px 0', borderRadius: 9,
              border: '1px solid ' + (method === m.key ? '#4a5af0' : '#e2e5ea'), background: method === m.key ? '#eef0fe' : '#fff', cursor: 'pointer', fontFamily: 'inherit',
              color: method === m.key ? '#4a5af0' : '#5b626d', fontWeight: 600, fontSize: 12.5, transition: 'all .12s' }}>
              <Icon name={methodIcon[m.key] || 'video'} size={18} stroke={2} />{m.label}
            </button>
          ))}
        </div>
      </Field>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
        <Field label={t('case-detail.date')} required><TextInput type="date" value={date} onChange={(e) => setDate(e.target.value)} /></Field>
        <Field label={t('case-detail.time')} required><TextInput type="time" value={time} onChange={(e) => setTime(e.target.value)} /></Field>
      </div>
      <Field label={imported ? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>{t('case-detail.summary')}<span style={{ fontSize: 12, fontWeight: 700, color: '#ef5a3c', background: '#ef5a3c18', padding: '1px 6px', borderRadius: 4 }}>{t('case-detail.aiSummary')}</span></span> : t('case-detail.summary')}>
        <textarea value={summary} onChange={(e) => setSummary(e.target.value)} rows={3} placeholder={t('case-detail.summaryPlaceholder')}
          style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }} />
      </Field>
      <Field label={t('case-detail.salesNotes')}>
        <textarea value={feedback} onChange={(e) => setFeedback(e.target.value)} rows={2} placeholder={t('case-detail.feedbackPlaceholder')}
          style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.65, fontSize: 14 }} />
      </Field>
      <Field label={t('case-detail.winProbLabel')}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <input type="number" min="0" max="100" value={prob} onChange={(e) => setProb(e.target.value)} placeholder={window.t("extra.form.durationExample")} style={{ ...inputStyle, width: 120 }} />
          <span style={{ fontSize: 13, color: '#7b828d' }}>%</span>
        </div>
      </Field>
      <div style={{ height: 1, background: '#f0f1f4', margin: '4px 0 16px' }} />
      <div style={{ display: 'grid', gridTemplateColumns: '140px 100px 1fr', gap: 12 }}>
        <Field label={t('case-detail.nextActionDueDate')}><TextInput type="date" value={naDate} onChange={(e) => setNaDate(e.target.value)} /></Field>
        <Field label={t('case-detail.time')}><TextInput type="time" value={naTime} onChange={(e) => setNaTime(e.target.value)} /></Field>
        <Field label={t('case-detail.nextActionContent')}><TextInput value={naText} onChange={(e) => setNaText(e.target.value)} placeholder={t('case-detail.nextActionExample')} /></Field>
      </div>

      {/* 次の一手：AI提案（商談メモ＋反応→具体アクション。選択分を保存時にToDo化） */}
      <div style={{ margin: '2px 0 16px' }}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
          <span style={{ fontSize: 12, color: '#9aa1ab' }}>{t('na.subtitle')}</span>
          <Button variant="default" size="sm" icon="spark" onClick={genNextActions} disabled={naSugLoading}>{naSugLoading ? t('na.suggesting') : t('na.suggest')}</Button>
        </div>
        {naSug && (naSug.length ? (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 9, padding: 10, background: '#f7f7fd', borderRadius: 10, border: '1px solid #ecebfa' }}>
            {naSug.map((a, i) => {
              const meta = naTypeMeta(a.type);
              return (
                <label key={i} style={{ display: 'flex', gap: 9, alignItems: 'flex-start', cursor: 'pointer', padding: '7px 9px', borderRadius: 8, background: naSugPick[i] ? '#fff' : 'transparent', border: '1px solid ' + (naSugPick[i] ? '#e4e2f8' : 'transparent') }}>
                  <input type="checkbox" checked={!!naSugPick[i]} onChange={() => setNaSugPick(p => ({ ...p, [i]: !p[i] }))} style={{ accentColor: '#4a5af0', marginTop: 3, flex: '0 0 auto' }} />
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
                      <span style={{ fontSize: 10, fontWeight: 700, color: meta.color, background: meta.bg, padding: '1px 7px', borderRadius: 999, flex: '0 0 auto' }}>{meta.label}</span>
                      <span style={{ fontSize: 13, fontWeight: 600, color: '#1f2430' }}>{a.head}</span>
                    </div>
                    {a.detail && <div style={{ fontSize: 12, color: '#7b828d', marginTop: 3, lineHeight: 1.55 }}>{a.detail}</div>}
                  </div>
                  <span style={{ fontSize: 11, color: '#9aa1ab', whiteSpace: 'nowrap', flex: '0 0 auto', marginTop: 2 }}>{fmtDate(dueFromDays(a.dueInDays))}</span>
                </label>
              );
            })}
          </div>
        ) : <div style={{ fontSize: 12.5, color: '#9aa1ab', marginTop: 9, padding: '9px 11px', background: '#f6f7fa', borderRadius: 9 }}>{t('na.empty')}</div>)}
      </div>
      {/* 次回商談（③）：次の一手(ToDo)とは別に「次の商談」を明示。空なら予定を作らない＝非商談アクションの幽霊予定を防ぐ */}
      <div style={{ margin: '2px 0 16px' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
          <Icon name="calendar" size={14} stroke={2} style={{ color: '#4a5af0' }} />
          <span style={{ fontSize: 13, fontWeight: 700, color: '#3b414b' }}>{window.t("cd.nextMeeting")}</span>
          <span style={{ fontSize: 11.5, color: '#9aa1ab' }}>{window.t("extra.form.optionalMeeting")}</span>
          {nextMtgDate && <button onClick={() => setNextMtgDate('')} style={{ marginLeft: 'auto', border: 'none', background: 'transparent', color: '#9aa1ab', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', textDecoration: 'underline' }}>{window.t("apo.clear")}</button>}
        </div>
        <div style={{ display: 'grid', gridTemplateColumns: '140px 100px 1fr', gap: 12 }}>
          <TextInput type="date" value={nextMtgDate} onChange={(e) => setNextMtgDate(e.target.value)} />
          <TextInput type="time" value={nextMtgTime} onChange={(e) => setNextMtgTime(e.target.value)} />
          <select value={nextMtgMethod} onChange={(e) => setNextMtgMethod(e.target.value)} style={{ ...inputStyle }}>
            {methods.filter(m => m.key !== 'email').map(m => <option key={m.key} value={m.key}>{m.label}</option>)}
          </select>
        </div>
      </div>
      <label style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 13px', borderRadius: 9, border: '1px solid #e2e5ea', cursor: 'pointer', background: toCal ? '#f6f6fe' : '#fff' }}>
        <div onClick={() => setToCal(t => !t)} style={{ width: 20, height: 20, borderRadius: 6, border: '1.5px solid ' + (toCal ? '#4a5af0' : '#cfd4db'), background: toCal ? '#4a5af0' : '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', flex: '0 0 auto' }}>
          {toCal && <Icon name="check" size={13} stroke={3} style={{ color: '#fff' }} />}
        </div>
        <div style={{ flex: 1 }}>
          <div style={{ fontSize: 13, fontWeight: 600, color: '#2b2f38' }}>{t('case-detail.registerToCalendar')}</div>
          <div style={{ fontSize: 12, color: '#9aa1ab', marginTop: 1 }}>{t('case-detail.calendarAutoSync')}</div>
        </div>
        <Icon name="calendar" size={18} stroke={2} style={{ color: '#4a5af0' }} />
      </label>
    </Modal>
  );
}

function linkMeta(url) {
  if (/readycrew/.test(url)) return { icon: 'refresh', color: '#4a5af0', label: 'ReadyCrew' };
  if (/fireflies/.test(url)) return { icon: 'spark', color: '#ef5a3c', label: 'Fireflies' };
  if (/drive\.google|docs\.google/.test(url)) return { icon: 'link', color: '#16a34a', label: 'Google Drive' };
  if (/notion\./.test(url)) return { icon: 'grid', color: '#1c1f26', label: 'Notion' };
  if (/canva\./.test(url)) return { icon: 'grid', color: '#00c4cc', label: 'Canva' };
  if (/youtube\.com|youtu\.be/.test(url)) return { icon: 'play', color: '#ff0000', label: 'YouTube' };
  if (/figma\.com/.test(url)) return { icon: 'grid', color: '#a259ff', label: 'Figma' };
  if (/loom\.com/.test(url)) return { icon: 'video', color: '#625df5', label: 'Loom' };
  if (/vimeo\.com/.test(url)) return { icon: 'play', color: '#1ab7ea', label: 'Vimeo' };
  if (/dropbox\.com/.test(url)) return { icon: 'attach', color: '#0061ff', label: 'Dropbox' };
  if (/zoom|meet\.google|teams/.test(url)) return { icon: 'video', color: '#2563eb', label: t('cd.linkType.meeting') };
  return { icon: 'link', color: '#4a5af0', label: t('cd.linkType.link') };
}

/* Fireflies のリンクから通話IDを取り出す（…/view/Title::ID と …/view/ID の両形式に対応） */
function parseFirefliesId(input) {
  const s = (input || '').trim();
  if (!s) return null;
  const dc = decodeURIComponent(s);
  const m1 = dc.match(/::([A-Za-z0-9]+)/);
  if (m1) return m1[1];
  const m2 = dc.match(/fireflies\.ai\/view\/([A-Za-z0-9]+)/);
  if (m2) return m2[1];
  if (/^[A-Z0-9]{20,}$/i.test(dc)) return dc; // ID 直貼り
  return null;
}

/* AI標註タグの色（需要・痛点・予算・決裁者・次の一歩） */
const TAG_COLORS = { '需要': '#2563eb', '痛点': '#ef4444', '予算': '#16a34a', '決裁者': '#7c3aed', '次の一歩': '#d97706' };
const fmtSec = (sec) => {
  const s = Math.max(0, sec | 0);
  const p = (n) => String(n).padStart(2, '0');
  return s >= 3600 ? `${(s / 3600) | 0}:${p(((s % 3600) / 60) | 0)}:${p(s % 60)}` : `${p((s / 60) | 0)}:${p(s % 60)}`;
};

/* 会議記録の一覧行：「n回目商談」。クリックで詳細を開く。
   会議URL（Zoom/Meet）の一貫性：URLがあればアイコンで開ける・無ければ「未登録」を明示して登録を促す（2026-07-13）。
   fallbackMeetUrl＝過去に取り込んだ記録（meetUrl未保存）向けの表示時自動解決（同日Google予定のMeet＞案件の会議リンク）。
   これが効く限り「未登録」は出さない＝登録済みの情報があるのに促さない（2026-07-13 ナンバ社指摘） */
function MeetingDocRow({ doc, n, onOpen, onRemove, onSetMeetUrl, fallbackMeetUrl = null }) {
  const meetUrl = doc.meetUrl || fallbackMeetUrl;
  const askUrl = (e) => {
    e.stopPropagation();
    const u = window.prompt(t('cd.meetUrl.prompt'));
    if (u == null || !u.trim()) return;
    let v = u.trim(); if (!/^https?:\/\//.test(v)) v = 'https://' + v;
    onSetMeetUrl(doc.id, v);
  };
  return (
    <div className="row-hover" onClick={onOpen}
      style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '13px 16px', border: '1px solid #ecedf0', borderRadius: 12, background: '#fff', cursor: 'pointer' }}>
      <div style={{ width: 38, height: 38, borderRadius: 10, background: '#1d2129', display: 'flex', alignItems: 'center', justifyContent: 'center', flex: '0 0 auto' }}>
        <Icon name="play" size={16} fill="#fff" style={{ color: '#fff' }} />
      </div>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 14, fontWeight: 700, color: '#1f2430', display: 'flex', alignItems: 'center', gap: 8 }}>
          {t('case-detail.meetingCount', { n })}
          {doc.probability != null && (
            <span style={{ fontSize: 12, fontWeight: 700, color: doc.probability >= 60 ? '#16a34a' : doc.probability >= 35 ? '#d97706' : '#dc2626',
              background: (doc.probability >= 60 ? '#16a34a' : doc.probability >= 35 ? '#d97706' : '#dc2626') + '14', padding: '1px 8px', borderRadius: 999 }}>
              {t('cd.winProb', { prob: doc.probability })}
            </span>
          )}
          {doc.aiSummary && <span style={{ fontSize: 12, fontWeight: 700, color: '#10a37f', background: '#10a37f14', padding: '1px 7px', borderRadius: 4 }}>{t('case-detail.aiAnalyzed')}</span>}
        </div>
        <div style={{ fontSize: 12, color: '#9aa1ab', marginTop: 3, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
          <span style={{ color: '#4a5af0' }}>{fmtDateFull(doc.datetime)} {fmtTime(doc.datetime)}</span>{doc.duration ? ` · ${t('cd.minutesCount', { n: doc.duration })}` : ''} · {doc.title} · {doc.manual ? t('case-detail.manualUpload') : 'Fireflies'}
        </div>
      </div>
      {meetUrl
        ? <a href={meetUrl} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()} style={{ flex: '0 0 auto' }}><IconButton name="video" size={15} title={t('cd.meetUrl.open')} /></a>
        : <button onClick={askUrl} title={t('cd.meetUrl.prompt')}
            style={{ flex: '0 0 auto', fontSize: 11.5, fontWeight: 700, color: '#b45309', background: '#fdf0db', border: '1px dashed #ecc98f', borderRadius: 999, padding: '2px 9px', cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>
            {t('cd.meetUrl.missing')}
          </button>}
      <span onClick={(e) => e.stopPropagation()}><IconButton name="x" size={15} title={t('btn.delete')} onClick={onRemove} /></span>
      <Icon name="chevronRight" size={16} stroke={2} style={{ color: '#cbd0d7' }} />
    </div>
  );
}

/* 会議記録の詳細：動画 + AI標註つき逐字稿 + 成約確率 + AI摘要 + ACTION ITEMS */
function MeetingDocDetailModal({ caseData, docId, n, onClose }) {
  const { patchCase, showToast } = useStore();
  const isMobile = useIsMobile();
  const docs = caseData.meetingDocs || [];
  const doc = docs.find(d => d.id === docId);
  // Fireflies 録画（doc.ffId あり）の署名URLは失効するため、モーダルを開くたびに fresh を取り直す。
  // 手動アップロード（videoAttId）は attachments から再生＝表示時に解決するので取り直し不要。
  // ★fresh取得が失敗した場合（例：Fireflies APIの動画取得は有料プラン限定＝"paid plan"エラー）、保存済み
  //   doc.videoUrl はほぼ確実に期限切れ＝黒い再生不能プレーヤーになるため使わず、Firefliesで開く誘導へ落とす。
  const [freshMedia, setFreshMedia] = React.useState(null);
  const [freshErr, setFreshErr] = React.useState(null);
  // 初期値を true にしておく：effect は paint の後に走るので、false 始まりだと
  // 「取得を始める前の1フレーム」だけ再生不可の見た目がちらつく
  const [freshLoading, setFreshLoading] = React.useState(!!(doc && doc.ffId));
  const [videoDead, setVideoDead] = React.useState(false); // <video> が実際に読み込み失敗した時のフォールバック
  const [everLoaded, setEverLoaded] = React.useState(false); // 一度でも再生できたか（＝途中で切れたのか最初から不可なのか）
  React.useEffect(() => {
    let alive = true;
    setFreshMedia(null); setFreshErr(null); setVideoDead(false); setEverLoaded(false);
    if (doc && doc.ffId) {
      setFreshLoading(true);
      API.meetingDocVideo(caseData.id, doc.id, doc.ffId)
        .then(r => { if (alive && r && (r.videoUrl || r.audioUrl)) setFreshMedia(r); else if (alive) setFreshErr((r && r.blocked === 'paid') ? 'paid plan' : 'no-media'); })
        .catch(e => { if (alive) setFreshErr((e && e.message) || 'error'); })
        .then(() => { if (alive) setFreshLoading(false); });
    }
    return () => { alive = false; };
  }, [doc && doc.id]);
  // 新しい署名URLが届いたら「再生失敗」状態を解除する。
  // これが無いと、取り直しを待つ間に表示した期限切れURLが CDN から 403 を貰って videoDead=true になり、
  // その後で有効なURLが届いても videoSrc が null に固定されたまま＝永久に「期限切れ」表示になる（実バグ）。
  React.useEffect(() => { if (freshMedia) setVideoDead(false); }, [freshMedia]);
  const [copied, setCopied] = React.useState(false);
  React.useEffect(() => { if (!copied) return; const id = setTimeout(() => setCopied(false), 1800); return () => clearTimeout(id); }, [copied]);
  const [reBusy, setReBusy] = React.useState(false);
  if (!doc) return null;
  const attSrc = doc.videoAttId ? API.attachmentUrl(doc.videoAttId) : null;
  const staleUnusable = !!(doc.ffId && freshErr && !attSrc); // fresh不可＝保存URLは期限切れとみなす
  // 保存済みURLは署名の Expires を見て、期限切れなら最初から <video> に渡さない。
  // 渡すと CDN が 403 を返し onError が発火してしまう（2026-08-01 実測：保存済み 178 件中 153 件が期限切れ）。
  const savedSrc = signedUrlAlive(doc.videoUrl) ? doc.videoUrl : null;
  const videoSrc = videoDead ? null : ((freshMedia && (freshMedia.videoUrl || freshMedia.audioUrl)) || attSrc || (staleUnusable ? null : savedSrc) || null);
  // 取得中はまだ結論が出ていない＝「期限切れ」と断定してはいけない
  const mediaPending = freshLoading && !videoSrc;
  /* 再生できない理由をひとつに確定させる。ここを雑に「期限切れ」で括ると、
     手動アップロード録画（期限の概念が無い）やサーバー障害まで「期限切れ」と誤って案内してしまう。 */
  const failReason = (!videoSrc && !mediaPending) ? (
    everLoaded ? 'interrupted'                                 // 一度は再生できていた＝期限でも形式でもなく通信断
    : !doc.ffId ? (doc.videoAttId ? 'attach' : 'none')          // 手動アップロード：署名URLではないので期限は無関係
    : /paid plan/i.test(String(freshErr || '')) ? 'paid'
    : /上限|429|too_many/i.test(String(freshErr || '')) ? 'ratelimit'
    : String(freshErr) === 'no-media' ? 'nomedia'              // 通話は在るが録画そのものが無い
    : freshErr ? 'fetch'                                       // 502/ネットワーク等。URLの期限とは別物
    : videoDead ? 'expired'                                    // 新しいURLでも <video> が読めなかった
    : 'none'
  ) : null;
  const FAIL_MSG = {
    paid: 'アプリ内再生はFireflies APIの動画取得が有料プラン限定のため利用できません。クリックでFirefliesサイトで再生（ログインが必要）',
    ratelimit: 'Fireflies APIの一時的な上限のため録画を取得できませんでした。しばらく待つか、クリックでFirefliesサイトで再生（ログインが必要）',
    fetch: '録画URLを取得できませんでした（一時的な通信エラー）。時間をおいて開き直すか、クリックでFirefliesサイトで再生',
    expired: '録画URLの有効期限が切れています。クリックでFirefliesサイトで再生（ログインが必要）',
    attach: 'アップロードされた録画を読み込めませんでした（形式または通信の問題）。',
    interrupted: '再生中に通信が途切れました。開き直すと再開できます。',
    nomedia: 'この通話には録画が登録されていません。クリックでFirefliesサイトを開く',
  };
  const tagOf = {};
  (doc.annotations || []).forEach(a => { tagOf[a.i] = a.tag; });
  const prob = doc.probability;
  const probColor = prob == null ? '#cbd0d7' : prob >= 60 ? '#16a34a' : prob >= 35 ? '#d97706' : '#dc2626';
  const R = 52, C = 2 * Math.PI * R;
  const toggleAction = (idx) => {
    const next = docs.map(d => d.id === doc.id ? { ...d, actions: d.actions.map((a, j) => j === idx ? { ...a, done: !a.done } : a) } : d);
    patchCase(caseData.id, { meetingDocs: next });
  };
  // AIで再分析：音声を Whisper で再文字起こし → 一次分析 → 二次AIレビュー（ダブルチェック）
  const reanalyze = async () => {
    if (reBusy) return;
    if (!window.confirm(t('case-detail.reanalyzeConfirm'))) return;
    setReBusy(true);
    try {
      const newDoc = await runReanalyze(caseData.id, doc.id,
        (i) => { if (i === 0) showToast(window.t("label.extra24")); });
      if (!newDoc) { showToast(window.t("label.extra25"), 'x'); setReBusy(false); return; }
      const next = (caseData.meetingDocs || []).map(d => d.id === doc.id ? newDoc : d);
      patchCase(caseData.id, { meetingDocs: next });
      showToast(newDoc.transcriptSource === 'whisper'
        ? t('cd.toast.reanalyzedWhisper')
        : t('cd.toast.reanalyzedExisting'));
    } catch (e) { showToast(t('cd.toast.reanalyzeFailed', { msg: e.message }), 'x'); }
    setReBusy(false);
  };
  // 逐字稿の全文コピー（Fireflies の Copy Transcript 相当）。話者・時刻つきのプレーンテキストで、
  // 見出しに商談名と日時を付けて他所に貼っても文脈が分かるようにする。
  const copyTranscript = async () => {
    const body = (doc.sentences || []).map(s => `${fmtSec(s.t || 0)} ${s.sp || '—'}\n${s.tx}`).join('\n\n');
    if (!body) return;
    const head = [doc.title, doc.datetime && `${fmtDateFull(doc.datetime)} ${fmtTime(doc.datetime)}`].filter(Boolean).join(' · ');
    const ok = await copyText(head ? `${head}\n\n${body}` : body);
    if (ok) { setCopied(true); showToast(t('integ.copied')); }
    else showToast(t('case-detail.copyTranscriptFailed'), 'x');
  };
  const SideCard = ({ title, icon, color, children }) => (
    <div style={{ background: '#fff', border: '1px solid #ecedf0', borderRadius: 12, padding: '15px 17px' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 11 }}>
        <Icon name={icon} size={14} stroke={2.2} style={{ color }} />
        <span style={{ fontSize: 12, fontWeight: 800, color: '#3b414b', letterSpacing: '.05em' }}>{title}</span>
      </div>
      {children}
    </div>
  );
  return (
    <div onMouseDown={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(24,26,32,.5)', backdropFilter: 'blur(3px)', zIndex: 250,
      display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: '4vh 20px 30px', overflowY: 'auto' }}>
      <div onMouseDown={(e) => e.stopPropagation()} style={{ background: '#f6f7fa', borderRadius: 16, width: 1100, maxWidth: '100%',
        boxShadow: '0 28px 70px rgba(20,22,40,.35)', animation: 'modalIn .18s ease', overflow: 'hidden' }}>
        {/* ヘッダー */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 13, padding: '16px 22px', background: '#fff', borderBottom: '1px solid #ecedf0' }}>
          <div style={{ width: 40, height: 40, borderRadius: 11, background: '#1d2129', display: 'flex', alignItems: 'center', justifyContent: 'center', flex: '0 0 auto' }}>
            <Icon name="play" size={17} fill="#fff" style={{ color: '#fff' }} />
          </div>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontSize: 17, fontWeight: 800, color: '#1c1f26' }}>{t('case-detail.meetingCount', { n })}</div>
            {/* duration は手動アップロードでは null。行側（MeetingDocRow）と同じくガードする */}
            <div style={{ fontSize: 12, color: '#9aa1ab', marginTop: 2 }}><span style={{ color: '#4a5af0' }}>{fmtDateFull(doc.datetime)}</span>{doc.duration ? ` · ${t('cd.minutesCount', { n: doc.duration })}` : ''} · {doc.title}</div>
          </div>
          <Button variant="primary" size="sm" icon="refresh" onClick={reanalyze} disabled={reBusy}>{reBusy ? t('cd.reanalyzing') : t('btn.reanalyzeWithAI')}</Button>
          <a href={doc.url} target="_blank" rel="noreferrer" style={{ textDecoration: 'none' }}>
            <Button variant="default" size="sm" icon="spark">Fireflies</Button>
          </a>
          <IconButton name="x" size={18} onClick={onClose} />
        </div>
        {reBusy && (
          <div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '10px 22px', background: '#eef0ff', borderBottom: '1px solid #dfe2fb', color: '#3a49d8', fontSize: 12.5, fontWeight: 600 }}>
            <Icon name="spark" size={15} stroke={2.2} />
            {t('case-detail.reanalyzingInProgress')}
          </div>
        )}

        <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 340px', gap: 16, padding: 18, alignItems: 'start' }}>
          {/* 左：動画 + AI標註 + 逐字稿 */}
          <div style={{ minWidth: 0 }}>
            {videoSrc ? (
              <video key={videoSrc} controls style={{ width: '100%', borderRadius: 12, background: '#101218', maxHeight: 420 }}
                onLoadedData={() => setEverLoaded(true)}
                onError={(e) => {
                  // MEDIA_ERR_ABORTED(1) は src 差し替え等による中断で、再生できない事とは別物。
                  // これを失敗として扱うと、新しいURLに切り替えた瞬間に自分で自分を殺してしまう。
                  const code = e.currentTarget.error && e.currentTarget.error.code;
                  if (code === 1) return;
                  console.warn('[meeting-video] load error', { code, src: e.currentTarget.currentSrc });
                  setVideoDead(true);
                }}>
                {/* type は残す（Fireflies CDN は octet-stream で返すので、これが無いと Safari/iOS が
                    再生を拒む）。ただし <source> の失敗は video へ伝播せず親の onError が発火しない＝
                    「最初の1バイトも取れない」時に黒画面のまま理由も出せなかったので、
                    ここでも失敗を拾って videoDead に落とす。 */}
                <source src={videoSrc} type={mediaTypeOf(videoSrc) || undefined}
                  onError={() => { console.warn('[meeting-video] source load error', videoSrc); setVideoDead(true); }} />
              </video>
            ) : mediaPending ? (
              /* 取り直し中。ここで「期限切れ」を出すと、直後に有効なURLが届いても誤情報を見せたことになる */
              <div style={{ borderRadius: 12, background: '#101218', height: 200, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 10, padding: '0 24px' }}>
                <Icon name="refresh" size={26} stroke={2.2} style={{ color: '#fff', opacity: .55, animation: 'spin 1s linear infinite' }} />
                <span style={{ color: 'rgba(255,255,255,.6)', fontSize: 12.5 }}>{t('case-detail.loadingRecording')}</span>
              </div>
            ) : (() => {
              /* Fireflies へ誘導できるのは Fireflies 由来の録画だけ。手動アップロード録画に
                 Fireflies のリンクを被せると、行き先の無いリンクを押させることになる。 */
              const block = (
                <div style={{ borderRadius: 12, background: '#101218', height: 200, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 10, cursor: doc.url ? 'pointer' : 'default', padding: '0 24px' }}>
                  <Icon name="play" size={34} fill="#fff" style={{ color: '#fff', opacity: .9 }} />
                  {doc.url && <span style={{ color: 'rgba(255,255,255,.75)', fontSize: 12.5 }}>{t('case-detail.playInFireflies')}</span>}
                  {FAIL_MSG[failReason] && (
                    <span style={{ color: 'rgba(255,255,255,.45)', fontSize: 11, textAlign: 'center', lineHeight: 1.6 }}>
                      {FAIL_MSG[failReason]}
                    </span>
                  )}
                </div>
              );
              return doc.url
                ? <a href={doc.url} target="_blank" rel="noreferrer" style={{ textDecoration: 'none' }}>{block}</a>
                : block;
            })()}
            {/* AI標註 凡例 */}
            <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', margin: '12px 2px 10px' }}>
              <span style={{ fontSize: 12, fontWeight: 700, color: '#7b828d' }}>{t('case-detail.aiAnnotations')}</span>
              {Object.entries(TAG_COLORS).map(([tag, color]) => (
                <span key={tag} style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 12, color: '#4b515c' }}>
                  <span style={{ width: 11, height: 11, borderRadius: 3, background: color + '33', border: '1px solid ' + color + '88' }} />{tag}
                </span>
              ))}
              {(doc.sentences || []).length > 0 && (
                <button onClick={copyTranscript} title={t('case-detail.copyTranscriptTitle')}
                  style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 12, fontWeight: 700,
                    color: copied ? '#16a34a' : '#5b626d', background: '#fff', border: '1px solid ' + (copied ? '#bbf7d0' : '#e3e6ea'),
                    borderRadius: 8, padding: '5px 10px', cursor: 'pointer', fontFamily: 'inherit', transition: 'all .12s' }}>
                  <Icon name={copied ? 'check' : 'copy'} size={13} stroke={2.2} />
                  {copied ? t('integ.copied') : t('case-detail.copyTranscript')}
                </button>
              )}
            </div>
            {/* 逐字稿 */}
            <div style={{ background: '#fff', border: '1px solid #ecedf0', borderRadius: 12, maxHeight: 460, overflowY: 'auto' }}>
              {(doc.sentences || []).map((s, i) => {
                const tag = tagOf[i];
                const color = tag ? TAG_COLORS[tag] : null;
                return (
                  <div key={i} style={{ display: 'flex', gap: 12, padding: '9px 16px', borderBottom: '1px solid #f6f7f9' }}>
                    <span style={{ flex: '0 0 62px', fontFamily: 'var(--mono)', fontSize: 12, color: '#a8aeb8', paddingTop: 2 }}>{fmtSec(s.t || 0)}</span>
                    <div style={{ flex: 1, minWidth: 0 }}>
                      <span style={{ fontSize: 12.5, fontWeight: 700, color: '#2b2f38', marginRight: 8 }}>{s.sp || '—'}</span>
                      {tag && <span style={{ fontSize: 12, fontWeight: 700, color, background: color + '16', padding: '1px 7px', borderRadius: 4, marginRight: 6, verticalAlign: '1px' }}>{tag}</span>}
                      <div style={{ fontSize: 12.5, color: '#3b414b', lineHeight: 1.7, marginTop: 2 }}>
                        {color ? <span style={{ background: color + '1c', borderBottom: '2px solid ' + color + '66', borderRadius: 2, padding: '0 2px' }}>{s.tx}</span> : s.tx}
                      </div>
                    </div>
                  </div>
                );
              })}
              {(doc.sentences || []).length === 0 && <div style={{ padding: '30px 0', textAlign: 'center', color: '#b4bac3', fontSize: 12.5 }}>{t('case-detail.noTranscript')}</div>}
            </div>
          </div>

          {/* 右：AI成約確率・AI摘要・ACTION ITEMS */}
          <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
            <SideCard title={t('case-detail.aiWinProbability')} icon="spark" color="#2563eb">
              <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
                <svg width="120" height="120" viewBox="0 0 120 120" style={{ flex: '0 0 auto' }}>
                  <circle cx="60" cy="60" r={R} fill="none" stroke="#eef0f3" strokeWidth="10" />
                  {prob != null && (
                    <circle cx="60" cy="60" r={R} fill="none" stroke={probColor} strokeWidth="10" strokeLinecap="round"
                      strokeDasharray={`${C * prob / 100} ${C}`} transform="rotate(-90 60 60)" />
                  )}
                  <text x="60" y="60" textAnchor="middle" dominantBaseline="central" style={{ fontSize: 26, fontWeight: 800, fill: '#1c1f26', fontFamily: 'inherit' }}>
                    {prob != null ? prob + '%' : '—'}
                  </text>
                </svg>
                <div style={{ fontSize: 12, color: '#6b727c', lineHeight: 1.65 }}>
                  {doc.probabilityReason || t('case-detail.aiScoreFromTalk')}
                </div>
              </div>
            </SideCard>

            <SideCard title={t('case-detail.aiSummarySideCard')} icon="spark" color="#10a37f">
              {(doc.bullets || []).length > 0 ? (
                <div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
                  {doc.bullets.map((b, i) => (
                    <div key={i} style={{ display: 'flex', gap: 9, fontSize: 12.5, color: '#3b414b', lineHeight: 1.65 }}>
                      <span style={{ width: 5, height: 5, borderRadius: '50%', background: '#1c1f26', flex: '0 0 auto', marginTop: 8 }} />{b}
                    </div>
                  ))}
                </div>
              ) : (
                <div style={{ fontSize: 12.5, color: '#3b414b', lineHeight: 1.7, whiteSpace: 'pre-wrap', maxHeight: 240, overflowY: 'auto' }}>
                  {doc.aiSummary || doc.summary || t('case-detail.noSummary')}
                </div>
              )}
            </SideCard>

            <SideCard title={t('case-detail.actionItems')} icon="check" color="#16a34a">
              {(doc.actions || []).length > 0 ? (
                <div style={{ display: 'flex', flexDirection: 'column' }}>
                  {doc.actions.map((a, i) => (
                    <div key={i} onClick={() => toggleAction(i)}
                      style={{ display: 'flex', gap: 10, padding: '8px 0', borderBottom: i === doc.actions.length - 1 ? 'none' : '1px solid #f4f5f7', cursor: 'pointer' }}>
                      <span style={{ width: 16, height: 16, borderRadius: 4, flex: '0 0 auto', marginTop: 2,
                        border: a.done ? 'none' : '1.5px solid #cbd0d7', background: a.done ? '#1d2129' : '#fff',
                        display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
                        {a.done && <Icon name="check" size={11} stroke={3} style={{ color: '#fff' }} />}
                      </span>
                      <div style={{ flex: 1, minWidth: 0 }}>
                        <div style={{ fontSize: 12.5, color: a.done ? '#a8aeb8' : '#2b2f38', lineHeight: 1.55, textDecoration: a.done ? 'line-through' : 'none' }}>{a.text}</div>
                        {(a.owner || a.due) && <div style={{ fontSize: 12, color: '#9aa1ab', marginTop: 2 }}>{[a.owner, a.due].filter(Boolean).join(' · ')}</div>}
                      </div>
                    </div>
                  ))}
                </div>
              ) : (
                <div style={{ fontSize: 12.5, color: '#b4bac3' }}>{t('case-detail.noActions')}</div>
              )}
            </SideCard>
          </div>
        </div>
      </div>
    </div>
  );
}

/* 会議記録タブ — Fireflies のリンクから動画・逐字稿・AI文字要約を取り込み、案件に保存 */
function MeetingDocsTab({ caseData }) {
  const { showToast, patchCase, meetingsOf, addMeetingLink } = useStore();
  const D = window.APP_DATA;
  const docs = caseData.meetingDocs || [];
  // 「n回目商談」の採番：記録の有無に関わらず、この案件の実際の商談日をすべて並べて数える。
  // （例：1回目は録画なし・2回目だけ Fireflies がある場合、その記録は「2回目商談」と表示）
  // 日の情報源 = 会議記録 / ReadyCrew商談日 / 次回商談 / 商談記録 / Googleカレンダー（会社名一致）
  const dayOfDt = (dt) => (dt || '').slice(0, 10);
  const normCo = (s) => (s || '').replace(/株式会社|（株）|\(株\)|・(レディクル|発注ナビ).*$|[（(].*$|\s+/g, '');
  const custForCase = D.customer(caseData.customerId);
  const coreCo = custForCase ? normCo(custForCase.company) : '';
  const ordered = (() => {
    const byDay = {};
    const addDay = (dt, kind, payload) => {
      const day = dayOfDt(dt);
      if (!day) return;
      if (!byDay[day]) byDay[day] = { day, time: dt, kinds: [], docs: [] };
      byDay[day].kinds.push(kind);
      if (payload) Object.assign(byDay[day], payload);
      /* 同じ日に記録が複数あっても1件に潰さない。以前は Object.assign の後勝ちで doc が
         上書きされ、同日の古い記録が画面から消えていた（本番で20件が非表示だった）。 */
      if (payload && payload.doc) byDay[day].docs.push(payload.doc);
      if (dt && dt.length > 10) byDay[day].time = byDay[day].docs.length ? byDay[day].docs[0].datetime : dt;
    };
    // nextMeeting/appointAt が食い違う時は casePlanPick の「正」の側だけ数える（採番がRC残骸で膨らむのを防ぐ。
    // サーバー側のイベント題採番 meetingSeqSrv も同じ規則）
    const planPk = casePlanPick(caseData);
    if (caseData.appointAt && planPk.ap) addDay(caseData.appointAt, 'appoint');
    if (caseData.nextMeeting && planPk.nm) addDay(caseData.nextMeeting, 'next');
    /* 架電メモは「n回目商談」に数えない。1回しか面談していない案件が架電の数だけ
       「4回目商談」まで膨らみ、一覧の商談回数（ui.jsx の caseMeetingCount＝meetings を見ない）と
       食い違っていた（2026-08-13・全社36行/22案件）。メモ自体は商談記録タブとタイムラインに残る。
       サーバー側の採番 meetingSeqSrv も同じ規則にしてある（片方だけだとカレンダー件名とズレる）。 */
    meetingsOf(caseData.id).filter(m => m.method !== 'phone').forEach(m => addDay(m.datetime, 'meeting'));
    // gcalPlanDedupe＝同じ「n回目」を名乗る未来予定の重複除去（タイムライン側と同じ選択）。
    // addDay は Object.assign＝後勝ちなので、同日ピックは Meet 付きを後に並べ替えて整合させる
    gcalPlanDedupe((D.gcalEvents || []).filter(g => (coreCo.length >= 3 && normCo(g.title).includes(coreCo)) || (coreCo.length >= 2 && normCo(g.title) === coreCo)))
      .slice().sort((a, b) => (a.meetUrl ? 1 : 0) - (b.meetUrl ? 1 : 0))
      .forEach(g => addDay(g.datetime, 'gcal', { gcal: g }));
    docs.forEach(d => addDay(d.datetime, 'doc', { doc: d }));
    /* 並びも番号も「商談日の昇順」だけで決める。
       以前はタイトル明記の回数（doc.seq / 予定の件名の「2回目」）を優先して番号を割り当て、
       そのあと番号順に並べ替えていた。件名が実態とズレている案件（本番で4件・JPass 等）では
       日付順が壊れ、「上から2番目を開くと7月の録画が出る」状態になっていた。
       件名の自己申告より、記録に残っている日付の方が信頼できる。 */
    const daysAsc = Object.values(byDay).sort((a, b) => a.day.localeCompare(b.day));
    daysAsc.forEach((r, i) => { r.n = i + 1; });
    return daysAsc;
  })();
  const [openDocId, setOpenDocId] = React.useState(null);
  const [ffUrl, setFfUrl] = React.useState('');
  const [fetching, setFetching] = React.useState(false);
  // 商談記録に Fireflies リンクがあって、まだ取り込んでいない通話 → ワンクリック取り込み候補
  const suggestions = meetingsOf(caseData.id)
    .filter(m => m.source === 'fireflies' && m.url)
    .map(m => ({ m, fid: parseFirefliesId(m.url) }))
    .filter(({ fid }) => fid && !docs.some(d => d.ffId === fid));
  /* その商談日の会議URL（同日Google予定のMeet ＞ 案件の会議リンク先頭）＝取込時の自動補完。
     見つからない回は doc.meetUrl 無し＝行に「会議URL未登録」を明示して手入力を促す（2026-07-13 一貫性対応） */
  const meetUrlForDay = (dt) => {
    const day = (dt || '').slice(0, 10);
    const g = (D.gcalEvents || []).filter(g2 => (g2.datetime || '').slice(0, 10) === day && g2.meetUrl &&
      (coreCo.length >= 3 && normCo(g2.title).includes(coreCo)) || (coreCo.length >= 2 && normCo(g2.title) === coreCo))[0];
    if (g) return g.meetUrl;
    // 手入力の「今回の会議URL」は次回商談用なので、過去回の記録の補完には使わない
    // （meetingDocs へ書き込まれる値なので、混ぜると過去の記録が未来のURLを指す。2026-08-12）
    const ml = caseAllMeetingLinks(caseData, true).filter(l => !l.place)[0];
    return (ml && ml.url) || null;
  };
  const setDocMeetUrl = (docId, url) => {
    patchCase(caseData.id, { meetingDocs: docs.map(d => d.id === docId ? { ...d, meetUrl: url } : d) });
    showToast(t('cd.meetUrl.registered'));
  };
  /* Zoom発行（2026-08-21）：サーバのServer-to-Server OAuthでミーティングを作成し、
     今回の会議URL（meetingPlace）と会議リンクに保存。未設定環境ではボタン自体を出さない */
  const [zoomOn, setZoomOn] = React.useState(!!window.__ZOOM_ST);
  React.useEffect(() => {
    if (window.__ZOOM_ST != null) { setZoomOn(!!window.__ZOOM_ST); return; }
    API.zoomStatus().then(r => { window.__ZOOM_ST = !!(r && r.enabled); setZoomOn(window.__ZOOM_ST); }).catch(() => { window.__ZOOM_ST = false; });
  }, []);
  const [zoomBusy, setZoomBusy] = React.useState(false);
  const issueZoom = async (when) => {
    if (zoomBusy) return;
    if (!window.confirm(t('cd.zoom.confirm'))) return;
    setZoomBusy(true);
    try {
      const r = await API.zoomIssue(caseData.id, when || undefined);
      patchCase(caseData.id, { meetingPlace: r.url, meetingLinks: r.meetingLinks || caseData.meetingLinks });
      await copyText(r.url).catch(() => {});
      showToast(t('cd.zoom.issued'));
    } catch (e) { showToast((e && e.message) || 'Zoom発行に失敗しました', 'x'); }
    setZoomBusy(false);
  };
  const importUrl = async (url) => {
    const fid = parseFirefliesId(url);
    if (!fid) { showToast(t('case-detail.invalidFirefliesLink'), 'x'); return; }
    if (docs.some(d => d.ffId === fid)) { showToast(t('case-detail.meetingDocAlreadyAdded'), 'x'); return; }
    setFetching(true);
    try {
      const r = await API.aiMeetingDoc(fid);
      if (!r.doc.meetUrl) r.doc.meetUrl = meetUrlForDay(r.doc.datetime); // 会議URLの自動補完
      patchCase(caseData.id, { meetingDocs: [r.doc, ...docs] });
      setFfUrl('');
      showToast(r.doc.aiSummary ? t('case-detail.importedWithSummary') : t('case-detail.importedMeetingDoc'));
    } catch (e) { showToast(e.message, 'x'); }
    setFetching(false);
  };
  const addDoc = () => importUrl(ffUrl);

  /* 録画（音声・動画）の手動アップロード → その商談日の会議記録として保存 */
  const [uploadingMedia, setUploadingMedia] = React.useState(false);
  const mediaRef = React.useRef(null);
  const mediaTargetTime = React.useRef(null); // どの商談日に紐づけるか（null = 今日）
  const pickMedia = (time) => { mediaTargetTime.current = time || null; if (mediaRef.current) mediaRef.current.click(); };
  const onMediaFile = async (fileList) => {
    const f = fileList && fileList[0];
    if (!f || uploadingMedia) return;
    /* 文字起こしテキスト（.txt/.vtt/.srt）＝逐字稿として取込（2026-08-28・ユーザー要望）。
       Whisper転写より正確なZoom/Teamsの文字起こしをそのまま使える。同じ日の記録があれば
       逐字稿を差し替えて再分析、無ければ新しい会議記録を作る */
    const isTranscript = /\.(vtt|srt)$/i.test(f.name) || ((f.type === 'text/plain' || /\.(txt|text)$/i.test(f.name)) && f.size < 2 * 1024 * 1024);
    if (isTranscript) {
      setUploadingMedia(true);
      try {
        const text = await f.text();
        const dt = mediaTargetTime.current || null;
        const day = (dt || '').slice(0, 10);
        const target = day ? docs.find(d => (d.datetime || '').slice(0, 10) === day) : null;
        if (target && !window.confirm(t('cd.textDoc.replaceConfirm', { title: target.title || '' }))) { setUploadingMedia(false); if (mediaRef.current) mediaRef.current.value = ''; return; }
        showToast(t('cd.textDoc.started'));
        const r = await API.meetingDocText({ caseId: caseData.id, docId: target ? target.id : undefined, datetime: dt || undefined, title: f.name.replace(/\.(txt|vtt|srt|text)$/i, ''), text });
        patchCase(caseData.id, { meetingDocs: target ? docs.map(d => d.id === r.doc.id ? r.doc : d) : [...docs, r.doc] });
        showToast(t(r.replaced ? 'cd.textDoc.replaced' : 'cd.textDoc.created'));
      } catch (e) { showToast((e && e.message) || '取込に失敗しました', 'x'); }
      setUploadingMedia(false);
      if (mediaRef.current) mediaRef.current.value = '';
      return;
    }
    // 動画・音声以外（PDF・Word・CSV等）は録画ではないので「その他」資料として案件に添付保存（文字起こしは対象外）
    const isAV = /^(audio|video)\//.test(f.type || '') || /\.(mp[34]|m4a|wav|mov|webm|aac|ogg|mkv)$/i.test(f.name);
    if (!isAV) {
      /* 資料（PDF・Word・議事録テキスト等）も600MBまで（2026-08-12拡大・ユーザー要望）。
         base64のJSON経由（15MB上限）をやめ、録画と同じrawアップロード（Volume保存）に統一 */
      if (f.size > 600 * 1024 * 1024) { showToast(t('case-detail.fileSizeLimit80MB'), 'x'); return; }
      setUploadingMedia(true);
      try {
        const r = await API.uploadMedia(f, caseData.id, 'その他');
        patchCase(caseData.id, { attachments: [r.att, ...(caseData.attachments || [])] });
        showToast(t('case-detail.fileAttachedSavedInTab', { name: f.name }));
      } catch (e) { showToast(e.message, 'x'); }
      setUploadingMedia(false);
      if (mediaRef.current) mediaRef.current.value = '';
      return;
    }
    // 600MBまで（長時間録画をカバー・2026-07-29拡大。サーバ側 /api/media-upload も同値＝railway反映要）
    if (f.size > 600 * 1024 * 1024) { showToast(t('case-detail.fileSizeLimit80MB'), 'x'); return; }
    setUploadingMedia(true);
    try {
      const r = await API.uploadMedia(f, caseData.id);
      const now = new Date();
      const p = (n) => String(n).padStart(2, '0');
      const dt = mediaTargetTime.current || `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}T${p(now.getHours())}:${p(now.getMinutes())}`;
      const doc = { id: 'md' + Date.now(), manual: true, ffId: null, seq: null, title: f.name, datetime: dt,
        duration: null, url: null, videoUrl: null, videoAttId: r.att.id, summary: '', keywords: [], aiSummary: '', bullets: [],
        probability: null, probabilityReason: '', annotations: [], actions: [], sentences: [], meetUrl: meetUrlForDay(dt) };
      patchCase(caseData.id, { meetingDocs: [...docs, doc] });
      showToast(t('case-detail.mediaUploaded', { name: f.name }));
      /* 音声も動画も自動で文字起こし→AI分析（議事録化）。
         動画はサーバー側の ffmpeg が音声だけを抜き出すので、以前のように
         「mp3 に書き出して再アップロード」を求める必要は無くなった（2026-08-13）。
         25MB超のmp3/ogg/wavはサーバ側で分割転写（2026-07-29）。 */
      showToast(window.t("label.extra26"));
      runReanalyze(caseData.id, doc.id)
        .then(rr => rr
          ? (patchCase(caseData.id, { meetingDocs: [...docs, rr] }), showToast('議事録化が完了しました：' + f.name))
          : showToast(window.t("label.extra27"), 'x'))
        .catch(er => showToast('文字起こしに失敗：' + ((er && er.message) || er), 'x'));
    } catch (e) { showToast(e.message, 'x'); }
    setUploadingMedia(false);
    if (mediaRef.current) mediaRef.current.value = '';
  };
  const removeDoc = (docId) => {
    if (!window.confirm(t('common.confirmDelete'))) return;
    patchCase(caseData.id, { meetingDocs: docs.filter(d => d.id !== docId) });
    showToast(t('case-detail.meetingDocDeleted'), 'x');
  };

  /* この案件の通話と思われる未取込の Fireflies 録画（顧客名がタイトルに含まれる／参加者メールのドメイン一致）。
     商談記録を書いていない RC 商談でも録画を取りこぼさないための導線（2026-07-09 創建 3件宙浮きで発覚）。
     判定は商談記録モーダルの matchScore と同基準（トークン2文字以上）。取込は既存 importUrl＝重複ガード込み。 */
  const ffTitleMatches = React.useMemo(() => {
    const cust = D.customer(caseData.customerId);
    if (!cust) return [];
    const tokens = [cust.company, cust.shortName, (cust.company || '').replace(/株式会社|有限会社|合同会社|\(株\)|（株）/g, '').trim()].filter(s => s && s.length >= 2);
    const domain = (cust.email || '').split('@')[1];
    return (D.firefliesMeetings || [])
      .filter(f => !docs.some(d => d.ffId === f.id))
      .filter(f => tokens.some(tk => (f.title || '').includes(tk)) || (domain && (f.attendees || []).some(a => String(a).includes(domain))))
      .sort((a, b) => (b.datetime || '').localeCompare(a.datetime || ''))
      .slice(0, 6);
  }, [caseData.customerId, docs.length]);
  return (
    <div style={{ padding: 22 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
        <Icon name="video" size={15} stroke={2} style={{ color: '#7b828d', flex: '0 0 auto' }} />
        <span style={{ fontSize: 12.5, fontWeight: 700, color: '#1c1f26', whiteSpace: 'nowrap', flex: '0 0 auto' }}>{t('case-detail.addMeetingDoc')}</span>
        <span style={{ fontSize: 12, color: '#a8aeb8', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t('case-detail.pasteFirefliesUrl')}</span>
      </div>
      <div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
        <TextInput value={ffUrl} onChange={(e) => setFfUrl(e.target.value)} onKeyDown={(e) => { if (enterSubmits(e)) addDoc(); }}
          placeholder={t('case-detail.firefliesUrlPlaceholder')} style={{ flex: 1 }} list="ff-meetings" />
        <Button variant="primary" icon={fetching ? 'refresh' : 'plus'} onClick={addDoc} disabled={fetching || !ffUrl.trim()}>
          {fetching ? t('cd.fetching') : t('btn.addMeetingDoc')}
        </Button>
        <Button variant="default" icon="download" onClick={() => pickMedia(null)} disabled={uploadingMedia}>
          {uploadingMedia ? t('cd.uploading') : t('btn.uploadMediaOrCSV')}
        </Button>
      </div>
      {/* 受け付けは全形式（2026-07-13 拡大）：動画/音声→録画として保存、それ以外（PDF・Word・議事録テキスト等）→資料として添付 */}
      <input ref={mediaRef} type="file" style={{ display: 'none' }} onChange={(e) => onMediaFile(e.target.files)} />
      <datalist id="ff-meetings">
        {D.firefliesMeetings.map(f => <option key={f.id} value={f.url}>{t('cd.firefliesOption', { title: f.title, date: fmtDate(f.datetime) })}</option>)}
      </datalist>
      {fetching && (
        <div style={{ display: 'flex', alignItems: 'center', gap: 9, marginTop: 10, padding: '10px 13px', background: '#f8faf9', border: '1px solid #e8f0ec', borderRadius: 9, fontSize: 12.5, color: '#10a37f', fontWeight: 600 }}>
          <Icon name="spark" size={15} stroke={2} style={{ color: '#10a37f' }} />
          {t('case-detail.generatingTranscript')}
        </div>
      )}
      {/* 商談記録由来の未取り込み通話 → ワンクリックで取り込み */}
      {suggestions.length > 0 && !fetching && (
        <div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 7 }}>
          {suggestions.map(({ m, fid }) => (
            <div key={fid} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 13px', background: '#fbf6f4', border: '1px solid #f5e2da', borderRadius: 9 }}>
              <Icon name="spark" size={15} stroke={2} fill="#ef5a3c" style={{ color: '#ef5a3c', flex: '0 0 auto' }} />
              <div style={{ flex: 1, minWidth: 0, fontSize: 12.5, color: '#3b414b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
                {t('case-detail.notImportedYet', { date: fmtDate(m.datetime) })}
              </div>
              <Button size="sm" variant="primary" icon="download" onClick={() => importUrl(m.url)}>{t('btn.import')}</Button>
            </div>
          ))}
        </div>
      )}
      {/* タイトル・参加者一致の未取込 Fireflies 録画（商談記録が無くても拾う） */}
      {ffTitleMatches.length > 0 && !fetching && (
        <div style={{ marginTop: 10 }}>
          <div style={{ fontSize: 12, fontWeight: 700, color: '#7b828d', marginBottom: 6 }}>{t('cd.ffMatch.title', { n: ffTitleMatches.length })}</div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
            {ffTitleMatches.map(f => (
              <div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 13px', background: '#fbf6f4', border: '1px solid #f5e2da', borderRadius: 9 }}>
                <Icon name="spark" size={15} stroke={2} fill="#ef5a3c" style={{ color: '#ef5a3c', flex: '0 0 auto' }} />
                <div style={{ flex: 1, minWidth: 0, fontSize: 12.5, color: '#3b414b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
                  {f.title}<span style={{ color: '#9aa1ab' }}>（{fmtDate(f.datetime)} {fmtTime(f.datetime)}）</span>
                </div>
                <Button size="sm" variant="primary" icon="download" onClick={() => importUrl(f.url)} disabled={fetching}>{t('btn.import')}</Button>
              </div>
            ))}
          </div>
        </div>
      )}
      <div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
        {ordered.map(r => {
          // 同じ日に複数の記録があれば全部出す（1件に潰すと古い記録が見えなくなる）
          if (r.docs && r.docs.length) return r.docs.slice().sort((a, b) => String(a.datetime).localeCompare(String(b.datetime)))
            .map(d => <MeetingDocRow key={d.id} doc={d} n={r.n} onOpen={() => setOpenDocId(d.id)} onRemove={() => removeDoc(d.id)} onSetMeetUrl={setDocMeetUrl} fallbackMeetUrl={meetUrlForDay(d.datetime)} />);
          const future = r.day >= today();
          const src = r.gcal ? t('cd.src.gcal') : r.kinds.includes('meeting') ? t('cd.src.meeting') : t('cd.src.readyCrew');
          const planL = future ? casePlanLinkList(caseData, r.gcal) : [];
          /* カレンダー予定URLと案件登録リンクの食い違い（Zoom/Meet違い）警告＝入れない事故の再発防止（2026-08-12） */
          const urlWarn = future ? planUrlConflict(caseData, r.gcal) : null;
          return (
            <div key={'p' + r.day} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', border: '1px dashed ' + (future && !planL.length ? '#ecc98f' : '#dfe2e8'), borderRadius: 12, background: future ? '#fbfbfe' : '#fafafa' }}>
              <div style={{ width: 38, height: 38, borderRadius: 10, background: future ? '#eef0fe' : '#eef0f3', display: 'flex', alignItems: 'center', justifyContent: 'center', flex: '0 0 auto' }}>
                <Icon name="calendar" size={16} stroke={2} style={{ color: future ? '#4a5af0' : '#a8aeb8' }} />
              </div>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ fontSize: 14, fontWeight: 700, color: future ? '#1f2430' : '#7b828d', display: 'flex', alignItems: 'center', gap: 8 }}>
                  {future ? t('cd.meetingCountPlanned', { n: r.n }) : t('case-detail.meetingCount', { n: r.n })}
                  {!future && <span style={{ fontSize: 12, fontWeight: 700, color: '#9aa1ab', background: '#f0f1f4', padding: '1px 7px', borderRadius: 4 }}>{t('case-detail.noRecord')}</span>}
                  {future && !planL.length && <span title={caseNeedsRcDetailImport(caseData) ? t('cd.rcDetail.hint') : ''} style={{ fontSize: 12, fontWeight: 700, color: '#b45309', background: '#fdf0db', padding: '1px 8px', borderRadius: 999, border: '1px dashed #ecc98f' }}>{caseNeedsRcDetailImport(caseData) ? t('cd.rcDetail.badge') : t('cd.meetUrl.missing')}</span>}
                </div>
                <div style={{ fontSize: 12, color: '#9aa1ab', marginTop: 3 }}>
                  <span style={{ color: '#4a5af0' }}>{fmtDateFull(r.time)} {fmtTime(r.time) || (future ? t('cd.timeTbd') : '')}</span> · {src}
                  {!future && ' · ' + t('case-detail.autoImportFireflies')}
                </div>
                {/* 後追いリマインド：記録なしの過去回は補完を促す */}
                {!future && <div style={{ fontSize: 12, color: '#b45309', marginTop: 4 }}>⏰ {t('cd.recordRemind')}</div>}
                {urlWarn && (
                  <div style={{ fontSize: 12, color: '#b91c1c', marginTop: 4, fontWeight: 600 }}>
                    ⚠ {t('calendar.linkConflict', { gk: urlWarn.gcalKind, ck: urlWarn.caseKind })}
                    <button onClick={() => {
                      const link = { id: 'ml' + Date.now(), label: t('calendar.linkFromCal', { kind: urlWarn.gcalKind }), url: urlWarn.gcalUrl };
                      patchCase(caseData.id, { meetingLinks: [link, ...(caseData.meetingLinks || [])] });
                      showToast(t('calendar.linkConflictFixed', { kind: urlWarn.gcalKind }));
                    }} style={{ marginLeft: 8, fontSize: 11.5, fontWeight: 700, padding: '2px 9px', borderRadius: 7, border: 'none', background: '#b91c1c', color: '#fff', cursor: 'pointer', fontFamily: 'inherit' }}>{t('calendar.linkConflictFix', { kind: urlWarn.gcalKind })}</button>
                  </div>
                )}
              </div>
              {/* 予定のリンクはタイムラインと同じ単一真実（casePlanLinkList：Meet優先→会議リンク上位2件）＋コピー */}
              {future && planL.map((l, li) => (
                <span key={li} style={{ display: 'inline-flex', gap: 4, alignItems: 'stretch' }}>
                  <a href={l.url} target="_blank" rel="noreferrer" style={{ textDecoration: 'none' }}>
                    <Button size="sm" variant="default" icon="video">{l.label}</Button>
                  </a>
                  <button onClick={async () => showToast(...((await copyText(l.url)) ? [t('cd.linkCopied')] : [t('cd.copyFailed'), 'x']))}
                    title={t('cd.copyLink')}
                    style={{ display: 'inline-flex', alignItems: 'center', padding: '0 9px', border: '1px solid #e2e5ea', background: '#fff', borderRadius: 8, cursor: 'pointer', color: '#5b626d' }}>
                    <Icon name="copy" size={13} stroke={2} />
                  </button>
                </span>
              ))}
              {/* URL未登録の予定：RC詳細未取込なら「詳細を取込」（RC詳細ページを開く→userscriptが会議URLを保存）、それ以外は手動でURL登録 */}
              {future && !planL.length && (caseNeedsRcDetailImport(caseData)
                ? <a href={caseData.sourceUrl} target="_blank" rel="noreferrer" style={{ textDecoration: 'none' }} title={t('cd.rcDetail.hint')}><Button size="sm" variant="default" icon="download">{t('cd.rcDetail.import')}</Button></a>
                : <Button size="sm" variant="default" icon="link" onClick={() => {
                    const u = window.prompt(t('cd.meetUrl.prompt'));
                    if (u == null || !u.trim()) return;
                    let v = u.trim(); if (!/^https?:\/\//.test(v)) v = 'https://' + v;
                    addMeetingLink(caseData.id, { label: t('cd.meetUrl.linkLabel'), url: v });
                  }}>{t('cd.meetUrl.register')}</Button>
              )}
              {future && zoomOn && !casePlaceUrl(caseData) && (
                <Button size="sm" variant="default" icon="video" disabled={zoomBusy} onClick={() => issueZoom(r.time)}>{zoomBusy ? t('cd.zoom.issuing') : t('cd.zoom.issue')}</Button>
              )}
              {!future && r.gcal && r.gcal.meetUrl && (
                <a href={r.gcal.meetUrl} target="_blank" rel="noreferrer" style={{ textDecoration: 'none' }}>
                  <Button size="sm" variant="default" icon="video">Meet</Button>
                </a>
              )}
              {!future && (
                <Button size="sm" variant="default" icon="download" onClick={() => pickMedia(r.time)} disabled={uploadingMedia}>
                  {t('btn.uploadRecording')}
                </Button>
              )}
            </div>
          );
        })}
        {ordered.length === 0 && !fetching && (
          <div style={{ padding: '36px 0', textAlign: 'center', color: '#b4bac3' }}>
            <Icon name="video" size={26} stroke={1.6} style={{ color: '#cbd0d7' }} />
            <div style={{ fontSize: 13, marginTop: 8 }}>{t('case-detail.noMeetingDocs')}</div>
            <div style={{ fontSize: 12, marginTop: 3 }}>{t('case-detail.pasteFirefliesLinkAbove')}</div>
          </div>
        )}
      </div>
      {openDocId && (
        <MeetingDocDetailModal caseData={caseData} docId={openDocId}
          n={(ordered.find(o => o.doc && o.doc.id === openDocId) || { n: 1 }).n}
          onClose={() => setOpenDocId(null)} />
      )}
    </div>
  );
}

/* 添付ファイルの種別ごとの色 */
Object.assign(window, { MeetingModal, MeetingDocRow, MeetingDocDetailModal, MeetingDocsTab });
