// ActivityDevice — 活動履歴と端末画面(K18-29・K18-30)。TODO 詳細と案件詳細(関係者タブ)で共用する部品は、ここ 1 か所に置く。
// ==================================================================
// 形(池田さん版 reference/ikeda-2026-09-16/screens/marurou/Screens.jsx の ScActivity / ScDevice を実データに合わせたもの):
//   上に相手のピル(すべて + 対象者)、左に「やり取り一覧」、右に「端末画面」(その相手とのやり取りをチャネル別に見る)。
//   一覧の行を押すと、右がその相手・そのチャネルの端末画面に切り替わり、その記録に印が付く。
//
// 読むもの: entries は commRowToLog(Screens.jsx)の形。raw がある行 = communications の実データ(0027)。
//   raw の無い行(記録した直後の楽観表示・関係者ログの文字だけの行)は一覧にだけ出す(端末画面には作らない)。
//
// main の旧 ScActivity(Screens.jsx)から変えたこと:
//   ・右は 1 件の「生画面」ではなく、相手ごとの端末画面(チャネルのタブ・メールの一覧・通話の記録)。
//   ・電話は 結果・要約・通話メモ を出す。録音の再生は出さない(決-33(B) の Zoom 通話ログの取込が未接続 ─ 無い録音を匂わせない)。
//   ・チャネルのタブは相手の役割で絞る(決-37: LINE は居住者だけ)。ただし、既にある記録のチャネルは見られるよう常に出す。
//   ・工程の自動完了の行(「○○ を完了」)は履歴に入れない(TODO 詳細の history 側で外した ─ 連絡ではないため)。
//   ・記録を相手に結ぶのは名前だけでなく ID でも(記録の相手の担当者/会社 = 対象者の contactDbId/orgDbId)。
//
// K18-30 で足したこと:
//   ・LINE・SMS・Slack は吹き出しのスレッド(1 件の記録 = 1 つの吹き出し。本文を割ったり返事を補ったりしない)。
//   ・下に入力欄。**記録のみ**(相手へは送らない。送るかは大島さんの判断待ち・docs/21 §3)。記録は呼び出し側の onRecord が
//     関係者のログ(parties[].log)に積み、書き込み翻訳(mstore-diff.js parseLogEntry)が contact.log にする。
//     件名・通話の結果・記録した時刻・向き(こちらから/相手から)も一緒に送る。サーバに届いたのを見届けてから onReload で読み直す。
//   ・Slack・LINE の確認の関門(ContactModal と同じ)は外さない: 0064/0072 のトリガが channel=Slack/LINE・direction=発信 の行を
//     実際に投稿しうるので、こちらからの Slack は「Slack にも送る」、実送信が有効な LINE は「LINE で送る」を押すまで記録できない。
//   ・決-37 で使わないチャネル・対象者に居ない相手には入力欄を出さず、理由を 1 行出す。
//
// integ-round1 の取り込みで足したこと(K18-37・0119):
//   ・入力欄に「報告先への報告」(canReport のときだけ・発信だけ・既定オフ)。記録の isReport になり、呼び出し側の
//     persistContact / logContact が連絡モーダルの opts.isReport と同じに運ぶ(TODO 詳細の連絡ボタンは端末画面に寄せて外した)。
//   ・各記録に 報告/入金催促/関連 の印(commRowToLog の raw.report / dunning / rel ─ 旧 ScRawView の見出しと同じ)。
//
// SF の活動記録の読み方(表示だけ・DB の値は変えない):
//   SF の Task(Call)は件名にチャネルの印を書く運用だった。取込(t10)は Call を一律 channel=電話 にしているので、
//   件名が「Line」「SMS」「Slack」「メール」の行はそのチャネルのタブへ、「TEL通電」「TEL不在」は電話の結果として読む
//   (dev の写しで 電話 17,495 件のうち メール 6,997・TEL通電 3,867・Line 3,841・TEL不在 1,677・SMS 734・Slack 373)。
//   印そのものは件名ではないので、件名・要約としては出さない。

/* 端末画面のタブの並び(池田さん版と同じ順)。「その他」は HP問合せ・来訪・社内メモなど、上のどれでもない記録。 */
const DEV_ORDER = ["LINE", "SMS", "Slack", "メール", "電話", "その他"];
const DEV_KNOWN = { "LINE": 1, "SMS": 1, "Slack": 1, "メール": 1, "電話": 1 };
/* 吹き出し(スレッド)で見せるチャネル(K18-30)。 */
const DEV_CHAT = { "LINE": 1, "SMS": 1, "Slack": 1 };
/* SF の Call の件名 → [チャネル, 通話の結果]。完全一致だけを読む(本文や件名の一部から推し量らない)。 */
const DEV_SF_MARKS = [
  [/^line$/i, "LINE", null],
  [/^sms$/i, "SMS", null],
  [/^slack$/i, "Slack", null],
  [/^(メール|mail|e-?mail)$/i, "メール", null],
  [/^tel\s*通電$/i, "電話", "通電"],
  [/^tel\s*不在$/i, "電話", "不在"],
];
/* 決-37(9/9): LINE は居住者との連絡専用。依頼元・関係先はメール/電話/Slack、オーナーはメール。
   仮置き(docs/21 §3 の確認待ち): 電話は誰にでも使う。居住者は最初の連絡に SMS も使う(LINE 登録の案内・決-37)。 */
const DEV_ALLOWED = {
  resident: ["LINE", "SMS", "メール", "電話"],
  owner:    ["メール", "電話"],
  other:    ["Slack", "メール", "電話"],
};
const DEV_RESIDENT_WORDS = ["居住者", "入居者"];

const devText = v => (v == null ? "" : String(v)).trim();

/** SF の Call の件名がチャネルの印なら { ch, result, mark }。電話の行でなければ・印でなければ null。 */
function devSfMark(raw) {
  if (!raw || raw.ch !== "電話") return null;
  const s = devText(raw.subject);
  if (!s) return null;
  for (const [re, ch, result] of DEV_SF_MARKS) if (re.test(s)) return { ch, result, mark: s };
  return null;
}
/** 記録(raw)が端末画面のどのタブに入るか。 */
function devChannelOf(raw) {
  if (!raw) return "その他";
  const m = devSfMark(raw);
  if (m) return m.ch;
  return DEV_KNOWN[raw.ch] ? raw.ch : "その他";
}
/** 件名(要約)。SF の印は件名ではないので空にする。 */
function devSubjectOf(raw) {
  if (!raw || devSfMark(raw)) return "";
  return devText(raw.subject);
}
/** 一覧の 1 行の見出し: 件名 → 本文の最初の行。raw の無い行は画面が積んだ文言(t)のまま。 */
function devTitleOf(entry) {
  const raw = entry && entry.raw;
  if (!raw) return devText(entry && entry.t);
  const first = String(raw.body || "").split("\n").map(x => x.trim()).find(Boolean) || "";
  return devSubjectOf(raw) || first || "(内容なし)";
}
/** 通話の結果: 列(call_result)→ SF の印(TEL通電/TEL不在)。 */
function devCallResultOf(raw) {
  const m = devSfMark(raw);
  return devText(raw && raw.callResult) || (m && m.result) || "";
}
function devDuration(sec) {
  if (sec == null || sec === "" || !Number.isFinite(Number(sec))) return "";
  const s = Math.max(0, Math.round(Number(sec)));
  return Math.floor(s / 60) + "分" + (s % 60) + "秒";
}
/** 相手の種別(決-37 の絞り込み用)。役割と名前の語を「・」などで割り、完全一致で見る
 *  (TODO 詳細の対象者はボールの区分名「居住者」が名前になることがある ─ Screens.jsx scTargets)。 */
function devRoleKind(target) {
  if (!target) return "other";
  const words = [target.role, target.name].map(devText).filter(Boolean).join("・")
    .split(/[・／/、,\s]+/).filter(Boolean);
  if (words.some(w => DEV_RESIDENT_WORDS.indexOf(w) >= 0)) return "resident";
  if (words.some(w => w === "オーナー")) return "owner";
  return "other";
}
/** その相手に、端末画面から記録してよいチャネルか(決-37)。 */
function devCanRecord(target, ch) {
  if (!target) return false;
  return (DEV_ALLOWED[devRoleKind(target)] || DEV_ALLOWED.other).indexOf(ch) >= 0;
}
/** 端末画面に出すタブ: 役割で使えるチャネル + 既に記録があるチャネル(見るだけ)。並びは DEV_ORDER。 */
function devTabsFor(target, rows) {
  const allowed = DEV_ALLOWED[devRoleKind(target)] || DEV_ALLOWED.other;
  const present = {};
  (rows || []).forEach(e => { if (e && e.raw) present[devChannelOf(e.raw)] = true; });
  return DEV_ORDER.filter(ch => allowed.indexOf(ch) >= 0 || present[ch]);
}
/** 新しい順(occurredAt。無ければ元の並び=新しい順のまま)。 */
function devNewestFirst(rows) {
  const list = (rows || []).map((e, i) => ({ e, i }));
  list.sort((a, b) => {
    const x = (a.e.raw && a.e.raw.at) || "", y = (b.e.raw && b.e.raw.at) || "";
    if (x && y && x !== y) return x < y ? 1 : -1;
    return a.i - b.i;
  });
  return list.map(x => x.e);
}
const devDirLabel = (dir, call) => dir === "in" ? (call ? "受電" : "受信") : dir === "out" ? (call ? "発信" : "送信") : "社内メモ";

/* ---- 表示の小物(Screens.jsx に依存しない ─ 先に読むため) ---- */
const DEV_TONES = {
  brand: ["var(--brand)", "var(--brand-muted)", "var(--brand-border)"],
  neutral: ["var(--muted-foreground)", "var(--muted)", "var(--border)"],
  warn: ["var(--warning)", "var(--warning-muted)", "var(--warning-border)"],
};
function DevChip({ tone = "neutral", children, title }) {
  const T = DEV_TONES[tone] || DEV_TONES.neutral;
  return <span title={title} style={{ display:"inline-flex", alignItems:"center", height:16, padding:"0 7px", borderRadius:"var(--radius-sm)",
    fontSize:"var(--text-caption)", fontWeight:600, whiteSpace:"nowrap", flexShrink:0, lineHeight:1,
    color:T[0], background:T[1], border:"1px solid "+T[2] }}>{children}</span>;
}
function DevField({ label, children }) {
  return (
    <div style={{ display:"flex", gap:10, alignItems:"baseline", minWidth:0 }}>
      <span style={{ fontSize:"var(--text-caption)", fontWeight:600, color:"var(--muted-foreground)", minWidth:52, flexShrink:0 }}>{label}</span>
      <span style={{ fontSize:"var(--text-body-md)", minWidth:0, overflowWrap:"anywhere" }}>{children}</span>
    </div>
  );
}
function DevEmpty({ children }) {
  return <div style={{ padding:"14px 12px", fontSize:"var(--text-body-sm)", color:"var(--foreground-subtle)" }}>{children}</div>;
}
function DevSfNote({ mark }) {
  return <div style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>SF の活動記録(件名「{mark}」)から</div>;
}
/* K18-37(0119): 報告先への報告・入金催促・何のための連絡か(commRowToLog の raw.report / dunning / rel)。
   旧 ScRawView の見出しに出していた印を、端末画面の各記録にも出す。 */
function DevMarks({ r }) {
  if (!r || !(r.report || r.dunning || r.rel)) return null;
  return (
    <React.Fragment>
      {r.report && <DevChip tone="brand">報告</DevChip>}
      {r.dunning && <DevChip tone="warn">入金催促</DevChip>}
      {r.rel && <span data-dev-rel="" style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)" }}>関連: {r.rel}</span>}
    </React.Fragment>
  );
}
/* 1 件の記録の外枠。選ばれた記録(一覧で押した行)には印を付ける。 */
function DevRow({ e, i, selId, children }) {
  const sel = !!selId && e.id === selId;
  return (
    <div data-dev-row={e.id} data-selected={sel ? "true" : undefined}
      style={{ display:"flex", flexDirection:"column", gap:6, padding:"10px 12px",
        borderTop:i ? "1px solid var(--border)" : "none", borderLeft:"3px solid "+(sel ? "var(--brand)" : "transparent"),
        background:sel ? "var(--brand-muted)" : "transparent" }}>{children}</div>
  );
}
function DevMeta({ children }) {
  return <div style={{ display:"flex", gap:8, flexWrap:"wrap", alignItems:"center", fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)" }}>{children}</div>;
}
const devWhen = e => (e.d || "") + (e.time ? " " + e.time : "");
const devBy = r => (r && r.by && r.by !== "—") ? r.by : "";

/** メール: 新しい順。送信/受信・日時・宛先(差出人)・担当・件名・本文。 */
function DevMailView({ rows, selId }) {
  const list = devNewestFirst(rows);
  if (!list.length) return <DevEmpty>メールのやり取りはありません。</DevEmpty>;
  return list.map((e, i) => {
    const r = e.raw, mark = devSfMark(r);
    return (
      <DevRow key={e.id || i} e={e} i={i} selId={selId}>
        <DevMeta>
          <DevChip tone={e.dir === "out" ? "brand" : "neutral"}>{devDirLabel(e.dir)}</DevChip>
          <span className="mono">{devWhen(e)}</span>
          {e.dir !== "memo" && <span>{e.dir === "in" ? "差出人" : "宛先"} <b style={{ color:"var(--foreground)" }}>{e.who}</b></span>}
          {devBy(r) && <span>担当 {devBy(r)}</span>}
          <DevMarks r={r} />
        </DevMeta>
        <DevField label="件名">{devSubjectOf(r) || <span style={{ color:"var(--foreground-subtle)" }}>(件名なし)</span>}</DevField>
        <DevField label="本文"><span style={{ whiteSpace:"pre-wrap" }}>{r.body || "(本文なし)"}</span></DevField>
        {mark && <DevSfNote mark={mark.mark} />}
      </DevRow>
    );
  });
}
/** 電話: 新しい順。発信/受電・日時・番号・通話時間・結果・担当、要約(件名)・通話メモ(本文)。録音は出さない。 */
function DevCallView({ rows, selId }) {
  const list = devNewestFirst(rows);
  if (!list.length) return <DevEmpty>通話の記録はありません。</DevEmpty>;
  return list.map((e, i) => {
    const r = e.raw, dur = devDuration(r.durationSec), result = devCallResultOf(r), sum = devSubjectOf(r);
    return (
      <DevRow key={e.id || i} e={e} i={i} selId={selId}>
        <DevMeta>
          <DevChip tone={e.dir === "out" ? "brand" : "neutral"}>{devDirLabel(e.dir, true)}</DevChip>
          <span className="mono">{devWhen(e)}</span>
          {r.counterpartyPhone && <span className="mono" style={{ color:"var(--foreground)" }}>{r.counterpartyPhone}</span>}
          {dur && <span>通話時間 <b className="mono" style={{ color:"var(--foreground)" }}>{dur}</b></span>}
          {result && <DevChip tone={/不在|不通/.test(result) ? "warn" : "neutral"}>{result}</DevChip>}
          {devBy(r) && <span>担当 {devBy(r)}</span>}
          <DevMarks r={r} />
        </DevMeta>
        {sum && <DevField label="要約">{sum}</DevField>}
        <DevField label="通話メモ"><span style={{ whiteSpace:"pre-wrap" }}>{r.body || "—"}</span></DevField>
      </DevRow>
    );
  });
}
/** その他(HP問合せ・来訪・社内メモなど): 新しい順の記録の並び。 */
function DevRecordView({ rows, selId, ch }) {
  const list = devNewestFirst(rows);
  if (!list.length) return <DevEmpty>{ch}のやり取りはありません。</DevEmpty>;
  return list.map((e, i) => {
    const r = e.raw, sub = devSubjectOf(r), mark = devSfMark(r);
    return (
      <DevRow key={e.id || i} e={e} i={i} selId={selId}>
        <DevMeta>
          <DevChip tone={e.dir === "out" ? "brand" : "neutral"}>{devDirLabel(e.dir)}</DevChip>
          {ch === "その他" && r.ch && <DevChip>{r.ch}</DevChip>}
          <span className="mono">{devWhen(e)}</span>
          {devBy(r) && <span>担当 {devBy(r)}</span>}
          <DevMarks r={r} />
        </DevMeta>
        {sub && <div style={{ fontSize:"var(--text-body-md)", fontWeight:700 }}>{sub}</div>}
        <div style={{ fontSize:"var(--text-body-md)", lineHeight:1.7, whiteSpace:"pre-wrap", overflowWrap:"anywhere" }}>{r.body || "(本文なし)"}</div>
        {mark && <DevSfNote mark={mark.mark} />}
      </DevRow>
    );
  });
}

/** LINE・SMS・Slack(K18-30): 吹き出しのスレッド。古い順(下が新しい)。
 *  **1 件の記録 = 1 つの吹き出し**。本文を割って会話に見せたり、相手の返事を補ったりしない(実データに無い吹き出しは作らない)。
 *  こちらから(発信)は右、相手から(受信)は左、社内メモは中央。 */
function DevChatView({ rows, selId, ch }) {
  const list = devNewestFirst(rows).slice().reverse();
  if (!list.length) return <DevEmpty>{ch}のやり取りはありません。</DevEmpty>;
  const marks = [...new Set(list.map(e => { const m = devSfMark(e.raw); return m ? m.mark : null; }).filter(Boolean))];
  return (
    <div data-chat-thread={ch} style={{ display:"flex", flexDirection:"column", gap:10, padding:12, background:"var(--background)" }}>
      {marks.length > 0 && (
        <div style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)", textAlign:"center" }}>
          SF の活動記録(件名「{marks.join("」「")}」)から読んだ記録を含みます(本文は記録された文面のまま)
        </div>
      )}
      {list.map((e, i) => {
        const r = e.raw, out = e.dir === "out", memo = e.dir !== "out" && e.dir !== "in";
        const sel = !!selId && e.id === selId, sub = devSubjectOf(r);
        const name = memo ? "社内メモ" : out ? "マルロウ" + (devBy(r) ? " " + devBy(r) : "") : e.who;
        return (
          <div key={e.id || i} data-dev-row={e.id} data-bubble={memo ? "memo" : out ? "out" : "in"} data-selected={sel ? "true" : undefined}
            style={{ display:"flex", flexDirection:"column", alignItems:memo ? "center" : out ? "flex-end" : "flex-start", gap:2 }}>
            <div style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)" }}>{name}</div>
            <div style={{ maxWidth:"85%", padding:"6px 10px", borderRadius:"var(--radius-lg)", fontSize:"var(--text-body-md)", lineHeight:1.6,
              whiteSpace:"pre-wrap", wordBreak:"break-word", color:"var(--foreground)",
              background:out ? "var(--brand-muted)" : memo ? "var(--muted)" : "var(--card)",
              border:(sel ? "2px solid var(--brand)" : "1px solid " + (out ? "var(--brand-border)" : "var(--border)")) }}>
              {sub && <div style={{ fontWeight:700, marginBottom:2 }}>{sub}</div>}
              {r.body || "(本文なし)"}
            </div>
            <div style={{ display:"flex", gap:6, alignItems:"center", flexWrap:"wrap" }}>
              <span className="mono" style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>{devWhen(e)}</span>
              <DevMarks r={r} />
            </div>
          </div>
        );
      })}
    </div>
  );
}

/* ---- 入力欄(K18-30)。**記録のみ** ─ 相手へは送らない(送るかは大島さんの判断待ち・docs/21 §3)。 ---- */
const DEV_CALL_RESULTS = ["応答", "不在", "折返し希望"];
/** 書き込みの控え(送信待ちの数え)。Screens.jsx の scWriteSnap と同じ見方(先に読むのでここに持つ)。 */
function devWriteSnap() {
  const w = typeof window !== "undefined" ? window.MarurouWrite : null;
  if (!w || typeof w.status !== "function") return null;
  const s = w.status() || {};
  return { failed:(s.failed || []).length, unsupported:(s.unsupported || []).length, conflicts:Number(s.conflicts) || 0, blocked:Number(s.blocked) || 0 };
}
/** 記録がサーバに届いたかを、控え(before)からの変化で読む(Screens.jsx scAwaitWrite と同じ読み方)。 */
async function devAwaitWrite(before, { timeout = 20000 } = {}) {
  const w = typeof window !== "undefined" ? window.MarurouWrite : null;
  if (!w || !before) return { ok:true, local:!w };
  const idle = typeof w.idle === "function" ? Promise.resolve(w.idle()) : Promise.resolve();
  const timedOut = await Promise.race([idle.then(() => false), new Promise(r => setTimeout(() => r(true), timeout))]);
  const s = (typeof w.status === "function" && w.status()) || {};
  const after = devWriteSnap() || before;
  const last = a => (Array.isArray(a) && a.length) ? a[a.length - 1] : null;
  if (after.unsupported > before.unsupported) { const u = last(s.unsupported); return { ok:false, reason:(u && u.reason) || "この記録はまだサーバに保存できません。" }; }
  if (after.failed > before.failed) { const f = last(s.failed); return { ok:false, reason:"サーバに拒否されました" + (f && f.message ? "（" + f.message + "）" : "") + "。記録は残っていません。" }; }
  if (after.conflicts > before.conflicts) return { ok:false, reason:"他の人が先に更新していたため、案件を読み直しました。記録が残ったか確かめてから、必要ならもう一度記録してください。" };
  if (after.blocked > 0) return { ok:false, reason:"認証の期限が切れています。ログインし直すと、この記録は自動で送られます。" };
  if (timedOut || (Number(s.pending) || 0) > 0) return { ok:false, reason:"保存がまだ終わっていません（送信待ち）。届けば一覧に出ます。" };
  return { ok:true };
}
/** Slack・LINE の確認の関門が要るか(ContactModal と同じ条件)。0064/0072 のトリガは channel=Slack/LINE・direction=発信 の行を
 *  実際に投稿しうる ─ 相手からの記録(受信)は対象外。LINE は実送信が有効(line_enabled)なときだけ。 */
function devGateNeeded(ch, dir, lineEnabled) {
  if (dir !== "out") return false;
  return ch === "Slack" || (ch === "LINE" && !!lineEnabled);
}
/** 一覧の見出し(Screens.jsx scCommLabel と同じ式)。記録した直後の楽観表示と、読み直した行を突き合わせる鍵にもなる。 */
function devRecordLabel(ch, subject, body) {
  return (ch || "連絡") + "：" + (subject || String(body || "").split("\n")[0] || "(内容なし)");
}
/** 入力から記録の形を作る(記録のみ・送らない)。電話は 要約=件名・通話メモ=本文(メモが空なら要約を本文に)。 */
function devBuildRecord({ who, ch, dir, text, subject, callResult, isReport, now }) {
  const isCall = ch === "電話", isMail = ch === "メール";
  const memo = devText(text), sub = (isCall || isMail) ? devText(subject) : "";
  const body = isCall ? (memo || sub) : memo;
  if (!who || !ch || !body) return null;
  const rec = { who, ch, dir: dir === "in" ? "in" : "out", text: body, at: (now || new Date()).toISOString() };
  if (sub) rec.subject = sub;
  if (isCall && callResult) rec.callResult = String(callResult);
  /* K18-37(0119): 報告先への報告は発信だけ(受信・社内メモで報告日が進むと、報告漏れの滞りが消える)。 */
  if (isReport && rec.dir === "out") rec.isReport = true;
  rec.label = devRecordLabel(ch, rec.subject, body);
  return rec;
}

function DevComposer({ who, target, ch, onRecord, onReload, canReport }) {
  const isCall = ch === "電話", isMail = ch === "メール";
  const [dir, setDir] = React.useState("out");
  const [text, setText] = React.useState("");
  const [subject, setSubject] = React.useState("");
  const [result, setResult] = React.useState("");
  const [confirm, setConfirm] = React.useState(false);
  /* K18-37(0119): 報告先への報告(既定オフ・発信だけ)。ContactModal の同じチェックと同じ意味。 */
  const [report, setReport] = React.useState(false);
  const [lineStatus, setLineStatus] = React.useState(null);
  const [pending, setPending] = React.useState(false);
  const [err, setErr] = React.useState("");
  const [done, setDone] = React.useState("");
  React.useEffect(() => { setDir("out"); setText(""); setSubject(""); setResult(""); setConfirm(false); setReport(false); setErr(""); setDone(""); }, [who, ch]);
  React.useEffect(() => {
    if (ch !== "LINE" || lineStatus) return undefined;
    let alive = true;
    const cloud = typeof window !== "undefined" ? window.MarurouCloud : null;
    const run = cloud && typeof cloud.lineStatus === "function" ? cloud.lineStatus() : Promise.resolve(null);
    Promise.resolve(run).then(r => { if (alive) setLineStatus(r || { lineEnabled:false }); })
      .catch(() => { if (alive) setLineStatus({ lineEnabled:false }); });
    return () => { alive = false; };
  }, [ch, lineStatus]);
  const lineEnabled = !!(lineStatus && lineStatus.lineEnabled);
  const gate = devGateNeeded(ch, dir, lineEnabled);
  const isReport = !!canReport && report && dir === "out";
  const rec = devBuildRecord({ who, ch, dir, text, subject, callResult: isCall ? result : null, isReport });
  const blocked = !rec || pending || (gate && !confirm);
  const submit = async () => {
    if (blocked) return;
    setPending(true); setErr(""); setDone("");
    try {
      const before = devWriteSnap();
      await Promise.resolve(onRecord(devBuildRecord({ who, ch, dir, text, subject, callResult: isCall ? result : null, isReport })));
      const res = await devAwaitWrite(before);
      if (!res.ok) throw new Error(res.reason);
      setText(""); setSubject(""); setResult(""); setConfirm(false); setReport(false);
      setDone(res.local ? "画面にだけ記録しました（保存の経路がありません）。"
        : gate ? "記録しました。有効化されていれば " + ch + " にも届きます。"
        : "記録しました（記録のみ・相手には送っていません）。");
      if (typeof onReload === "function") onReload();
    } catch (e) {
      setErr((e && e.message) || "記録できませんでした。もう一度お試しください。");
    } finally { setPending(false); }
  };
  const dirs = isCall ? [["out", "発信"], ["in", "受電"]] : [["out", "こちらから"], ["in", "相手から"]];
  const field = { width:"100%", boxSizing:"border-box", padding:"6px 8px", borderRadius:"var(--radius-md)", border:"1px solid var(--input)",
    fontSize:"var(--text-body-md)", fontFamily:"var(--font-sans)", color:"var(--foreground)", background:"var(--card)" };
  const Btn = typeof AfButton === "function" ? AfButton : "button";
  return (
    <div data-composer={ch} style={{ display:"flex", flexDirection:"column", gap:7, padding:"10px 12px", borderTop:"1px solid var(--border)", background:"var(--accent)" }}>
      <div style={{ display:"flex", alignItems:"center", gap:6, flexWrap:"wrap" }}>
        <div role="radiogroup" aria-label="向き" style={{ display:"inline-flex", gap:3 }}>
          {dirs.map(([k, label]) => (
            <label key={k} style={{ display:"inline-flex", alignItems:"center", gap:3, fontSize:"var(--text-caption)", cursor:"pointer",
              padding:"2px 8px", borderRadius:"var(--radius-full)", border:"1px solid " + (dir === k ? "var(--brand)" : "var(--border)"),
              color:dir === k ? "var(--brand)" : "var(--muted-foreground)", background:dir === k ? "var(--brand-muted)" : "transparent" }}>
              <input type="radio" name={"dev-dir-" + ch} value={k} checked={dir === k} onChange={() => setDir(k)} style={{ margin:0 }} />{label}
            </label>
          ))}
        </div>
        <span data-record-only="" style={{ marginLeft:"auto", fontSize:"var(--text-caption)", fontWeight:600, color:"var(--warning)" }}>
          {!gate ? "記録のみ・相手には送信しません" : ch === "Slack" ? "記録します・有効化されると Slack にも届きます" : "記録します・確認すると LINE で相手に届きます"}</span>
      </div>
      {isCall && (
        <div style={{ display:"flex", gap:6 }}>
          <select aria-label="通話の結果" value={result} onChange={ev => setResult(ev.target.value)} style={{ ...field, width:"auto", flex:"0 0 auto" }}>
            <option value="">結果</option>
            {DEV_CALL_RESULTS.map(x => <option key={x} value={x}>{x}</option>)}
          </select>
          <input aria-label="要約" value={subject} onChange={ev => setSubject(ev.target.value)} placeholder="要約" style={{ ...field, flex:1, minWidth:0 }} />
        </div>
      )}
      {isMail && <input aria-label="件名" value={subject} onChange={ev => setSubject(ev.target.value)} placeholder="件名" style={field} />}
      <textarea aria-label={isCall ? "通話メモ" : "本文"} value={text} onChange={ev => setText(ev.target.value)} rows={isMail ? 4 : 2}
        placeholder={isCall ? "通話メモ" : ch + "の内容(記録のみ・送信しません)"} style={{ ...field, resize:"vertical" }} />
      {canReport && dir === "out" && (
        <label style={{ display:"flex", alignItems:"center", gap:6, fontSize:"var(--text-body-sm)", color:"var(--foreground)", cursor:"pointer" }}>
          <input type="checkbox" aria-label="報告先への報告" checked={report} onChange={ev => setReport(ev.target.checked)} />
          報告先への報告（相手の前回報告日を今日にする）
        </label>
      )}
      {ch === "Slack" && dir === "out" && (
        <label style={{ display:"flex", alignItems:"center", gap:6, fontSize:"var(--text-body-sm)", color:"var(--foreground)", cursor:"pointer" }}>
          <input type="checkbox" checked={confirm} onChange={ev => setConfirm(ev.target.checked)} />
          Slack にも送る
        </label>
      )}
      {ch === "LINE" && dir === "out" && lineEnabled && (
        <label style={{ display:"flex", alignItems:"center", gap:6, fontSize:"var(--text-body-sm)", color:"var(--foreground)", cursor:"pointer" }}>
          <input type="checkbox" checked={confirm} onChange={ev => setConfirm(ev.target.checked)} />
          LINE で送る
        </label>
      )}
      {(ch === "Slack" || ch === "LINE") && dir === "out" && (
        <div data-send-gate={ch} style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)", lineHeight:1.6 }}>
          {ch === "Slack"
            ? "Slack の記録は実送信の対象です。保存はDB側の差込口(notify_settings)経由で行われ、有効化されるまでは実際には届きません。"
            : lineEnabled
              ? "LINE の記録は公式アカウントからの実送信の対象です。相手の LINE が紐づいていない場合は届きません。"
              : "LINE の実送信はまだ有効化されていません(池田さんのトークン待ち)。この記録はこの案件に残るだけで、相手には届きません。"}
        </div>
      )}
      {err && (typeof AfSaveFail === "function"
        ? <AfSaveFail reason={err} title="記録できませんでした" onDismiss={() => setErr("")} />
        : <div role="alert" style={{ fontSize:"var(--text-body-sm)", color:"var(--destructive)" }}>{err}</div>)}
      {done && <div role="status" style={{ fontSize:"var(--text-caption)", color:"var(--success)" }}>{done}</div>}
      <div style={{ display:"flex", justifyContent:"flex-end" }}>
        <Btn size="sm" variant="primary" disabled={blocked} onClick={submit} data-record-button="">
          {pending ? "記録中…" : isCall ? "通話を記録" : "記録する"}
        </Btn>
      </div>
    </div>
  );
}

/** 入力欄の代わりに出す注記(記録できない理由)。null なら入力欄を出す。 */
function devComposerNote(target, ch, canInput) {
  if (!canInput) return null;
  if (!target) return "この相手は対象者に居ないため、ここからは記録できません。上の対象者を選んでください。";
  if (!devCanRecord(target, ch)) return "この相手には " + ch + " を使いません(決-37)。既にある記録を見るだけです。";
  return "";
}

/** 端末画面: 1 人の相手とのやり取りを、チャネルのタブで切り替えて見る。onRecord があれば下に入力欄(記録のみ)。 */
function ScDevice({ who, target, rows, ch, setCh, selId, onRecord, onReload, canReport }) {
  const bodyRef = React.useRef(null);
  const list = Array.isArray(rows) ? rows.filter(e => e && e.raw) : [];
  const tabs = devTabsFor(target || (who ? { name: who } : null), list);
  const cur = tabs.indexOf(ch) >= 0 ? ch : tabs[0];
  const counts = {};
  list.forEach(e => { const k = devChannelOf(e.raw); counts[k] = (counts[k] || 0) + 1; });
  const mine = list.filter(e => devChannelOf(e.raw) === cur);
  const chat = DEV_CHAT[cur];
  /* 一覧で選んだ記録を、端末画面の中でだけ見える位置へ送る(ページ全体は動かさない)。
     選んでいないときの吹き出しは、いちばん下(新しいもの)を見せる。 */
  React.useEffect(() => {
    const box = bodyRef.current;
    if (!box || typeof box.querySelector !== "function") return;
    const el = selId ? box.querySelector('[data-selected="true"]') : null;
    if (el) box.scrollTop = Math.max(0, el.offsetTop - 8);
    else if (chat) box.scrollTop = box.scrollHeight;
  }, [selId, cur, who, mine.length]);
  const body = cur === "メール" ? <DevMailView rows={mine} selId={selId} />
    : cur === "電話" ? <DevCallView rows={mine} selId={selId} />
    : chat ? <DevChatView rows={mine} selId={selId} ch={cur} />
    : <DevRecordView rows={mine} selId={selId} ch={cur} />;
  const note = devComposerNote(target, cur, typeof onRecord === "function");
  return (
    <div data-device="" style={{ border:"1px solid var(--border-strong)", borderRadius:"var(--radius-lg)", overflow:"hidden",
      background:"var(--card)", boxShadow:"var(--shadow-sm)", minWidth:0 }}>
      <div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap", padding:"8px 12px", background:"var(--muted)", borderBottom:"1px solid var(--border)" }}>
        <span style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.04em", color:"var(--muted-foreground)" }}>端末画面</span>
        <span data-device-who="" style={{ fontSize:"var(--text-body-md)", fontWeight:700 }}>{who}</span>
        {target && target.role && <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>{target.role}</span>}
        {note === "" && <span style={{ marginLeft:"auto" }}><DevChip tone="warn" title="ここで入力した内容は連絡記録として残るだけで、相手には送りません">記録のみ</DevChip></span>}
      </div>
      <div role="tablist" aria-label="チャネル" style={{ display:"flex", gap:4, flexWrap:"wrap", padding:"8px 12px", borderBottom:"1px solid var(--border)" }}>
        {tabs.map(k => {
          const on = k === cur;
          return (
            <button key={k} type="button" role="tab" aria-selected={on ? "true" : "false"} data-dev-ch={k}
              title={devCanRecord(target, k) || !target ? undefined : "この相手には使わないチャネルです(既にある記録を見るだけ・決-37)"}
              onClick={() => setCh && setCh(k)}
              style={{ all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)", fontWeight:on ? 700 : 500,
                padding:"3px 10px", borderRadius:"var(--radius-full)", whiteSpace:"nowrap",
                border:"1px solid "+(on ? "var(--brand)" : "var(--border)"), background:on ? "var(--brand-muted)" : "transparent",
                color:on ? "var(--brand)" : "var(--muted-foreground)" }}>
              {k}<span className="mono" style={{ fontSize:"var(--text-caption)", marginLeft:5, color:on ? "var(--brand)" : "var(--foreground-subtle)" }}>{counts[k] || 0}</span>
            </button>
          );
        })}
      </div>
      {cur === "電話" && <div style={{ padding:"6px 12px 0", fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>録音は取り込んでいません(Zoom の通話ログは未接続)。</div>}
      <div ref={bodyRef} data-device-body={cur} style={{ position:"relative", maxHeight:360, overflowY:"auto", minHeight:96 }}>{body}</div>
      {note === "" && <DevComposer who={who} target={target} ch={cur} onRecord={onRecord} onReload={onReload} canReport={canReport} />}
      {note && <div data-composer-note="" style={{ padding:"8px 12px", borderTop:"1px solid var(--border)", fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>{note}</div>}
    </div>
  );
}

/** 記録(一覧の行)がその相手のものか: 名前が同じ、または記録の相手の ID(担当者・会社)が対象者の ID と同じ。
 *  実データの記録の名前は担当者の個人名、対象者(関係者)の名前は会社名のことが多いので、ID でも結ぶ。 */
function devBelongs(l, name, target) {
  if (!l) return false;
  if (name && l.who === name) return true;
  const r = l.raw;
  if (!r || !target) return false;
  if (target.contactDbId && r.contactId && r.contactId === target.contactDbId) return true;
  if (target.orgDbId && r.orgId && r.orgId === target.orgDbId) return true;
  return false;
}
/** 記録を開いたときの端末画面の相手: 名前の同じ対象者 → ID の合う対象者 → 記録の名前のまま。 */
function devWhoOf(l, tlist) {
  if (!l) return null;
  const list = tlist || [];
  const byName = list.find(t => t.name === l.who);
  if (byName) return byName.name;
  const byId = list.find(t => devBelongs({ raw: l.raw }, null, t));
  return byId ? byId.name : l.who;
}
/** TODO 詳細の対象者(名前だけ)に、記録先と同じ関係者の ID を添える。記録先の選び方は Screens.jsx persistContact と同じ
 *  (トラックの先方担当はその人、ほかは名前が同じか名前を含む関係者)。 */
function devAttachIds(targets, parties, contact) {
  const ps = Array.isArray(parties) ? parties : [];
  return (Array.isArray(targets) ? targets : []).map(t => {
    if (!t || !t.name) return t;
    if (contact && t.name === contact.name) return { ...t, contactDbId: contact.contactDbId || null, orgDbId: contact.orgDbId || null };
    const p = ps.find(x => x && x.name && (x.name === t.name || String(x.name).indexOf(t.name) >= 0));
    return p ? { ...t, contactDbId: p.contactDbId || null, orgDbId: p.orgDbId || null } : t;
  });
}

/** 活動履歴: 相手のピル + 左に一覧・右に端末画面(TODO 詳細・案件詳細で共用)。
 *  defaultWho: 「すべて」で何も選んでいないときに端末画面に出す相手(TODO 詳細は宛先の初期値 deviceDefaultWho)。
 *  targets: [{ name, role, contactDbId?, orgDbId? }]。role は決-37 の絞り込み、ID は記録を相手に結ぶのに使う。
 *  onRecord(rec): 端末画面の入力欄から記録する(記録のみ)。rec = { who, ch, dir, text, subject?, callResult?, isReport?, at, label }。
 *  canReport: 入力欄に「報告先への報告」を出す(K18-37・発信だけ)。呼び出し側は rec をそのまま opts として記録に渡す。
 *  onReload(): 記録がサーバに届いたあと、連絡履歴を読み直す。 */
function ScActivity({ targets, entries, tab, setTab, selId, setSelId, emptyNote, defaultWho, onRecord, onReload, canReport }) {
  const list = Array.isArray(entries) ? entries : [];
  const tlist = (Array.isArray(targets) ? targets : []).filter(t => t && t.name);
  const targetOf = name => tlist.find(t => t.name === name) || null;
  const shown = tab === "すべて" ? list : list.filter(l => devBelongs(l, tab, targetOf(tab)));
  const sel = selId ? (list.find(x => x.id === selId && x.raw) || null) : null;
  /* 「すべて」で何も選んでいないとき: 宛先の初期値(TODO 詳細)→ いちばん新しい記録の相手(案件詳細)→ 先頭の対象者。 */
  const latest = devNewestFirst(list.filter(l => l.raw && l.who))[0];
  const who = tab !== "すべて" ? tab
    : (sel ? devWhoOf(sel, tlist) : (defaultWho || (latest && devWhoOf(latest, tlist)) || (tlist[0] && tlist[0].name) || null));
  const target = targetOf(who);
  const rows = list.filter(l => l.raw && devBelongs(l, who, target));
  /* 端末画面のタブは「この相手のとき」だけ覚える(相手が変われば、選んだ記録か最新の記録のチャネルに戻す)。 */
  const [pick, setPick] = React.useState({ who: null, ch: null });
  const listRef = React.useRef(null), devRef = React.useRef(null);
  const tabs = devTabsFor(target || (who ? { name: who } : null), rows);
  const newest = devNewestFirst(rows)[0];
  /* 記録が 1 件も無い相手はメール(実データで最も多いチャネル)から開く。 */
  const ch = (pick.who === who && tabs.indexOf(pick.ch) >= 0) ? pick.ch
    : sel && rows.indexOf(sel) >= 0 ? devChannelOf(sel.raw)
    : newest ? devChannelOf(newest.raw) : (tabs.indexOf("メール") >= 0 ? "メール" : tabs[0]);
  const choose = l => {
    if (!l.raw) return;
    setSelId && setSelId(l.id);
    setPick({ who: devWhoOf(l, tlist), ch: devChannelOf(l.raw) });
    /* 縦積み(狭い画面)のときだけ、下にある端末画面まで送る。横並びなら動かさない。 */
    setTimeout(() => {
      const a = listRef.current, b = devRef.current;
      if (!a || !b || typeof b.getBoundingClientRect !== "function") return;
      if (b.getBoundingClientRect().top >= a.getBoundingClientRect().bottom - 1 && b.scrollIntoView) b.scrollIntoView({ block: "start", behavior: "smooth" });
    }, 0);
  };
  return (
    <div data-activity="" data-default-who={defaultWho || undefined}>
      <div style={{ display:"flex", gap:5, flexWrap:"wrap", marginBottom:10 }}>
        {["すべて", ...tlist.map(t => t.name)].map(k => {
          const n = k === "すべて" ? list.length : list.filter(l => devBelongs(l, k, targetOf(k))).length;
          return (
            <button key={k} onClick={() => { setTab && setTab(k); setSelId && setSelId(null); }} style={{ all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)",
              fontSize:"var(--text-body-sm)", fontWeight:tab === k ? 700 : 500, padding:"4px 10px", borderRadius:"var(--radius-full)", whiteSpace:"nowrap",
              border:"1px solid "+(tab === k ? "var(--brand)" : "var(--border)"), background:tab === k ? "var(--brand-muted)" : "transparent",
              color:tab === k ? "var(--brand)" : "var(--muted-foreground)" }}>
              {k}<span className="mono" style={{ fontSize:"var(--text-caption)", marginLeft:5, color:tab === k ? "var(--brand)" : "var(--foreground-subtle)" }}>{n}</span>
            </button>
          );
        })}
      </div>
      <div style={{ display:"grid", gridTemplateColumns:"repeat(auto-fit, minmax(260px, 1fr))", gap:12, alignItems:"start" }}>
        <div ref={listRef} data-activity-list="" style={{ minWidth:0 }}>
          <div style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.04em", color:"var(--muted-foreground)", marginBottom:4 }}>
            やり取り一覧<span className="mono" style={{ marginLeft:5, color:"var(--foreground-subtle)" }}>{shown.length}</span>
          </div>
          {shown.length === 0 && <div style={{ fontSize:"var(--text-body-md)", color:"var(--foreground-subtle)" }}>履歴はまだありません。</div>}
          <div style={{ display:"flex", flexDirection:"column", maxHeight:440, overflowY:"auto" }}>
            {shown.map((l, j) => {
              const on = !!sel && l.id === sel.id;
              const chLabel = l.raw ? devChannelOf(l.raw) : "";
              return (
                <button key={l.id || j} onClick={() => choose(l)} disabled={!l.raw} data-activity-row={l.id || ""}
                  style={{ all:"unset", boxSizing:"border-box", width:"100%", display:"flex", gap:7, alignItems:"baseline",
                    padding:"7px 8px", borderRadius:"var(--radius-sm)", cursor:l.raw ? "pointer" : "default",
                    background:on ? "var(--brand-muted)" : "transparent", borderBottom:"1px solid var(--border)",
                    fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)" }}>
                  <span className="mono" style={{ color:"var(--foreground-subtle)", flexShrink:0, minWidth:"6.6em" }}>{l.d}{l.time ? " " + l.time : ""}</span>
                  <span title={devDirLabel(l.dir)} style={{ color:l.dir === "out" ? "var(--brand)" : l.dir === "in" ? "var(--success)" : "var(--muted-foreground)", width:11, flexShrink:0 }}>
                    {l.dir === "out" ? "↑" : l.dir === "in" ? "↓" : "・"}</span>
                  {chLabel && <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)", flexShrink:0 }}>{chLabel}</span>}
                  <span style={{ minWidth:0, flex:1, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap",
                    color:on ? "var(--foreground)" : "var(--muted-foreground)", fontWeight:on ? 600 : 400 }}>
                    {tab === "すべて" && l.who ? l.who + "　" : ""}{devTitleOf(l)}
                  </span>
                </button>
              );
            })}
          </div>
        </div>
        <div ref={devRef} style={{ minWidth:0 }}>
          {who
            ? <ScDevice who={who} target={target} rows={rows} ch={ch} setCh={k => setPick({ who, ch: k })} selId={sel ? sel.id : null}
                onRecord={onRecord} onReload={onReload} canReport={canReport} />
            : (
              <div style={{ padding:"9px 12px", background:"var(--muted)", border:"1px solid var(--border)", borderRadius:"var(--radius-md)", fontSize:"var(--text-body-sm)", color:"var(--foreground-subtle)" }}>
                {emptyNote || "連絡先がありません"}
              </div>
            )}
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { ScActivity, ScDevice, devChannelOf, devSfMark, devSubjectOf, devTitleOf, devCallResultOf,
  devRoleKind, devCanRecord, devTabsFor, devNewestFirst, devBelongs, devWhoOf, devAttachIds,
  devGateNeeded, devRecordLabel, devBuildRecord, devAwaitWrite, devComposerNote });
