// Screens — TODO詳細 / 取引先詳細 / TODO追加 / 連絡モーダル / 検索結果（アクアフロー Light）

const SC_TABS = ["報告","社内承認","社外承認","連絡","方針確認","見積","入金精算","その他"];
const SC_CH = {
  call:  { label:"電話", kind:"call", hint:"電話のかけ先はまだ繋がっていません。この画面では「連絡した」記録だけ残せます。" },
  mail:  { label:"メール", kind:"text", hint:"送信はしません。入力した文面を連絡記録に残します。" },
  sms:   { label:"SMS", kind:"text", hint:"送信はしません。入力した文面を連絡記録に残します。" },
  line:  { label:"LINE", kind:"text", hint:"送信はしません。入力した文面を連絡記録に残します。" },
  slack: { label:"Slack", kind:"text", hint:"送信はしません。入力した文面を連絡記録に残します。" },
};
const scChip = { display:"inline-flex", alignItems:"center", gap:3, height:16, padding:"0 7px", borderRadius:"var(--radius-sm)", fontSize:"var(--text-caption)", fontWeight:600, whiteSpace:"nowrap", flexShrink:0, lineHeight:1 };
const SC_TONES = {
  brand:["var(--brand)","var(--brand-muted)","var(--brand-border)"],
  success:["var(--success)","var(--success-muted)","var(--success-border)"],
  warn:["var(--warning)","var(--warning-muted)","var(--warning-border)"],
  survey:["var(--status-survey)","var(--status-survey-muted)","var(--status-survey-border)"],
  danger:["var(--urgency-emergency)","var(--destructive-muted)","var(--destructive-border)"],
  neutral:["var(--muted-foreground)","var(--muted)","var(--border)"],
};
function ScChip({ tone="neutral", dot, children, style }) {
  const T = SC_TONES[tone]||SC_TONES.neutral;
  return <span style={{ ...scChip, color:T[0], background:T[1], border:"1px solid "+T[2], ...style }}>{dot && <span style={{ fontSize:7 }}>●</span>}{children}</span>;
}
function ScField({ label, children, wide }) {
  return (
    <div style={{ display:"flex", gap:10, alignItems:"baseline", minWidth:wide?"100%":0 }}>
      <span style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.04em", color:"var(--muted-foreground)", minWidth:60, flexShrink:0 }}>{label}</span>
      <span style={{ fontSize:"var(--text-body-md)", minWidth:0 }}>{children}</span>
    </div>
  );
}
function ScBack({ onClick, label }) {
  return <button onClick={onClick} style={{ all:"unset", fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)", fontWeight:600, color:"var(--brand)", cursor:"pointer", marginBottom:12, display:"inline-block" }}>← {label}</button>;
}
function ScModal({ title, sub, onClose, children, footer, width="var(--modal-w-md)" }) {
  return (
    <div onClick={onClose} style={{ position:"fixed", inset:0, zIndex:50, background:"rgba(20,22,28,0.35)", display:"flex", alignItems:"center", justifyContent:"center", animation:"mrfade var(--duration-fast) var(--easing)" }}>
      <div onClick={e=>e.stopPropagation()} style={{ width, maxWidth:"calc(100vw - 48px)", background:"var(--card)", borderRadius:"var(--radius-lg)", boxShadow:"var(--shadow-lg)", border:"1px solid var(--border)", animation:"mrpop var(--duration-base) var(--easing)" }}>
        <div style={{ padding:"14px 16px", borderBottom:"1px solid var(--border)", display:"flex", alignItems:"center" }}>
          <div>
            <h3 style={{ margin:0, fontSize:"var(--text-display-sm)", fontWeight:600 }}>{title}</h3>
            {sub && <div style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)", marginTop:2 }}>{sub}</div>}
          </div>
          <div style={{ flex:1 }} />
          <button onClick={onClose} style={{ all:"unset", cursor:"pointer", color:"var(--muted-foreground)", fontSize:16, padding:4 }}>✕</button>
        </div>
        <div style={{ padding:16, display:"flex", flexDirection:"column", gap:14 }}>{children}</div>
        {/* K18-49: 390px でボタンが 4 つ並ぶ(工程のモーダル)と左端の「閉じる」が画面の外へ切れていたので、折り返す */}
        {footer && <div style={{ padding:"12px 16px", borderTop:"1px solid var(--border)", display:"flex", gap:8, justifyContent:"flex-end", flexWrap:"wrap" }}>{footer}</div>}
      </div>
    </div>
  );
}

/* ===== 1. TODO詳細（4構成：トラック情報／TODO詳細／活動履歴／アクションビュー） ===== */
/* 紐づけは明示された trkId のみ（推測しない＝誤った断言を出さない） */
try { if(localStorage.getItem("marurou.store.v3")) localStorage.removeItem("marurou.store.v3"); } catch(e) {}
const scTrack = t => !t.trkId ? null : (window.TR_ROWS||[]).find(x=>x.id===t.trkId) || null;
/* 今日をM/D表記で。CaseDetailV25.jsx の dn()・Intake.jsx の msn() と同じ形(案件詳細-3で発見:
   ここが定義されておらず scTodayMd() を呼ぶたび ReferenceError で握りつぶされていた ─
   受領・帳票発行・共有・連絡の記録がすべて例外で止まり、setLog より後ろが一切実行されない)。 */
const scTodayMd = () => { const d = new Date(); return `${d.getMonth()+1}/${d.getDate()}`; };
/* TODO追加(TaskAddModal)の期限の初期値(今日・YYYY/MM/DD)。0a4e967 で足したが、その後の統合で定義だけが落ち、
   モーダルを開くと ReferenceError で画面ごと白くなっていた(K18-21 の見出しの ＋TODO で見つけた)。 */
const scTodayYmd = () => { const d = new Date(), p2 = x => String(x).padStart(2,"0");
  return d.getFullYear()+"/"+p2(d.getMonth()+1)+"/"+p2(d.getDate()); };

/* ── K18-07: TODO詳細の「完了して次のTODOへ」の保存を見届ける道具 ───────────
   以前の finish はトーストを出して画面を移るだけで、DB には何も書いていなかった
   (docs/21 K18-07)。MStore.update は同期で、送信は書き込みキュー(app/write)が
   後から行う。「完了しました」と言って画面を移ってよいのは、キューが空になり、
   その間に 失敗(failed)・翻訳できない変更(unsupported)・競合(conflicts)・
   認証切れ(blocked)が **増えていない** と確かめたときだけ(docs/05 §5.5 の
   「保存したふりをしない」)。 */
const scWriteSnap = () => {
  const w = window.MarurouWrite;
  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, held:Number(s.held)||0 };
};
/* 控え(before)からの変化で成否を読む。
   ・lost=true : サーバに残らなかった(翻訳不能・拒否)。画面の変更は戻してよい。
   ・lost=false で ok=false : 競合(案件は読み直し済み)・認証切れ(再ログイン後に
     送られる)・時間切れ(まだ送信待ち)。変更は失われていないので戻さない。 */
const scAwaitWrite = async (before, { timeout = 30000 } = {}) => {
  const w = window.MarurouWrite;
  if (!w || !before) return { ok:true };
  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 = scWriteSnap() || before;
  const last = arr => (Array.isArray(arr) && arr.length) ? arr[arr.length-1] : null;
  if (after.unsupported > before.unsupported) {
    const u = last(s.unsupported);
    return { ok:false, lost:true, reason:(u && u.reason) || "この変更はまだサーバに保存できません。" };
  }
  if (after.failed > before.failed) {
    const f = last(s.failed);
    return { ok:false, lost:true, reason:"サーバに拒否されました" + (f && f.message ? "（"+f.message+"）" : "") + "。この完了はサーバに残っていません。" };
  }
  if (after.conflicts > before.conflicts) {
    return { ok:false, lost:false, reason:"他の人が先に更新していたため、この案件を最新に読み直しました。内容を確かめてから、もう一度完了にしてください。" };
  }
  if (after.blocked > 0) {
    return { ok:false, lost:false, reason:"認証の期限が切れています。ログインし直すと、この完了は自動で送られます。" };
  }
  /* H5(9/25): 通信断で保留した(書き込み層が送信待ちに残して止めている)。失われてはいないので戻さない。 */
  if ((after.held || 0) > 0) {
    return { ok:false, lost:false, reason:"通信できないため、この完了はまだサーバに届いていません。通信が戻ると自動で送ります。" };
  }
  if (timedOut || (Number(s.pending)||0) > 0) {
    return { ok:false, lost:false, reason:"保存がまだ終わっていません（送信待ち）。届くまでこの画面に残ります。もう一度押すと続きを待ちます。" };
  }
  return { ok:true };
};
/* K18-37(0119): 連絡の「何のための連絡か」。TODO 詳細の TODO 行(rpc_seed の todos)が工程なら
   { relatedStepId: track_steps.id }、手動の TODO なら { relatedTaskId: tasks.id }。分からなければ {}。 */
const SC_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const scRelatedRef = (c, todo) => {
  if (!c || !todo) return {};
  const rowId = todo.todoId || todo.id;
  const row = (c.todos || []).find(t => t && t.id === rowId) || todo;
  if (!row || !SC_UUID_RE.test(String(row.dbId || ""))) return {};
  if (row.kind === "step" || row.railStep) return { relatedStepId: row.dbId };
  if (row.kind === "task") return { relatedTaskId: row.dbId };
  return {};
};
/* 赤帯(㉝ showSaveFail)。書き込み層・ContactModal と同じ形で出す。 */
const scSaveFail = (reason) => {
  if (typeof window.showSaveFail === "function") window.showSaveFail({ reason, title:"完了を保存できませんでした" });
  else console.warn("[TODO詳細] " + reason);
};
/* TODO行(rpc_seed の todo_track)が指す工程をトラックの steps から引く。
   dbId(= track_steps.id)で名指しできればそれ、無ければ工程名(railStep)で未完了の
   最初の同名。どちらも無ければ null ─ 推測で別の工程を完了にしない。 */
const scFindRailStep = (c, row) => {
  if (!c || !row || !row.trkId || typeof window.msTracks !== "function") return null;
  const hit = window.msTracks(c).find(x => x && x.t && x.t.id === row.trkId);
  if (!hit) return null;
  const steps = Array.isArray(hit.t.steps) ? hit.t.steps : [];
  let i = row.dbId ? steps.findIndex(s => s && s.stepId && s.stepId === row.dbId) : -1;
  if (i < 0) i = steps.findIndex(s => s && s.s !== "done" && s.s !== "skip" && s.n === row.railStep);
  return i < 0 ? null : { track:hit.t, kind:hit.kind, index:i, step:steps[i] };
};
/* トラックの表示用の状態(state/status/stall/mk)を工程から導き直す。
   CaseDetailV25.jsx の dRecalc と同じ導き方 ─ 書き込み翻訳はこれらを「steps と
   一緒に変わる導出値」として扱う(mstore-diff.js DERIVED_WITH_STEPS)。 */
const scRecalcTrack = t => {
  const steps = Array.isArray(t.steps) ? t.steps : [];
  const o = steps.find(s => s && s.s !== "done" && s.s !== "skip");
  if (!o) {
    const allSkip = steps.length > 0 && steps.every(s => s && s.s === "skip");
    t.state = allSkip ? "closed" : "done"; t.status = allSkip ? "打ち切り" : "完了"; t.mk = ""; t.stall = false;
    return t;
  }
  if (o.s === "todo") o.s = "now";
  t.state = o.s === "waiting" ? "wait" : "now";
  t.status = o.s === "waiting" ? (/待ち$/.test(o.n) ? o.n : o.n + "待ち") : o.n + "中";
  t.stall = o.s === "waiting" && /保険/.test(o.n);
  t.mk = t.stall ? "delay" : "";
  return t;
};
/* 対象者＝このTODOで関わる相手（ボール相手・業者・精算先・依頼者）。自社は除く */
const scNorm = n => (n||"").replace(/（[^）]*）|\([^)]*\)/g,"").trim();
/* store.jsx が「未設定」を書くときの記号(vendor / payer / sub / sales / dueDone …
   すべて "—")。**相手の名前ではない**ので対象者に並べない。並べると業者が
   未定の進行でも「— へ連絡」が押せてしまい、案件の関係者に居ない "—" 宛に
   連絡を記録することになる ─ 書き込み層(mstore-diff.js)はそれを「prev に居ない
   関係者」として actor.add に倒し、連絡記録(contact.log)を送らない。結果、
   画面には「記録しました」と出るのに communications には1行も残らず、
   代わりに名前が "—" の関係者だけがDBに増える(9/8 実測。E2E smoke
   「TODO詳細の連絡モーダル…」が全体実行のときだけ落ちていた正体)。 */
const SC_UNSET = /^[-–—―ー]+$/;
const scTargets = (todo, trk, contact) => {
  const meta = (window.BV_CASEMETA||{})[todo.caseId] || {};
  const list = [];
  const push = (name, role) => {
    if(!name || name==="自社") return;
    const key = scNorm(name);
    if(!key || SC_UNSET.test(key)) return;
    const hit = list.find(x=>x.key===key);
    if(hit){ if(hit.roles.indexOf(role)<0) hit.roles.push(role); return; }
    list.push({ key, name:key, roles:[role] });
  };
  push(todo.dunning || (todo.ball!=="自社" ? todo.ball : null), "ボール");
  if(trk){ push(trk.vendor, trk.kind==="ins"?"保険会社":"業者"); }
  /* K18-20(0112): トラックの先方担当(担当者個人)。会社(上の業者・保険会社)とは別のタブにする。
     役割の語は画面だけのもの ─ 連絡の記録は persistContact が会社の関係者の行に積む(関係者には足さない)。 */
  if(contact) push(contact.name, contact.side==="保険会社" ? "保険会社の担当" : "業者の担当");
  if(trk){ push(trk.payer, "精算先"); }
  push(meta.client, "依頼者");
  return list.map(x=>({ ...x, role:x.roles.join("・") }));
};
/* 連絡履歴（communications・書き込み経路-10）。0027 rpc_list_communications を案件単位で読む。
   固定モックの SC_LOGS は廃止（案件詳細-19/22/2）。全件を一度に取らない ─ 1ページ(既定50件)だけ
   取り、続きは loadMore() で足す。読めなかったら黙って空にしない(err を返し、呼び出し側が
   「読めません」を出す。docs/05 §5.5)。 */
function useCaseComms(caseDbId, limit) {
  const lim = limit || 50;
  const [rows, setRows] = React.useState(null);   // null=読込中
  const [err, setErr] = React.useState("");
  const [hasMore, setHasMore] = React.useState(false);
  const [cursor, setCursor] = React.useState(null);
  const [loadingMore, setLoadingMore] = React.useState(false);
  const [tick, setTick] = React.useState(0);       // reload() で増やす
  React.useEffect(() => {
    setRows(null); setErr(""); setHasMore(false); setCursor(null);
    if (!caseDbId) { setRows([]); return; }
    const cloud = typeof window !== "undefined" ? window.MarurouCloud : null;
    if (!cloud || typeof cloud.listCommunications !== "function") {
      setRows([]); setErr("連絡履歴を読み込めていません（接続の設定がありません）。");
      return;
    }
    let alive = true;
    cloud.listCommunications(caseDbId, { limit: lim }).then(r => {
      if (!alive) return;
      setRows((r && r.rows) || []); setHasMore(!!(r && r.hasMore)); setCursor((r && r.nextCursor) || null);
    }).catch(e => {
      if (!alive) return;
      setRows([]); setErr((e && e.message) || "連絡履歴を読み込めませんでした");
    });
    return () => { alive = false; };
  }, [caseDbId, lim, tick]);
  const loadMore = () => {
    const cloud = typeof window !== "undefined" ? window.MarurouCloud : null;
    if (!cloud || !hasMore || loadingMore || rows === null) return;
    setLoadingMore(true);
    cloud.listCommunications(caseDbId, { limit: lim, cursor }).then(r => {
      setRows(list => [...(list || []), ...((r && r.rows) || [])]);
      setHasMore(!!(r && r.hasMore)); setCursor((r && r.nextCursor) || null);
    }).catch(e => { setErr((e && e.message) || "続きを読み込めませんでした"); })
      .finally(() => setLoadingMore(false));
  };
  /* K18-30: 記録した直後の読み直し。reload() と違って「読み込み中」に戻さない(端末画面の相手・タブ・入力を
     保ったまま新しい行を足す)。1 ページ目を読み直し、既に読んだ古い行は残す(続きのカーソルもそのまま使える)。 */
  const caseRef = React.useRef(caseDbId);
  caseRef.current = caseDbId;
  const refresh = () => {
    const cloud = typeof window !== "undefined" ? window.MarurouCloud : null;
    const id = caseDbId;
    if (!id || !cloud || typeof cloud.listCommunications !== "function" || rows === null) return;
    cloud.listCommunications(id, { limit: lim }).then(r => {
      if (caseRef.current !== id) return;
      const fresh = (r && r.rows) || [];
      const ids = new Set(fresh.map(x => x.id));
      setRows(list => [...fresh, ...((list || []).filter(x => !ids.has(x.id)))]);
      setErr("");
    }).catch(e => { if (caseRef.current === id) setErr((e && e.message) || "連絡履歴を読み直せませんでした"); });
  };
  return { rows, err, hasMore, loadingMore, loadMore, reload: () => setTick(n => n + 1), refresh };
}
/* チャネル名(communications.channel の実値)→ 見出しの色味。旧SC_CHMETAの短いコード('call'等)は廃止 */
const SC_REAL_CH_TONE = { "電話":"success", "メール":"brand", "LINE":"success", "SMS":"survey", "Slack":"brand", "HP問合せ":"neutral", "来訪":"neutral", "その他":"neutral" };
const scCommDir = r => r.direction==="発信" ? "out" : r.direction==="受信" ? "in" : "memo";
const scCommWho = r => r.counterpartyContactName || r.counterpartyOrgName || (r.direction==="社内メモ" ? "社内" : "相手");
const scCommDate = iso => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso||""); return m ? (+m[2])+"/"+(+m[3]) : "—"; };
const scCommTime = iso => { const m = /T(\d{2}):(\d{2})/.exec(iso||""); return m ? m[1]+":"+m[2] : ""; };
/* 一覧の1行の見出し。生データは全チャネル共通でテキスト化されているため件名か本文冒頭を出す */
const scCommLabel = r => (r.channel||"連絡") + "：" + (r.subject || (r.body||"").split("\n")[0] || "(内容なし)");
/* ScActivity/ScRawView が読める形に正規化。raw.real=true でモック時代の分岐と区別する */
const commRowToLog = r => {
  const who = scCommWho(r), d = scCommDate(r.occurredAt), time = scCommTime(r.occurredAt), dir = scCommDir(r);
  /* K18-37(0119): 報告チェック・関連の工程/TODO・入金催促は生画面の見出しに印で出す。一覧の見出し(t)は変えない
     ─ 送った直後の楽観行と d+t で突き合わせているため(案件詳細-3)。 */
  return { id:r.id, trkId:r.trackId, d, time, dir, who, t:scCommLabel(r),
    /* K18-29: at(時刻そのもの)・contactId/orgId(記録の相手の ID)は端末画面が読む(並び・相手への結び付け)。 */
    raw:{ real:true, ch:r.channel, who, d, time, at:r.occurredAt||"", dir, by:r.byName||"—", subject:r.subject, body:r.body,
      contactId:r.counterpartyContactId||null, orgId:r.counterpartyOrgId||null,
      counterpartyPhone:r.counterpartyPhone, durationSec:r.durationSec, callResult:r.callResult,
      report:!!r.isReport, dunning:!!r.isDunning, rel:r.relatedStepName || r.relatedTaskSubject || null } };
};

/* 生画面ビュー：そのやり取りを端末の表示のまま見せる */
function ScRawView({ e, who }) {
  if(!e) return null;
  const tone = SC_REAL_CH_TONE[e.ch] || "neutral";
  const Frame = ({ head, children }) => (
    <div style={{ border:"1px solid var(--border-strong)", borderRadius:"var(--radius-lg)", overflow:"hidden", background:"var(--card)", boxShadow:"var(--shadow-sm)" }}>
      <div style={{ display:"flex", alignItems:"center", gap:8, padding:"8px 12px", background:"var(--muted)", borderBottom:"1px solid var(--border)" }}>{head}</div>
      <div style={{ padding:"12px 14px 14px" }}>{children}</div>
    </div>
  );
  const dirLabel = e.dir==="in" ? "受信" : e.dir==="out" ? "発信" : "社内メモ";
  const head = (
    <React.Fragment>
      <ScChip tone={tone}>{e.ch||"連絡"}</ScChip>
      <span style={{ fontSize:"var(--text-body-md)", fontWeight:700 }}>{e.who}</span>
      <span className="mono" style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)" }}>{e.d} {e.time}</span>
      {/* K18-37(0119): 報告先への報告・入金催促・何のための連絡か */}
      {e.report && <ScChip tone="brand">報告</ScChip>}
      {e.dunning && <ScChip tone="warn">入金催促</ScChip>}
      {e.rel && <span style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)" }}>関連: {e.rel}</span>}
      <span style={{ marginLeft:"auto", fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>{dirLabel}{e.by ? "・担当 "+e.by : ""}</span>    </React.Fragment>
  );
  const durLabel = e.durationSec!=null ? Math.floor(e.durationSec/60)+"分"+(e.durationSec%60)+"秒" : null;
  /* 実データは全チャネル共通でテキスト化されている(communications.body)。通話は電話番号・通話時間・
     応対結果を分かる範囲で添え、それ以外(メール等)は件名＋本文をそのまま出す。チャット吹き出しの
     再現や「録音あり」の演出はしない ─ 実データに無いものを補って作らない(docs/05 §5.5と同じ理由)。 */
  return (
    <Frame head={head}>
      {e.subject && <div style={{ fontSize:"var(--text-body-lg)", fontWeight:700, marginBottom:6 }}>{e.subject}</div>}
      {(e.counterpartyPhone || durLabel || e.callResult) && (
        <div style={{ display:"flex", alignItems:"center", gap:10, flexWrap:"wrap", marginBottom:9, fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)" }}>
          {e.counterpartyPhone && <span className="mono" style={{ color:"var(--foreground)" }}>{e.counterpartyPhone}</span>}
          {durLabel && <span>通話時間 <b className="mono" style={{ color:"var(--foreground)" }}>{durLabel}</b></span>}
          {e.callResult && <span>{e.callResult}</span>}
        </div>
      )}
      <div style={{ fontSize:"var(--text-body-md)", lineHeight:1.9, whiteSpace:"pre-wrap", color:"var(--foreground)" }}>{e.body || "(本文なし)"}</div>
    </Frame>
  );
}

/* このTODOを終えるまでの内訳（3工程）。btype で型を決め、状態はデータから導出 */
const SC_W = "@who@", SC_C = "@client@", SC_T = "@target@";
/* 各工程は自分の信号(sig)と操作(act)を持つ。位置では判定しない
   sig: recv=相手からの受領物 / made=自社の確定版 / shared=報告先へ全員共有
        rail=進行のその工程が完了 / railName:X=指定工程が完了 / railAll=進行が全部完了
        out=自社からの連絡記録 / in=相手からの返答 */
const SC_MICRO = {
  報告: { lead:SC_W+"から報告書を受け取り、確定版にして"+SC_C+"へ共有する", steps:[
    { t:"業者報告書の受領", sig:"recv", act:"receive" },
    { t:"確定版の作成", sig:"made", act:"doc" },
    { t:"関係者へ共有", sig:"shared", act:"share" }] },
  見積: { lead:SC_W+"の原価見積をもとに自社見積を作り、"+SC_C+"へ提出する", steps:[
    { t:"原価見積の受領", sig:"recv", act:"receive" },
    { t:"自社見積の作成", sig:"made", act:"doc" },
    { t:"提出・承認依頼", sig:"shared", act:"share" }] },
  社内承認:{ lead:"自社で内容を確認して決裁し、次の工程に進める", steps:[
    { t:"内容の確認", sig:"made", act:null },
    { t:"社内決裁", sig:"rail", act:"step" },
    { t:"相手へ通知", sig:"out", act:"contact" }] },
  社外承認:{ lead:SC_W+"から承認・発注書をもらい、受注として記録する", steps:[
    { t:"承認の依頼", sig:"out", act:"contact" },
    { t:"回答・発注書の受領", sig:"recv", act:"receive" },
    { t:"受注として記録", sig:"rail", act:"step" }] },
  承認: { lead:SC_W+"の承認をもらい、次の工程に進める", steps:[
    { t:"承認の依頼", sig:"out", act:"contact" },
    { t:"回答の受領", sig:"in", act:"contact" },
    { t:"結果の記録", sig:"rail", act:"step" }] },
  連絡: { lead:SC_W+"へ連絡し、回答をもらって記録する", steps:[
    { t:"連絡・催促", sig:"out", act:"contact" },
    { t:"回答の受領", sig:"in", act:"contact" },
    { t:"結果の反映", sig:"rail", act:"step" }] },
  方針確認:{ lead:SC_T+"に方針を確認し、決まった内容を記録する", steps:[
    { t:"案の提示", sig:"out", act:"contact" },
    { t:"相手の回答", sig:"in", act:"contact" },
    { t:"方針の記録", sig:"rail", act:"step" }] },
  入金精算:{ lead:SC_W+"へ請求し、入金を確認して消し込む", steps:[
    { t:"請求書の発行・送付", sig:"made", act:"doc" },
    { t:"入金の確認", sig:"railName:入金", act:"step" },
    { t:"消込", sig:"railAll", act:"step" }] },
  その他: { lead:"現地・自社で対応し、結果を記録する", steps:[
    { t:"段取り・連絡", sig:"out", act:"contact" },
    { t:"実施", sig:"rail", act:"step" },
    { t:"記録", sig:"railAll", act:null }] },
  /* 自社が手を動かすTODO＝受領工程を持たない */
  "報告:self": { lead:"報告書をまとめ、"+SC_C+"へ共有する", steps:[
    { t:"報告書の作成", sig:"made", act:"doc" },
    { t:"関係者へ共有", sig:"shared", act:"share" }] },
  "見積:self": { lead:"自社見積を作り、"+SC_C+"へ提出する", steps:[
    { t:"自社見積の作成", sig:"made", act:"doc" },
    { t:"提出・承認依頼", sig:"shared", act:"share" }] },
  "連絡:self": { lead:SC_T+"へ連絡し、結果を記録する", steps:[
    { t:"連絡", sig:"out", act:"contact" },
    { t:"結果の記録", sig:"rail", act:"step" }] },
  "入金精算:self": { lead:SC_T+"へ請求し、入金を確認して消し込む", steps:[
    { t:"請求書の発行・送付", sig:"made", act:"doc" },
    { t:"入金の確認", sig:"railName:入金", act:"step" },
    { t:"消込", sig:"railAll", act:"step" }] },
};
/* 管-12「TODO種別」(0026 todo_types)で保存した文言を、SC_MICRO の型に重ねる(0078)。
   差し替えるのは **lead と工程名(t)だけ**。sig(状態の見方)と act(押したときの操作)は
   画面の仕組みそのもので管理画面では編集できない ─ SC_MICRO 固定のまま使う。
   ・:self 版(自社が手を動かす=受領工程を持たない2工程)に対応する code は
     todo_types に無い。工程数も文言も基本形と違うので**差し替えない**
     (基本形の「業者報告書の受領」を自社のTODOに被せると嘘になる)。
   ・該当 code が無い/工程数が違う/文言が空 のときも、その部分は固定文言のまま残す。 */
const scApplyTodoType = (base, key, isSelf) => {
  if (isSelf || base !== SC_MICRO[key]) return base;
  const rows = (window.MStore && typeof window.MStore.todoTypes === "function") ? window.MStore.todoTypes() : [];
  const hit = (rows || []).find(x => x && x.code === key);
  if (!hit) return base;
  const dbSteps = Array.isArray(hit.steps) ? hit.steps : [];
  if (dbSteps.length !== base.steps.length) return base;
  const lead = String(hit.leadText || "").trim();
  return {
    lead: lead || base.lead,
    steps: base.steps.map((x,i) => {
      const t = String((dbSteps[i] && dbSteps[i].text) || "").trim();
      return t ? { ...x, t } : x;
    }),
  };
};
/* K-29: TODO の起きた日(raised・"M/D")を年つきの "YYYY-MM-DD" に直す。年を持たないので、今日より先に
   見える月日は去年とみなす(起きた日は未来にならない)。読めなければ null ─ そのときは絞らない。 */
const scRaisedIso = (md, today) => {
  const m = /^(\d{1,2})\/(\d{1,2})$/.exec(String(md || "").trim());
  if (!m) return null;
  const now = today || new Date();
  const mo = +m[1], d = +m[2], nm = now.getMonth() + 1, nd = now.getDate();
  const y = now.getFullYear() - ((mo > nm || (mo === nm && d > nd)) ? 1 : 0);
  return y + "-" + String(mo).padStart(2, "0") + "-" + String(d).padStart(2, "0");
};
const scMicro = (todo, trk, who, comms) => {
  const cs = window.MStore ? window.MStore.case(todo.caseId) : null;
  const key = window.dvTypeOf ? window.dvTypeOf(todo)
    : todo.btype==="承認" ? (todo.step.indexOf("社内")>=0 ? "社内承認" : "社外承認") : todo.btype;
  const self = todo.ball==="自社";
  const M = scApplyTodoType(
    SC_MICRO[key+(self?":self":"")] || SC_MICRO[key] || SC_MICRO[todo.btype] || SC_MICRO["その他"], key, self);
  /* 実際の相手：督促先 → 自社以外のボール → 連絡先 → 依頼者 */
  const target = todo.dunning || (!self ? todo.ball : null) || who || (cs?cs.client:"相手");
  const lead = M.lead.split(SC_W).join(self?"自社":(who||"相手")).split(SC_C).join(cs?cs.client:"依頼者").split(SC_T).join(target);
  const kindOf = { "報告":"報告書", "見積":"見積書", "入金精算":"請求書", "社外承認":"発注書" }[key] || null;
  /* 日付の比べ鍵(年月日)。K18-49 から店の日付は、M/D だと別の年に読まれる日だけ YYYY/M/D で来る
     (FormParts.jsx mrDateToUi)。以前の「M/D を探す」読み方は 2025/12/3 を 25/12 と読み、起票日が去年の
     TODO では発行・共有の記録(H7)が数えられなかった。年の無い M/D は起票日と同じ規則(scRaisedIso)で年を補う。 */
  const dnum = d => {
    const s = String(d||"").trim();
    const y = /^(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})/.exec(s);
    if (y) return (+y[1])*10000+(+y[2])*100+(+y[3]);
    const iso = scRaisedIso((/(\d{1,2})\/(\d{1,2})/.exec(s)||[""])[0]);
    return iso ? Number(iso.replace(/-/g, "")) : 0;
  };
  const raised = todo.raised ? dnum(todo.raised) : 0;
  const tid = todo.todoId || todo.id || null;
  const all = cs ? cs.files : [];
  const stamped = f => !!tid && f.todoId===tid;
  const scoped = trk
    ? all.filter(f=>stamped(f) || (!f.todoId && f.trkId===trk.id && dnum(f.date) >= raised))
    : all.filter(f=>stamped(f) || !f.todoId);
  const mine = kindOf ? scoped.filter(f=>f.type===kindOf) : scoped;
  const recv = mine.some(f=>f.stage==="received");
  /* H7(9/25 本番の不具合): 「作成済み」「共有済み」は DB の記録からも見る。
     発行した書類の行(files[] の stage "issued")は発行したその端末の控えにしか無く、別の端末には
     添付(受領物)しか届かない。以前はその受領物が共有の記録で「確定」に化けたときだけ済みに見えていた
     (読み込みの届く順で変わる)。受領物は確定版として数えず、発行の記録(doc_issues・0084)と
     共有の記録(file_shares・0044)で見る。トラックの TODO は、そのトラックの・起票日以降の記録だけ
     (files[] の絞り込みと同じ規則)。トラックの無い古い共有の記録はどのトラックの共有か分からないので、
     トラックの TODO では数えない(別のトラックの共有で済みに見せない)。 */
  const kindHit = k => !kindOf || (typeof k==="string" && k.indexOf(kindOf)>=0);
  const issuedLog = ((cs && cs.docIssues) || []).filter(d => d && (d.action==="issued" || d.action==="finalized")
    && kindHit(d.kind) && (!trk || (d.trkId===trk.id && dnum(d.at) >= raised)));
  const made = mine.some(f=>f.stage==="issued") || issuedLog.length>0;
  const draft = mine.some(f=>f.stage==="draft");
  const need = (cs && cs.reportTo) || [];
  const shareable = mine.filter(f=>f.stage!=="received");
  const needOf = nm => need.find(n => nm===n || String(nm).indexOf(n)>=0) || null;
  /* 共有の記録はトラックを dbId で持つ。TR_ROWS の行(trk)は dbId を持たないので、案件のトラックから引く。 */
  const trkDb = !trk ? null : (todo.trkDbId || trk.dbId || (() => {
    const ins = !cs || !cs.ins ? [] : (Array.isArray(cs.ins) ? cs.ins : [cs.ins]);
    const t = (cs ? [...(cs.visits||[]), ...(cs.works||[]), ...ins] : []).find(x => x && x.id===trk.id);
    return (t && t.dbId) || null;
  })());
  const sharedLog = ((cs && cs.fileShareLog) || []).filter(r => r && r.recipientName && (!kindOf || r.fileLabel===kindOf)
    && (!trk || (!!trkDb && r.trackId===trkDb && dnum(r.occurredAt) >= raised)))
    .map(r => needOf(r.recipientName)).filter(Boolean);
  const sharedNames = [...new Set([...shareable.flatMap(f=>f.sharedTo||[]), ...sharedLog])].filter(n=>need.indexOf(n)>=0);
  const rails = trk ? [trk] : (window.TR_ROWS||[]).filter(r=>r.caseId===todo.caseId);
  const railOf = n => { for(const t of rails){ const hit = t.rail.find(r=>r[0]===n); if(hit && hit[1]==="done") return "done"; if(hit) return hit[1]; } return null; };
  const railAll = rails.length>0 && rails.every(t=>t.rail.every(r=>r[1]==="done"||r[1]==="skip"));
  /* 連絡・催促／回答の受領は communications(0027)から見る。読み込み中/読めない間は「まだ」扱いにする
     (誤って完了扱いにしない ─ 空データへ黙って落とさない。comms.err/ready はUIに返し、呼び出し側が
     出す。案件詳細-2)。 */
  const commsRows = comms && Array.isArray(comms.rows) ? comms.rows : null;
  const commsReady = commsRows !== null;
  /* K-29: 進行(trk)のTODOは、その進行に付いた連絡(trackId = trk.dbId)だけを見る。以前は trk.dbId が
     無く(store.jsx trackRows)常に空だった。あわせて**このTODOが起きた日(raised)より前の連絡は数えない**
     ─ 1トラック1行のTODOは工程が進むと同じ行が次の工程になるので、前の工程でした連絡で
     次の工程の「連絡・催促」が最初から済みに見えてしまう(書類の raised と同じ考え方)。 */
  const since = scRaisedIso(todo.raised);
  const inThisTodo = r => !since || String(r.occurredAt || "").slice(0, 10) >= since;
  const commsScoped = commsReady ? commsRows.filter(r => (trk ? r.trackId===trk.dbId : true) && inThisTodo(r)) : [];
  const test = sig => {
    if(!sig) return false;
    if(sig==="recv") return recv;
    if(sig==="made") return made;
    if(sig==="shared") return need.length>0 && sharedNames.length>=need.length;
    if(sig==="out") return commsScoped.some(r=>r.direction==="発信");
    if(sig==="in"){
      if(todo.railStep && railOf(todo.railStep)==="waiting") return false;
      const lastOut = commsScoped.filter(r=>r.direction==="発信").reduce((mx,r)=>r.occurredAt>mx?r.occurredAt:mx, "");
      return commsScoped.some(r=>r.direction==="受信" && r.occurredAt>=lastOut);
    }
    if(sig==="rail") return todo.railStep ? railOf(todo.railStep)==="done" : railAll;
    if(sig==="railAll") return railAll;
    if(sig.indexOf("railName:")===0) return railOf(sig.slice(9))==="done";
    return false;
  };
  const raw = M.steps.map(x=>({ ...x, ok:test(x.sig) }));
  /* 済みは前詰め：後ろが立っていても手前が未了なら「今ここ」は手前 */
  let cursor = raw.findIndex(x=>!x.ok);
  if(cursor<0) cursor = raw.length;
  const steps = raw.map((x,i)=>({ t:x.t, act:x.act,
    s: i<cursor ? "done" : i===cursor ? "now" : "todo",
    count: x.sig==="shared" && need.length ? sharedNames.length+"／"+need.length+"者" : null }));
  const usesComms = M.steps.some(x=>x.sig==="out"||x.sig==="in");
  return { lead, steps, need, shared:sharedNames, files:shareable, all:mine, draft, kindOf, trkId:trk?trk.id:null, hasTrack:!!trk, allDone:cursor>=raw.length,
    commsReady: usesComms ? commsReady : true, commsErr: usesComms && comms ? comms.err : "" };
};

/* 共有先の候補（ファイル・帳票-6・決-8）: 報告先(need=cs.reportTo。表示名)を
   案件の関係者(parties)と突き合わせ、case_actors.id(actorId)を持つ相手だけを
   選べる状態にする。名前の文字列一致では解決しない(同名の別の関係者に付け替わる
   事故になる。0044_file_shares.sqlの設計判断と同じ理由)ので、一致しない・IDが
   無い枠は選べないまま出し、理由を1行添える(黙って選択肢から消さない)。 */
function scShareCandidates(parties, need, shared) {
  return (need || []).map(n => {
    const done = (shared || []).indexOf(n) >= 0;
    const p = (parties || []).find(x => x && (x.name === n || String(x.name).indexOf(n) >= 0));
    if (!p) return { name: n, actorId: null, done, reason: "関係者の一覧に見つかりません(関係者タブで先に追加してください)" };
    if (!p.actorId) return { name: n, actorId: null, done, reason: "この関係者はIDが未登録のため選べません" };
    return { name: n, actorId: p.actorId, done, reason: null };
  });
}

/* 内訳(3工程)の表は TodoDetailParts.jsx の TdStepTable(K18-27。以前の横並び ScMicroSteps は表に置き換えた)。 */

/* ③共有：報告先に済みの印を付ける（送信はまだない） */
function ScShareModal({ need, shared, parties, files, onClose, onSend }) {
  const cands = scShareCandidates(parties, need, shared);
  const [sel, setSel] = React.useState(cands.filter(c=>!c.done && c.actorId).map(c=>c.name));
  const [ch, setCh] = React.useState("メール");
  const [fid, setFid] = React.useState(files[0] ? files[0].id : null);
  const send = () => {
    // selはボタンの選択状態でしかない ─ 実際に送るのはactorIdを持つ人だけ
    // (disabledなボタンからは選べないはずだが、名前だけでIDが伴わない共有を
    // 二重に防ぐ)。
    const eligible = sel.map(n => cands.find(c=>c.name===n)).filter(c => c && c.actorId);
    if (!eligible.length || !fid) return;
    onSend(fid, eligible.map(c=>c.name), ch, eligible.map(c=>c.actorId));
  };
  const eligibleCount = sel.map(n => cands.find(c=>c.name===n)).filter(c => c && c.actorId).length;
  return (
    <ScModal title="関係者へ共有" sub="相手への送信はまだありません。済みの印だけ残せます" onClose={onClose}
      footer={<React.Fragment>
        <AfButton onClick={onClose}>キャンセル</AfButton>
        <AfButton variant="primary" disabled={!eligibleCount} onClick={send}>{ch}したと記録（{eligibleCount}者・送信しません）</AfButton>
      </React.Fragment>}>
      <div>
        <div style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.06em", color:"var(--muted-foreground)", marginBottom:5 }}>送る書類</div>
        <div style={{ display:"flex", flexDirection:"column", gap:4 }}>
          {files.length===0 && <div style={{ fontSize:"var(--text-body-md)", color:"var(--foreground-subtle)" }}>この進行に確定版の書類がありません。先に②で作成してください。</div>}
          {files.map(f=>(
            <button key={f.id} onClick={()=>setFid(f.id)} style={{ all:"unset", cursor:"pointer", display:"flex", alignItems:"center", gap:8,
              padding:"7px 10px", borderRadius:"var(--radius-md)", fontFamily:"var(--font-sans)",
              border:"1px solid "+(fid===f.id?"var(--brand)":"var(--border)"), background:fid===f.id?"var(--brand-muted)":"var(--card)" }}>
              <span style={{ fontSize:"var(--text-body-md)", fontWeight:600 }}>{f.name}</span>
              <span className="mono" style={{ marginLeft:"auto", fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>{f.date}</span>
            </button>
          ))}
        </div>
      </div>
      <div>
        <div style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.06em", color:"var(--muted-foreground)", marginBottom:5 }}>報告先（{shared.length}／{need.length}者 済み）</div>
        <div style={{ display:"flex", flexDirection:"column", gap:4 }}>
          {cands.map(c=>{
            const done = c.done, on = sel.indexOf(c.name)>=0, disabled = done || !c.actorId;
            return (
              <button key={c.name} disabled={disabled} onClick={()=>setSel(l=>l.indexOf(c.name)>=0?l.filter(x=>x!==c.name):[...l,c.name])}
                title={!done && c.reason ? c.reason : undefined}
                style={{ all:"unset", cursor:disabled?"default":"pointer", display:"flex", alignItems:"center", gap:8, padding:"7px 10px",
                  borderRadius:"var(--radius-md)", fontFamily:"var(--font-sans)", opacity:disabled?0.6:1,
                  border:"1px solid "+(on?"var(--brand)":"var(--border)"), background:done?"var(--success-muted)":on?"var(--brand-muted)":"var(--card)" }}>
                <span style={{ width:14, height:14, borderRadius:3, boxSizing:"border-box", flexShrink:0,
                  border:"1.5px solid "+(done?"var(--success)":on?"var(--brand)":"var(--border-strong)"),
                  background:done?"var(--success)":on?"var(--brand)":"transparent", display:"inline-flex", alignItems:"center", justifyContent:"center" }}>
                  {(done||on) && <span style={{ fontSize:8, color:"#fff", fontWeight:700, lineHeight:1 }}>✓</span>}
                </span>
                <span style={{ fontSize:"var(--text-body-md)", fontWeight:600 }}>{c.name}</span>
                {done && <span style={{ marginLeft:"auto", fontSize:"var(--text-caption)", fontWeight:600, color:"var(--success)" }}>記録済</span>}
                {!done && c.reason && <span style={{ marginLeft:"auto", fontSize:"var(--text-caption)", color:"var(--warning)" }}>{c.reason}</span>}
              </button>
            );
          })}
          {need.length===0 && <div style={{ fontSize:"var(--text-body-md)", color:"var(--foreground-subtle)" }}>報告先が未設定です（受付で決めます）。</div>}
        </div>
      </div>
      <div>
        <div style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.06em", color:"var(--muted-foreground)", marginBottom:5 }}>手段</div>
        <div style={{ display:"flex", gap:5, flexWrap:"wrap" }}>
          {["メール","LINE","SMS","電話"].map(x=>(
            <button key={x} onClick={()=>setCh(x)} style={{ all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)",
              fontSize:"var(--text-body-sm)", fontWeight:ch===x?700:500, padding:"4px 11px", borderRadius:"var(--radius-full)",
              border:"1px solid "+(ch===x?"var(--brand)":"var(--border)"), background:ch===x?"var(--brand-muted)":"transparent",
              color:ch===x?"var(--brand)":"var(--muted-foreground)" }}>{x}</button>
          ))}
        </div>
      </div>
    </ScModal>
  );
}

/* 受信箱＝メール／LINE／Slack で相手から届いた添付。取り込みはまだ無いので常に空。
 * 以前の架空6件は実在する案件番号に乗っていた。受領は手元ファイル選択だけ残す。 */
const SC_CH_LABEL = { mail:"メール", line:"LINE", slack:"Slack", sms:"SMS" };
const SC_CH_TONE = { mail:"survey", line:"success", slack:"brand", sms:"neutral" };
const scInboxFor = () => [];
/* 受信箱：相手×チャネルごとに届いた添付を選ぶ */
function ScInbox({ entries, sel, onToggle, taken }) {
  if(!entries || entries.length===0) return (
    <div style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)", lineHeight:1.7 }}>受信箱に該当する添付はありません。ファイル選択で格納してください。</div>
  );
  return (
    <div style={{ display:"flex", flexDirection:"column", gap:6 }}>
      {entries.map(e=>(
        <div key={e.id} style={{ border:"1px solid var(--border)", borderRadius:"var(--radius-md)", background:"var(--card)", padding:"7px 8px" }}>
          <div style={{ display:"flex", alignItems:"center", gap:6, marginBottom:4 }}>
            <ScChip tone={SC_CH_TONE[e.ch]||"neutral"}>{SC_CH_LABEL[e.ch]||e.ch}</ScChip>
            <span style={{ fontSize:"var(--text-caption)", fontWeight:700, whiteSpace:"nowrap", overflow:"hidden", textOverflow:"ellipsis" }}>{e.who}</span>
            <span className="mono" style={{ marginLeft:"auto", fontSize:"var(--text-caption)", color:"var(--foreground-subtle)", flexShrink:0 }}>{e.d} {e.time}</span>
          </div>
          <div style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)", lineHeight:1.6, marginBottom:5,
            overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{e.subject}</div>
          <div style={{ display:"flex", flexDirection:"column", gap:3 }}>
            {e.atts.map((a,i)=>{
              const key = e.id+":"+i, on = sel.indexOf(key)>=0, got = taken.indexOf(a.name)>=0;
              return (
                <button key={key} disabled={got} onClick={()=>onToggle(key)}
                  style={{ all:"unset", boxSizing:"border-box", display:"flex", alignItems:"center", gap:6, width:"100%",
                    padding:"4px 6px", borderRadius:"var(--radius-sm)", cursor:got?"default":"pointer", fontFamily:"var(--font-sans)",
                    border:"1px solid "+(on?"var(--brand)":"var(--border)"), background:on?"var(--brand-muted)":"transparent", opacity:got?0.55:1 }}>
                  <span style={{ width:12, height:12, flexShrink:0, borderRadius:2, display:"flex", alignItems:"center", justifyContent:"center",
                    border:"1px solid "+(on?"var(--brand)":"var(--border-strong)"), background:on?"var(--brand)":"var(--card)",
                    color:"#fff", fontSize:9, lineHeight:1 }}>{on?"✓":got?"✓":""}</span>
                  <span style={{ fontSize:"var(--text-caption)", fontWeight:600, minWidth:0, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{a.name}</span>
                  <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)", flexShrink:0 }}>{a.kind}</span>
                  <span className="mono" style={{ marginLeft:"auto", fontSize:"var(--text-caption)", color:"var(--foreground-subtle)", flexShrink:0 }}>{got?"受領済":a.size}</span>
                </button>
              );
            })}
          </div>
        </div>
      ))}
    </div>
  );
}

/* 書類の状態の語：受領／下書き／確定(プレビューの見出しが読む。表の色は TodoDetailParts.jsx TD_FILE_STAGE) */
const SC_STAGE = { received:["受領","survey"], draft:["下書き","warn"], issued:["確定","success"] };
/* このTODOの書類の表は TodoDetailParts.jsx の TdFileTable(K18-28。以前の ScFileStrip は表に置き換えた)。 */
/* 発行の記録（K-7・決-8・0084）：いつ・誰に・何を発行したか／いつ確定にしたか。
   帳票の中身（PDF）はまるろうOSでは作らない ─ ここは記録の一覧だけ。
   保存先は doc_issues（rpc_list_doc_issues で案件を開いたときに取り直す）。 */
function ScDocIssues({ rows }) {
  const list = Array.isArray(rows) ? rows : [];
  if (!list.length) return null;
  const shown = list.slice(0, 6);
  return (
    <div style={{ marginTop:10, paddingTop:9, borderTop:"1px solid var(--border)" }}>
      <div style={{ display:"flex", alignItems:"center", gap:8, marginBottom:5 }}>
        <span style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.04em", color:"var(--muted-foreground)" }}>発行の記録</span>
        <span className="mono" style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>{list.length}</span>
      </div>
      <div style={{ display:"flex", flexDirection:"column" }}>
        {shown.map(d=>(
          <div key={d.id} style={{ display:"flex", alignItems:"center", gap:8, padding:"5px 2px", borderBottom:"1px solid var(--border)" }}>
            <ScChip tone={d.action==="finalized"?"success":"survey"}>{d.action==="finalized"?"確定":"発行"}</ScChip>
            <span style={{ fontSize:"var(--text-body-sm)", fontWeight:600, minWidth:0, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{d.kind||"書類"}</span>
            {d.to && <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)", minWidth:0, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{"→ "+d.to}</span>}
            <span className="mono" style={{ marginLeft:"auto", fontSize:"var(--text-caption)", color:"var(--foreground-subtle)", flexShrink:0 }}>{d.at||""}</span>
            {d.by && <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)", flexShrink:0 }}>{d.by}</span>}
            {!d.dbId && <span style={{ fontSize:"var(--text-caption)", color:"var(--warning)", flexShrink:0 }}>保存中</span>}
          </div>
        ))}
      </div>
      {list.length>shown.length && (
        <div style={{ marginTop:4, fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>
          {"ほか "+(list.length-shown.length)+" 件（送付はシステム外です）"}
        </div>
      )}
    </div>
  );
}
/* プレビュー：本文つきは読める形で、画像はそのまま */
function ScFileView({ file, onClose, onSave }) {
  const [body, setBody] = React.useState(file.docBody||"");
  const img = (file.mime||"").startsWith("image") && file.url;
  const printIt = () => {
    if (file.url) { const w = window.open(file.url, "_blank", "noopener"); if (w) w.focus(); return; }
    window.print();
  };
  return (
    <ScModal width="var(--modal-w-lg)" title={file.name} sub={(SC_STAGE[file.stage]||["—"])[0]+"・"+file.date+((file.sharedTo||[]).length?"・共有 "+file.sharedTo.join("・"):"")} onClose={onClose}
      footer={<React.Fragment>
        <AfButton onClick={onClose}>閉じる</AfButton>
        {(file.url || file.docBody) && <AfButton onClick={printIt}>{file.url ? "別タブで開く／印刷" : "印刷"}</AfButton>}
        {file.docBody && <AfButton variant="primary" onClick={()=>onSave(file.id, body)}>保存</AfButton>}
      </React.Fragment>}>
      {img && <img src={file.url} alt={file.name} style={{ maxWidth:"100%", borderRadius:"var(--radius-md)", border:"1px solid var(--border)" }} />}
      {file.docBody && (
        <textarea value={body} onChange={e=>setBody(e.target.value)}
          style={{ width:"100%", boxSizing:"border-box", minHeight:220, padding:10, borderRadius:"var(--radius-md)", border:"1px solid var(--input)",
            fontSize:"var(--text-body-md)", lineHeight:1.8, fontFamily:"var(--font-sans)", resize:"vertical", color:"var(--foreground)", background:"var(--card)" }} />
      )}
      {!img && !file.docBody && <div style={{ fontSize:"var(--text-body-md)", color:"var(--foreground-subtle)" }}>{file.url ? "この形式はプレビューできません（ダウンロードして確認）" : "受領記録のみ（実ファイルなし）"}</div>}
    </ScModal>
  );
}

/* 活動履歴(相手のピル＋左に一覧・右に端末画面)は ActivityDevice.jsx の ScActivity(K18-29。TODO詳細・案件詳細で共用)。 */

/* レール：このTODOの工程に枠とラベルを出す */
function ScRail({ rail, mark }) {
  return (
    <div style={{ display:"flex", alignItems:"flex-start", gap:2, flexWrap:"wrap", marginTop:9 }}>
      {rail.map(([n,st,date],i)=>{
        const done=st==="done", now=st==="now", wait=st==="waiting", skip=st==="skip", hot=now||wait;
        const col = done?"var(--success)" : now?"var(--brand)" : wait?"var(--warning)" : "var(--border-strong)";
        const isMark = mark===n;
        return (
          <React.Fragment key={i}>
            <div style={{ display:"flex", flexDirection:"column", alignItems:"center", gap:3, minWidth:hot||isMark?58:44,
              padding:isMark?"4px 5px 3px":"4px 1px 3px", borderRadius:"var(--radius-sm)",
              background:isMark?"var(--brand-muted)":"transparent", border:"1px solid "+(isMark?"var(--brand)":"transparent") }}>
              <span style={{ width:hot?12:8, height:hot?12:8, borderRadius:999, boxSizing:"border-box",
                background: done||now||wait ? col : "var(--card)", border:"2px solid "+col,
                display:"inline-flex", alignItems:"center", justifyContent:"center" }}>
                {done && <span style={{ fontSize:6, color:"#fff", fontWeight:700, lineHeight:1 }}>✓</span>}
              </span>
              <span style={{ fontSize:"var(--text-caption)", fontWeight:hot||isMark?700:400, whiteSpace:"nowrap", textAlign:"center", lineHeight:1.3,
                textDecoration:skip?"line-through":"none",
                color: isMark?"var(--brand)" : done?"var(--muted-foreground)" : now?"var(--foreground)" : wait?"var(--warning)" : "var(--foreground-subtle)" }}>{n}</span>
              <span className="mono" style={{ fontSize:9, whiteSpace:"nowrap",
                color: isMark?"var(--brand)" : done?"var(--success)" : now?"var(--brand)" : wait?"var(--warning)" : "transparent" }}>{isMark?"このTODO":(date||"·")}</span>
            </div>
            {i<rail.length-1 && <span style={{ height:2, flex:"1 0 6px", maxWidth:18, borderRadius:1, marginTop:hot?10:8,
              background: done?"var(--success-border)":"var(--border)" }} />}
          </React.Fragment>
        );
      })}
    </div>
  );
}
function ScBlock({ title, note, action, children, style }) {
  return (
    <AfCard pad={0} style={style}>
      <div style={{ display:"flex", alignItems:"center", gap:8, padding:"9px 14px", borderBottom:"1px solid var(--border)", background:"var(--accent)" }}>
        <span style={{ fontSize:"var(--text-body-md)", fontWeight:700 }}>{title}</span>
        {note && <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>{note}</span>}
        {action && <span style={{ marginLeft:"auto" }}>{action}</span>}
      </div>
      <div style={{ padding:"12px 14px 13px" }}>{children}</div>
    </AfCard>
  );
}
function TodoDetail({ todo, queue, onBack, onOpenCase, onContact, onToast, onNext, onOpenTrack }) {
  const store = window.useMStore ? window.useMStore() : null;
  const [done, setDone] = React.useState(false);
  const [memo, setMemo] = React.useState("");
  const [saving, setSaving] = React.useState(false);   /* K18-07: 完了の保存を見届けている間 */
  const [log, setLog] = React.useState([]);
  const [tab, setTab] = React.useState("すべて");
  const [selId, setSelId] = React.useState(null);
  const [inSel, setInSel] = React.useState([]);
  React.useEffect(()=>{ setDone(false); setMemo(""); setSaving(false); setLog([]); setTab("すべて"); setSelId(null); setPane(null); setShare(false); setInSel([]); }, [todo]);
  /* 連絡履歴(0027)はフックなので早期returnの前で呼ぶ(Reactのフックのルール)。 */
  const caseDbId = todo && window.MStore ? ((window.MStore.case(todo.caseId)||{}).dbId || null) : null;
  const comms = useCaseComms(caseDbId);
  const [detailContext, setDetailContext] = React.useState({ status:"idle" });
  // H1(9/25): 案件をサーバの値で差し替えたら(store.jsx caseRev)、開いたままでも関係者を取り直す。
  const caseRev = todo && window.MStore && window.MStore.caseRev ? window.MStore.caseRev(todo.caseId) : 0;
  React.useEffect(() => {
    if (!caseDbId || !window.MStore || !window.MStore.ensureCaseDetailContext) return;
    let alive = true;
    setDetailContext({ status:"loading" });
    window.MStore.ensureCaseDetailContext(todo.caseId, caseDbId).then(() => alive && setDetailContext({ status:"ready" }))
      .catch(error => alive && setDetailContext({ status:"error", error }));
    return () => { alive = false; };
  }, [todo && todo.caseId, caseDbId, caseRev]);
  if(!todo) return null;
  const st = window.bvDueState ? window.bvDueState(todo.due) : "future";
  const dueCol = st==="over"?"var(--destructive)" : st==="today"?"var(--warning)" : st==="soon"?"var(--status-survey)" : "var(--muted-foreground)";
  const dueLabel = window.bvDueLabel ? window.bvDueLabel(todo.due) : todo.due;
  const idx = queue ? queue.findIndex(x=>x===todo) : -1;
  const next = queue && idx>=0 ? queue[idx+1] : null;
  const trk = scTrack(todo);
  const K = trk && window.TR_KINDS ? window.TR_KINDS[trk.kind] : null;
  const mine = todo.ball==="自社";
  /* K18-20(0112): このトラックの先方担当(業者側/保険会社側)。案件の個別取得(detailContext)が届いてから読む。
     案件の関係者(parties)とは別に持つ(TrackContacts.jsx 冒頭)。表示名は対象者と同じくカッコ書きを落とす。 */
  const trkDbId = todo.trkDbId || (window.trackDbIdOf ? window.trackDbIdOf(todo.caseId, todo.trkId) : null);
  const tcRaw = detailContext.status==="ready" && window.trackContactPrimary && window.trackContactsOf
    ? window.trackContactPrimary(window.trackContactsOf(todo.caseId, trkDbId), trk && trk.kind) : null;
  const tContact = tcRaw && scNorm(tcRaw.name) ? { ...tcRaw, name: scNorm(tcRaw.name) } : null;
  const targets = scTargets(todo, trk, tContact);
  /* この進行(trk)ぶんの連絡履歴。track_id 無し(案件全体の連絡)も見せる。 */
  const logs = (comms.rows||[]).filter(r=>!trk || !r.trackId || r.trackId===trk.dbId).map(commRowToLog);
  /* 送った直後の楽観表示(log)と、0027の読み出し(logs)が両方載ると同じ連絡が二重に見える
     (案件詳細-19のpartyEntriesと同じやり方でd+tを突き合わせて避ける。案件詳細-3)。 */
  const seenComm = new Set(logs.map(x=>x.d+x.t));
  /* K18-29: 工程の自動完了(「○○ を完了」)は連絡ではないので履歴に入れない(工程はレールで見る)。 */
  const history = [
    ...log.filter(l=>!seenComm.has(l.d+l.t)),
    ...logs,
  ];
  /* 連絡の宛先：タブ選択＞督促先＞トラックの先方担当(K18-20)＞自社以外のボール＞先頭の対象者。実在しなければ null（連絡不可）。
     先方担当は、ボールが自社のときか、ボールの相手が担当の側と同じとき(保険会社/業者)だけ先に出す
     (規則は TrackContacts.jsx deviceDefaultWho。部品が無いときは今までの優先順)。 */
  const who = window.deviceDefaultWho
    ? window.deviceDefaultWho({ tab, dunning: todo.dunning, ball: todo.ball, contact: tContact, targets })
    : (tab!=="すべて" ? tab : (todo.dunning || (todo.ball!=="自社" ? todo.ball : null) || (targets[0] && targets[0].name) || null));
  const micro = React.useMemo(()=>scMicro(todo, trk, who, comms), [todo, trk, who, store, comms.rows, comms.err]);
  const [share, setShare] = React.useState(false);   /* K18-28: 共有のモーダル(ScShareModal) */
  const [pane, setPane] = React.useState(null);      /* K18-28: 受領・書類作成のモーダル(receive / doc) */
  const [view, setView] = React.useState(null);
  const [doc, setDoc] = React.useState(false);
  const cs = window.MStore ? window.MStore.case(todo.caseId) : null;
  /* 共有先候補：報告先(reportTo)を関係者(parties)のactorIdと突き合わせる。
     actorIdが無い枠はここで選べなくする(scShareCandidates参照)。 */
  const shareCands = scShareCandidates(cs && cs.parties, micro.need, micro.shared);
  const contextBlocked = detailContext.status !== "ready";
  const SC_ACT_LABEL = { receive:"受け取る", doc:(micro.kindOf||"書類")+"を作る", share:"共有する", contact:"連絡する", step:"工程を消込む" };
  const scActLabel = x => x.act==="contact" && /回答|相手の回答/.test(x.t) ? "催促する" : SC_ACT_LABEL[x.act];
  const microAct = i => {
    /* 決-8: 帳票の本文は組まない(廃止済み)。ここで例外を握りつぶすと押しても
       無反応になる(9/6 claude6 発見)ので、失敗したら黙らずトーストで見せる。 */
    try {
      const a = micro.steps[i] && micro.steps[i].act;
      /* 共有(share)は宛先の actorId を**選ぶ画面**なので、関係者が揃う前に
         開くと選べない枠を並べてしまう ─ 今までどおり断る。
         連絡(contact)はここで断らない。モーダルは開き、記録する直前に
         persistContact が到着を待つ(上限8秒)。混んでいるだけの時に
         「連絡する ›」が押せないままになるのを避ける。 */
      if (contextBlocked && a === "share") {
        onToast && onToast(detailContext.status === "error" ? "関係者を取得できないため、この操作はできません。再試行してください。" : "関係者を読み込み中です。完了するまでお待ちください。");
        return;
      }
      if(a==="receive"){ setPane("receive"); return; }
      if(a==="doc"){ setPane("doc"); return; }
      if(a==="share"){
        /* K18-28: 共有はモーダル(ScShareModal)。宛先は actorId のある関係者だけを選べ、
           初期選択にも actorId の無い枠を含めない(scShareCandidates・0044 以前の壊れ方を作らない)。 */
        setShare(true); return;
      }
      if(a==="contact"){ send("電話"); return; }
      if(a==="step"){ if(trk && onOpenTrack) onOpenTrack(trk); else onOpenCase && onOpenCase(todo); return; }
    } catch(e) {
      console.error("microAct failed:", e);
      onToast && onToast("操作に失敗しました("+(e && e.message || "不明なエラー")+")");
    }
  };
  /* K18-28: 受領はアップロードで行う(受信箱の取り込みは本番ではまだ空・scInboxFor)。接続先(Supabase)があれば、
     案件詳細の「＋ファイルを追加」・工程消込の受領(CaseDetailV25.jsx addFiles / stepReceive)と同じ uploadAttachment で
     Storage に実体を置いてから files[] に足す ─ 書き込み翻訳(mstore-diff.js diffFileAttachments)が attachment.add
     (trackId・stepId 付き)に直して attachments に残す。以前は URL.createObjectURL の見た目だけで、保存されず
     「まだ保存できません」になり、再読込で消えていた。工程の TODO なら今の工程(track_steps.id)にも結び付ける。
     接続先の無い開発モードは今までどおり画面にだけ置き、保存されないと言う。
     戻り値 { ok, message } は受領のモーダル(TdReceiveModal)が読む ─ 全部失敗したらモーダルを閉じずに理由を出す。 */
  const receive = async list => {
    if(!list.length || !window.MStore) return { ok:false, message:"ファイルが選ばれていません。" };
    const kind = micro.kindOf || "その他";
    const tid = todo.todoId || todo.id;
    const row = ((cs && cs.todos) || []).find(t => t && t.id === tid);
    const stepId = row && row.railStep && row.dbId ? row.dbId : null;
    const typeOf = f => (f.type||"").startsWith("image") ? "写真" : kind;
    const where = trk ? trk.id : "案件";
    const cloud = window.MarurouCloud;
    if (cloud && typeof cloud.uploadAttachment === "function" && caseDbId) {
      const results = await Promise.all(list.map(f => cloud.uploadAttachment(f, { caseId: caseDbId })
        .then(meta => ({ f, meta }), err => ({ f, err }))));
      const added = results.filter(r => r.meta).map((r,k) => ({
        id:"ATT-"+Date.now()+"-"+k+"-"+Math.random().toString(36).slice(2,7), dbId:null,
        name:r.meta.fileName || r.f.name || "", date:scTodayMd(), trkId:micro.trkId, stepId, todoId:tid, stage:"received",
        type:typeOf(r.f), note:"受領", storagePath:r.meta.storagePath || null, mime:r.meta.mime || null,
        sizeBytes:r.meta.sizeBytes == null ? null : Number(r.meta.sizeBytes) }));
      const failed = results.filter(r => r.err);
      const why = failed.length ? failed.map(r => r.f.name).join("・")+" を添付できませんでした（"+((failed[0].err && failed[0].err.message) || "通信エラー")+"）" : "";
      if (!added.length) return { ok:false, message: why || "受領できませんでした。" };
      window.MStore.updateCase(todo.caseId, n => { n.files = [...(n.files || []), ...added]; });
      onToast && onToast(added.length+"件を受領しました（"+where+"）"+(why ? "。"+why : ""));
      return { ok:true };
    }
    const added = list.map((f,k)=>({ id:"R"+Date.now()+k, name:f.name, date:scTodayMd(), trkId:micro.trkId, todoId:tid, stage:"received",
      type:typeOf(f), note:"受領", url:URL.createObjectURL(f), mime:f.type }));
    window.MStore.updateCase(todo.caseId, n => { n.files = [...n.files, ...added]; });
    onToast && onToast(added.length+"件を受領しました（"+where+"・開発モード：保存はされません）");
    return { ok:true };
  };
  const inbox = React.useMemo(()=>scInboxFor(todo, trk), [todo, trk]);
  const takenNames = (cs && cs.files || []).map(f=>f.name);
  const receiveInbox = () => {
    if(!inSel.length || !window.MStore) return;
    const kind = micro.kindOf || "その他";
    const picked = inSel.map(k=>{ const [eid,i] = k.split(":"); const e = inbox.find(x=>x.id===eid); return e ? { e, a:e.atts[+i] } : null; }).filter(Boolean);
    const added = picked.map((p,k)=>({ id:"R"+Date.now()+k, name:p.a.name, date:scTodayMd(), trkId:micro.trkId, todoId:(todo.todoId||todo.id), stage:"received",
      type:p.a.kind==="写真" ? "写真" : (p.a.kind||kind), note:"受領（"+(SC_CH_LABEL[p.e.ch]||p.e.ch)+"／"+p.e.who+"）",
      src:p.e.who+"・"+(SC_CH_LABEL[p.e.ch]||p.e.ch)+" "+p.e.d+" "+p.e.time }));
    window.MStore.updateCase(todo.caseId, n => { n.files = [...n.files, ...added]; });
    setLog(l=>[{ id:"R"+Date.now(), d:scTodayMd(), dir:"in", who:picked[0].e.who, t:(SC_CH_LABEL[picked[0].e.ch])+"：添付"+added.length+"件を受領" }, ...l]);
    setInSel([]); setPane(null);
    onToast && onToast(added.length+"件を受領しました（"+picked[0].e.who+"／"+(SC_CH_LABEL[picked[0].e.ch])+"）");
  };
  const issueDoc = d2 => {
    setDoc(false); setPane(null);
    // 決-8: 報告書・見積・請求書はここで作らない。残すのは「いつ・誰に・何を発行したか」の記録だけ。
    // 本文(docBody)は持たない ─ 送付は人がシステム外で行う。発行=送付と読める文言にしない。
    window.MStore.updateCase(todo.caseId, n => {
      n.files = [...n.files, { id:"D"+Date.now(), name:d2.name, date:scTodayMd(), trkId:micro.trkId, todoId:(todo.todoId||todo.id),
        stage:d2.advance?"issued":"draft", type:micro.kindOf||"その他", note:d2.advance?"発行":"下書き", link:d2.to }];
    });
    /* K-7(決-8・0084): 発行そのものは doc_issues に残す。これまでは上の files[] に
       積むだけで、実体(storagePath)を持たないため mstore-diff.js が保存できず、
       リロードで消えていた。下書き(advance:false)はまだ発行していないので記録しない
       ─ 記録が要るのは「発行した」「確定にした」の2つだけ(0084【設計判断1】)。
       宛先IDは名簿(shareCands)に同名の候補が居るときだけ付ける。名前だけで
       推測はしない(同名の別人に付く事故を避ける・0084【設計判断3】)。 */
    if (d2.advance && window.MStore.addDocIssue) {
      const cand = d2.to ? shareCands.find(c => c && c.name === d2.to && c.actorId) : null;
      window.MStore.addDocIssue(todo.caseId, {
        kind: d2.name || micro.kindOf || "書類",
        action: "issued",
        to: d2.to || null,
        actorId: cand ? cand.actorId : null,
        trkId: micro.trkId || null,
      });
    }
    onToast && onToast(d2.name+(d2.advance?"の発行を記録しました（送付はシステム外です）":"の下書きを記録しました"));
  };
  /* actorIds: 選ばれた宛先の case_actors.id 配列(scShareCandidates が用意した候補からしか
     選べないので、ここに来る時点で全員が実在するIDを持つ)。names は表示用にそのまま残す。 */
  const sendShare = (fid, names, ch, actorIds) => {
    setShare(false); setPane(null);
    window.MStore.shareFile(todo.caseId, fid, names, { actorIds, channel: ch });
    setLog(l=>[{ id:"S"+Date.now(), d:scTodayMd(), dir:"out", who:names[0], t:ch+"："+(micro.kindOf||"書類")+"を共有（"+names.join("・")+"）" }, ...l]);
    onToast && onToast("共有を記録しました(送信はシステム外です)");
  };
  const actorName = todo.dunning || (todo.ball!=="自社" ? todo.ball : null) || (targets[0] && targets[0].name) || "相手";
  /* 連絡を記録する**直前に**、案件の個別取得(rpc_case_detail_context・0078)の
     到着を待つ。TODO を開いた時の先読み(下の useEffect)が済んでいれば待ち時間は0。
     CaseDetailV25.jsx の awaitStepIds(9/8 統合・工程の stepId 待ち)と同じ構え・
     同じ上限(8秒)で、理由も同じ ─ 「開いたときに1回読む」だけでは足りない。
     app/live/server.py の注記どおり同じ問い合わせが 0.9〜20.65 秒まで振れるので、
     混んでいる時だけ「開いた直後に押す」が先着してしまう。 */
  const awaitDetailContext = async () => {
    if (!contextBlocked) return true;
    if (!caseDbId || !window.MStore || !window.MStore.ensureCaseDetailContext) return false;
    const waiting = Promise.race([
      window.MStore.ensureCaseDetailContext(todo.caseId, caseDbId).then(() => true, () => false),
      /* 通信が返らないときに操作を握ったままにしない(押した本人が固まる)。 */
      new Promise(r => setTimeout(() => r(false), 8000)),
    ]);
    /* 待っている間も「送信待ち」に数えさせる(awaitStepIds と同じ理由 ─
       数えないと送信待ち表示が0で嘘になる)。 */
    const ok = await (window.MarurouWrite && window.MarurouWrite.prepare
      ? window.MarurouWrite.prepare(waiting) : waiting);
    if (ok) setDetailContext({ status: "ready" });
    return ok;
  };
  /* 案件詳細-3: 連絡は parties[].log へ残し、mstore-diff→contact.log で communications へ。
     ローカル setLog だけだと TODO 切替の useEffect で消える。楽観表示＋DB書き込みの両方。 */
  /* opts は 2 つの系統の持ち物を 1 つで運ぶ: isReport(K18-37・ContactModal の報告チェック)と、
     端末画面の入力欄の dir/subject/callResult/at/label(K18-30)。 */
  const persistContact = async (name, label, body, opts) => {
    /* 待っても届かないときは**黙って進めない**。関係者(actorId付き)が画面に
       載っていないまま記録すると、書き込み層が「prev に居ない関係者」として
       actor.add に倒し、連絡記録(contact.log)を送らない ─ 画面には
       「記録しました」と出るのに communications に残らない(9/8 実測)。
       理由は ContactModal の失敗表示(既存)に載せる。押した操作を握りつぶさず、
       できなかったことを嘘なく出す。 */
    if (!(await awaitDetailContext())) {
      throw new Error("案件の関係者を取得できないため、連絡を記録できません。");
    }
    const when = scTodayMd();
    /* K18-30: 端末画面の入力欄は 向き・件名・通話の結果・記録した時刻 も持つ(mstore-diff.js parseLogEntry が
       contact.log に写す)。label は一覧の見出し(scCommLabel と同じ式)で、読み直した行と楽観表示を突き合わせる鍵。 */
    const x = opts || {};
    const entry = { d: when, dir: x.dir === "in" ? "in" : "out", t: label + "：" + (body || "連絡") };
    if (x.subject) entry.subject = x.subject;
    if (x.callResult) entry.callResult = x.callResult;
    if (x.at) entry.at = x.at;
    if (trk && trk.dbId) entry.trackDbId = trk.dbId;
    /* K18-37(0119): 何のための連絡か(この TODO)と、報告先への報告。工程の TODO は track_steps.id、手動の TODO は
       tasks.id(scRelatedRef)。同じ案件のものかはサーバ(トリガ)が確かめる。報告チェックは相手に当たる関係者の
       報告日を進める(private._rpc_mark_reported)。 */
    Object.assign(entry, scRelatedRef(window.MStore && todo.caseId ? window.MStore.case(todo.caseId) : null, todo));
    if (x.isReport && entry.dir === "out") entry.isReport = true;
    /* K18-20: トラックの先方担当宛て。担当者は案件の関係者(case_actors)に足さない ─ 記録は会社の行
       (関係者に居なければ、今までの業者宛てと同じく会社を関係者として足す)に積み、相手の担当者は
       記録そのもの(contactDbId/orgDbId → communications.counterparty_*・mstore-diff.js parseLogEntry)に持たせる。
       会社の行の担当者(case_actors.contact_id)を相手にしない ─ LINE の宛先(0072)が別人になるため。 */
    const tc = tContact && name === tContact.name ? tContact : null;
    if (tc && tc.contactDbId) entry.contactDbId = tc.contactDbId;
    if (tc && tc.orgDbId) entry.orgDbId = tc.orgDbId;
    const host = tc ? (scNorm(tc.orgName) || name) : name;
    setLog(l => [{ id: "S" + Date.now(), d: when, dir: entry.dir, who: name, t: x.label || entry.t }, ...l]);
    if (!window.MStore || !todo.caseId) {
      onToast && onToast("連絡を画面にだけ残しました（案件へ保存できる経路がありません）");
      return;
    }
    window.MStore.updateCase(todo.caseId, c => {
      let party = (c.parties || []).find(x => x && (x.name === host || String(x.name).indexOf(host) >= 0));
      if (!party) {
        /* 相手の役割は画面が持っている一覧から引く(「関係者」で丸めると誰だったか分からなくなる)。
           先方担当宛ては会社の役割(業者/保険会社 ─ 書き込み層が relation_role に直せる語)。 */
        const role = tc ? (tc.side === "保険会社" ? "保険会社" : "業者")
          : ((targets.find(x => x.name === name) || {}).role || "関係者");
        party = { role, name: host, wait: null, log: [] };
        c.parties = [...(c.parties || []), party];
      }
      party.log = [entry, ...(party.log || [])];
      party.wait = null;
    });
    /* 書き込みキューが走ったあと communications を読み直す。すぐ再読すると空のままなので少し待つ。
       K18-30: 「読み込み中」に戻さない読み直し(refresh)を使う ─ 戻すと活動履歴が作り直され、端末画面の相手・タブが初期に戻る。 */
    const reread = comms.refresh || comms.reload;
    if (typeof reread === "function") setTimeout(() => reread(), 800);
  };
  /* **contextBlocked では止めない。** 以前はここで黙って return しており、
     個別取得が遅い(＝機械が混んでいる)ときだけ電話・メールを押しても
     モーダルが開かず、押した本人には何も起きなかった(9/8 実測。E2E では
     「通話を記録」が90秒待ちで落ちる形で出ていた)。待つのは persistContact
     ─ 記録する直前だけにする(awaitStepIds と同じ考え方)。 */
  const send = (ch) => {
    if (!who) return;
    onContact && onContact({ name: who, ch, canReport: true, onSent: (label, body, opts) => persistContact(who, label, body, opts) });
  };
  /* 工程を触る前に、レールが track_steps と結び付く(stepId が入る)のを待つ。
     CaseDetailV25.jsx の awaitStepIds と同じ構え・同じ上限(8秒)・同じ理由 ─
     rpc_case_steps(【ページング1段目】)は案件を開いた**後**に届く。届く前に完了を
     押すと stepId が無く、書き込み層(diffSteps)が捨てる。待っている間は
     「送信待ち」に数える(prepare。数えないと送信待ち表示が0で嘘になる)。 */
  const awaitStepIds = async () => {
    if (!window.MStore || !window.MStore.ensureCaseSteps) return;
    try {
      const waiting = Promise.race([
        window.MStore.ensureCaseSteps(todo.caseId, caseDbId),
        new Promise(r => setTimeout(r, 8000)),
      ]);
      await (window.MarurouWrite && window.MarurouWrite.prepare
        ? window.MarurouWrite.prepare(waiting) : waiting);
    } catch (err) {
      console.warn("[TODO詳細] 工程の追加情報(stepId等)を取得できませんでした。", err);
    }
  };
  /* 対応メモの記録先。連絡モーダル(persistContact)と同じく parties[].log に積み、
     書き込み翻訳(mstore-diff.js parseLogEntry)が contact.log に直す。dir:"memo" は
     direction='社内メモ'(発信でも受信でもない)。相手(who)が関係者に居ればその人、
     居なければ案件の依頼者(0078 が先頭に出す '依頼者' 行)。ここでは関係者を
     **新しく作らない** ─ 作ると actor.add が伴い、役割が登録できる種類でないと
     完了ごと保存できなくなる(persistContact は連絡先そのものが目的なので作るが、
     メモは完了の付随物)。 */
  const scMemoParty = (c) => {
    const list = Array.isArray(c && c.parties) ? c.parties : [];
    /* K18-20: 相手がトラックの先方担当(担当者個人)なら、その会社の行へ(persistContact と同じ読み替え)。 */
    const key = tContact && who === tContact.name ? (scNorm(tContact.orgName) || who) : who;
    const byName = key ? list.find(x => x && x.name && (x.name === key || String(x.name).indexOf(key) >= 0)) : null;
    return byName || list.find(x => x && x.role === "依頼者" && x.name) || null;
  };
  /* K18-07: 完了の保存。TODO には 2 種類ある:
       ・レール上の工程(railStep あり・rpc_seed todo_track: kind 'step'・dbId = track_steps.id)
         → CaseDetailV25 の工程完了(stepDone)と同じ経路。トラックの steps を done にし、
           書き込み翻訳(diffSteps)が step.complete に直す。
       ・手動の TODO(railStep なし・todo_task: kind 'task'・dbId = tasks.id)
         → todos[].done(= MStore.toggleTodo と同じ変更)。書き込み翻訳が todo.toggle に直す。
     対応メモがあれば同じ update に積み、1つのバッチ(1回の rpc_apply_case_changes)で送る。
     保存が終わる(scAwaitWrite)まで画面を移らず、失敗したら赤帯を出してその場に留まる。
     サーバに残らなかった(lost)ときは画面の変更も戻す ─ 戻さないと、もう一度押しても
     差分が無く「完了にしました」と言って画面を移ってしまう(保存したふり)。
     完了の状態は引数でも受ける ─ 「完了にする」は setDone(true) の直後に呼ぶので、
     その描画の done(false)を見ると何もせず戻ってしまう(docs/21 の指摘)。 */
  const finish = async (doneNow) => {
    if (!(doneNow || done) || saving) return;
    const S = window.MStore;
    const rowId = todo.todoId || todo.id;
    const c0 = S ? S.case(todo.caseId) : null;
    const row0 = c0 ? (c0.todos || []).find(t => t.id === rowId) : null;
    if (!S || !c0 || !row0) {
      scSaveFail("このTODOを案件の中に見つけられないため、完了を保存できません。TODO一覧を開き直してください。");
      return;
    }
    const isRail = !!row0.railStep;
    const memoText = (memo || "").trim();
    const when = scTodayMd();
    setSaving(true);
    let keep = null;
    try {
      /* 下ごしらえ: 保存に要るものが揃うのを待つ。揃わなければ**書かずに止まる**。 */
      if (memoText && !(await awaitDetailContext())) {
        throw new Error("案件の関係者を取得できないため、対応メモを記録できません。メモを空にすれば完了だけ保存できます。");
      }
      if (isRail) await awaitStepIds();
      const c1 = S.case(todo.caseId) || c0;
      const ref = isRail ? scFindRailStep(c1, row0) : null;
      if (isRail && !ref) throw new Error("工程「"+row0.railStep+"」を進行の中に見つけられないため、完了を保存できません。案件を開き直してからもう一度お試しください。");
      if (isRail && !ref.step.stepId) throw new Error("工程「"+row0.railStep+"」はDBの工程行と結び付いていないため、完了を保存できません。少し待ってからもう一度お試しください。");
      if (!isRail && !row0.dbId) throw new Error("このTODOはまだサーバに登録されていないため、完了を保存できません。少し待ってからもう一度お試しください。");
      const memoParty = memoText ? scMemoParty(c1) : null;
      if (memoText && !memoParty) throw new Error("対応メモの記録先(相手か依頼者)が関係者に無いため、記録できません。メモを空にすれば完了だけ保存できます。");
      const localOnly = !window.MarurouWrite;   /* 書き込み層が無い(保存経路なし) ─ 嘘の「完了にしました」を出さない */

      /* 戻すための控え(触る所だけ)。案件全体を撮って戻すと、待っている間に届いた
         別の補充(mergeCaseSteps 等)まで巻き戻してしまう。 */
      keep = JSON.parse(JSON.stringify({
        track: ref ? { id:ref.track.id, kind:ref.kind, steps:ref.track.steps, state:ref.track.state, status:ref.track.status, stall:ref.track.stall, mk:ref.track.mk } : null,
        row: row0, rowIndex: (c1.todos || []).findIndex(t => t.id === rowId),
        party: memoParty ? { name:memoParty.name, actorId:memoParty.actorId || null, log:memoParty.log || [] } : null,
      }));
      const before = scWriteSnap();
      const entry = memoText ? { d:when, dir:"memo", t:memoText, ...(row0.trkDbId ? { trackDbId:row0.trkDbId } : {}) } : null;
      S.updateCase(todo.caseId, c => {
        const row = (c.todos || []).find(t => t.id === rowId);
        if (entry) {
          const p = scMemoParty(c);
          if (p) p.log = [entry, ...(p.log || [])];
        }
        if (isRail) {
          const r = scFindRailStep(c, row || row0);
          if (!r) return;
          const t = r.track, s = r.step;
          s.s = "done"; s.date = when;
          const nx = (t.steps || []).find(x => x && x.s !== "done" && x.s !== "skip");
          if (nx && nx.s === "todo") nx.s = "now";
          scRecalcTrack(t);
          /* TODO行はサーバ(rpc_seed todo_track)が「トラックの今の工程」を1行で返すもの。
             完了したら次の工程へ進め、工程が尽きたら行を外す(再読込後と同じ形にする)。
             todos の done 以外の変化は書き込み翻訳が op にしないので、ここで op は増えない
             (step.complete 1つだけが送られる)。 */
          if (row) {
            if (nx) {
              row.railStep = nx.n; row.dbId = nx.stepId || null; row.due = nx.plan || "—";
              row.marker = "on"; row.sev = null; row.raised = when;
              if (row.stepLabel !== undefined) row.stepLabel = nx.n;
              if (r.kind === "ins") row.step = "保険 " + nx.n;
            } else {
              c.todos = (c.todos || []).filter(t => t !== row);
            }
          }
        } else if (row) {
          row.done = true;
        }
      });
      const res = await scAwaitWrite(before);
      if (!res.ok) {
        if (res.lost) {
          /* 書き戻しは利用者の編集ではない(Payment.jsx の充当と同じ守り)。 */
          const prev = window.__MARUROU_APPLYING_REMOTE__;
          window.__MARUROU_APPLYING_REMOTE__ = true;
          try {
            S.updateCase(todo.caseId, c => {
              if (keep.track && typeof window.msTracks === "function") {
                const hit = window.msTracks(c).find(x => x && x.t && x.t.id === keep.track.id);
                if (hit) { hit.t.steps = keep.track.steps; hit.t.state = keep.track.state; hit.t.status = keep.track.status; hit.t.stall = keep.track.stall; hit.t.mk = keep.track.mk; }
              }
              const todos = c.todos || [];
              const i = todos.findIndex(t => t.id === rowId);
              if (i >= 0) todos[i] = keep.row;
              else todos.splice(Math.max(0, Math.min(keep.rowIndex, todos.length)), 0, keep.row);
              c.todos = todos;
              if (keep.party) {
                const p = (c.parties || []).find(x => x && (keep.party.actorId ? x.actorId === keep.party.actorId : x.name === keep.party.name));
                if (p) p.log = keep.party.log;
              }
            });
          } finally { window.__MARUROU_APPLYING_REMOTE__ = prev; }
        }
        throw new Error(res.reason);
      }
      onToast && onToast(localOnly
        ? "完了を画面にだけ残しました（案件へ保存できる経路がありません）"
        : todo.step+" を完了にしました"+(memoText?"・メモを活動履歴に記録":""));
      next ? onNext && onNext(next) : onBack && onBack();
    } catch (e) {
      setDone(false);
      scSaveFail((e && e.message) || "完了を保存できませんでした。");
    } finally {
      setSaving(false);
    }
  };
  /* K18-27: 1 カラムに組み直す(池田さん版 TodoDetail・仕様 03 v0.20 追記 1)。上から
       見出し(完了の操作は右)→ 進行のレール → TODO 情報(4 列の表)→ 内訳(工程の表)→ 書類 → 対応メモ → 活動履歴。
     右の「アクション」欄はやめた。K18-29/30 で連絡ボタン(電話/メール/LINE)は外し、相手とのやり取りは活動履歴の端末画面から
     記録する(記録のみ・送らない)。対応メモは残す ─ 端末画面は社内メモを記録せず、K18-07 の完了メモ(完了と同じバッチの社内メモ)を
     落とさないため(池田さん版 02 v0.32 §8 は対応メモ欄も撤去。残すかは確認待ち)。
     main にだけあるもの ─ 超過の色の期限・レールの『このTODO』の印・関係者の読み込み待ち/失敗の表示 ─ は残す。 */
  const actorRole = (targets.find(t=>t.name===actorName)||{}).role || "相手";
  const infoItems = [
    { label:"状態", value:(
      <span data-todo-state={saving ? "saving" : done ? "done" : "open"} style={{ fontWeight:600, color:done ? "var(--success)" : "var(--foreground)" }}>
        {saving ? "保存中…" : done ? "完了（未保存）" : "未完了"}
      </span>) },
    { label:"期限", value:(
      <span><b className="mono" style={{ color:dueCol }}>{todo.due}</b> <span style={{ fontSize:"var(--text-body-sm)", color:dueCol }}>{dueLabel}</span></span>) },
    { label:"種類", value: todo.btype || "—" },
    { label:"対象者", value: mine ? "自社" : (
      <span><b>{actorName}</b> <span style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)" }}>{actorRole}</span></span>) },
    { label:"ボール", value:(
      <span>{todo.ball}{todo.dunning ? "／"+todo.dunning : ""} <span style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)" }}>{mine ? "自社の手番" : "相手待ち"}</span></span>) },
    { label:"進行", value: trk ? (
      <span style={{ display:"inline-flex", alignItems:"center", gap:6, flexWrap:"wrap" }}>
        {K && <ScChip tone={K.tone}>{K.label}</ScChip>}
        <span>{trk.target}{trk.work ? "　"+trk.work : ""}</span>
        <span className="code" style={{ fontSize:"var(--text-caption)", color:"var(--brand)" }}>{trk.id}</span>
      </span>) : <span style={{ color:"var(--foreground-subtle)" }}>進行に紐づかない</span> },
    { label:"工程", value: todo.railStep || <span style={{ color:"var(--foreground-subtle)" }}>{trk ? "付随（工程ではない）" : "—"}</span> },
    { label:"関係する相手", value: targets.length===0 ? <span style={{ color:"var(--foreground-subtle)" }}>自社のみ</span> : (
      <span style={{ display:"flex", gap:5, flexWrap:"wrap" }}>
        {targets.map((tg,i)=>(
          <button key={i} onClick={()=>setTab(tg.name)} title={tg.name+" の活動履歴を見る"}
            style={{ all:"unset", boxSizing:"border-box", maxWidth:"100%", cursor:"pointer", display:"inline-flex", alignItems:"baseline", flexWrap:"wrap", gap:"0 4px",
              padding:"2px 8px", borderRadius:"var(--radius-md)",
              border:"1px solid "+(tab===tg.name?"var(--brand)":"var(--border)"), background:tab===tg.name?"var(--brand-muted)":"transparent",
              fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)", fontWeight:600, overflowWrap:"anywhere",
              color:tab===tg.name?"var(--brand)":"var(--foreground)" }}>
            {tg.name}<span style={{ fontSize:"var(--text-caption)", fontWeight:500, color:"var(--foreground-subtle)" }}>{tg.role}</span>
          </button>
        ))}
      </span>) },
  ];
  return (
    <div className="mr-page-pad" data-todo-detail="" style={{ padding:"18px 24px 32px 20px", maxWidth:1080 }}>
      <ScBack onClick={onBack} label="TODO一覧" />
      {/* 見出し: 左に 所属・相手・TODO 名・印、右に完了の操作(池田さん版はページヘッダ右の primary)。 */}
      <div data-todo-header="" style={{ display:"flex", alignItems:"flex-start", gap:"10px 16px", flexWrap:"wrap" }}>
        <div style={{ flex:"1 1 420px", minWidth:0 }}>
          <div style={{ display:"flex", alignItems:"center", gap:7, flexWrap:"wrap", fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>
            <ScChip>{todo.btype}</ScChip>
            <span className="code" style={{ color:"var(--brand)", fontWeight:600 }}>{todo.trk}</span>
            <span>·</span>
            <button onClick={()=>onOpenCase&&onOpenCase(todo)} style={{ all:"unset", fontFamily:"var(--font-sans)", cursor:"pointer", color:"var(--brand)", fontWeight:600 }}>{todo.prop} ›</button>
          </div>
          <div style={{ display:"flex", alignItems:"center", gap:9, marginTop:4, flexWrap:"wrap" }}>
            <span style={{ display:"inline-flex", alignItems:"center", gap:6, padding:"3px 11px", borderRadius:"var(--radius-full)",
              background:mine?"var(--muted)":"var(--brand-muted)", border:"1px solid "+(mine?"var(--border)":"var(--brand-border)") }}>
              <span style={{ fontSize:"var(--text-body-lg)", fontWeight:700, color:mine?"var(--muted-foreground)":"var(--brand)" }}>{mine?"自社で":actorName}</span>
              {!mine && <span style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)" }}>{actorRole}</span>}
            </span>
            <span style={{ fontSize:"var(--text-body-lg)", color:"var(--foreground-subtle)" }}>{mine?"":"に"}</span>
            <h1 style={{ margin:0, fontSize:"var(--text-display-md)", fontWeight:700, letterSpacing:"var(--tracking-tight)", textDecoration:done?"line-through":"none", color:done?"var(--muted-foreground)":"var(--foreground)" }}>{todo.step}</h1>
            <span style={{ fontSize:"var(--text-body-md)", fontWeight:600, color:"var(--muted-foreground)" }}>{todo.ball==="自社"?"（自社の手番）":"（相手待ち）"}</span>
            {todo.urgent && <ScChip tone="danger" dot>緊急</ScChip>}
            {todo.marker==="delay" && <ScChip tone="warn" dot>滞留</ScChip>}
            {todo.marker==="warn" && <ScChip tone="survey" dot>予兆</ScChip>}
            {todo.watch && <ScChip tone="warn">要注意</ScChip>}
          </div>
        </div>
        <div data-todo-finish="" style={{ display:"flex", flexDirection:"column", alignItems:"flex-end", gap:5, marginLeft:"auto" }}>
          <div style={{ display:"flex", alignItems:"center", gap:10, flexWrap:"wrap", justifyContent:"flex-end" }}>
            <button onClick={()=>setDone(!done)} style={{ all:"unset", display:"flex", alignItems:"center", gap:7, cursor:"pointer", fontFamily:"var(--font-sans)" }}>
              <span style={{ width:18, height:18, borderRadius:4, boxSizing:"border-box", flexShrink:0,
                border:"1.5px solid "+(done?"var(--success)":"var(--border-strong)"), background:done?"var(--success)":"transparent",
                display:"inline-flex", alignItems:"center", justifyContent:"center" }}>
                {done && <span style={{ fontSize:11, color:"#fff", fontWeight:700, lineHeight:1 }}>✓</span>}
              </span>
              <span style={{ fontSize:"var(--text-body-md)", fontWeight:600 }}>{done?"完了にしました":"このTODOを完了にする"}</span>
            </button>
            <AfButton variant="primary" disabled={!done || saving} onClick={()=>finish()} style={{ justifyContent:"center" }}>
              {saving ? "保存中…" : next ? "完了して次のTODOへ ›" : "完了してTODO一覧へ"}
            </AfButton>
          </div>
          {next && <div style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>次：{next.prop}・{next.step}</div>}
          {!done && <div style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>完了はトラックの工程消込と連動します。</div>}
        </div>
      </div>
      {detailContext.status!=="ready" && <div role="status" style={{ margin:"10px 0 0", padding:"9px 12px", borderRadius:8, background:"var(--surface-muted)" }}>
        {detailContext.status==="loading" ? "案件の関係者を読み込み中…" : "案件の関係者を取得できませんでした。"}
        {detailContext.status==="error" && <AfButton size="sm" style={{ marginLeft:8 }} onClick={()=>{ setDetailContext({status:"loading"}); window.MStore.ensureCaseDetailContext(todo.caseId, caseDbId).then(()=>setDetailContext({status:"ready"})).catch(error=>setDetailContext({status:"error",error})); }}>再試行</AfButton>}
      </div>}

      <div style={{ display:"flex", flexDirection:"column", gap:12, marginTop:12 }}>
        {/* ① 進行のレール(このTODOの工程に『このTODO』の印) */}
        <ScBlock title="紐づくトラック" note={!trk?null:todo.railStep?"このTODO＝この進行の1工程":"この進行に付随するTODO（工程ではない）"}
          action={trk && onOpenTrack ? <button onClick={()=>onOpenTrack(trk)} style={{ all:"unset", fontFamily:"var(--font-sans)", fontSize:"var(--text-caption)", fontWeight:600, color:"var(--brand)", cursor:"pointer" }}>進行詳細 ›</button> : null}>
          {!trk ? <div style={{ fontSize:"var(--text-body-md)", color:"var(--foreground-subtle)" }}>進行に紐づかないTODO（初動・単発対応など）。案件からは <button onClick={()=>onOpenCase&&onOpenCase(todo)} style={{ all:"unset", fontFamily:"var(--font-sans)", cursor:"pointer", color:"var(--brand)", fontWeight:600 }}>{todo.prop} ›</button> で辿れます。</div> : (
            <React.Fragment>
              <div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap", marginBottom:2 }}>
                {K && <ScChip tone={K.tone}>{K.label}</ScChip>}
                <span style={{ fontSize:"var(--text-body-md)", fontWeight:600 }}>{trk.target}{trk.work?"　"+trk.work:""}</span>
                <span style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)" }}>{trk.vendor}</span>
                <span style={{ fontSize:"var(--text-body-sm)", fontWeight:600, color:trk.mk==="delay"?"var(--warning)":"var(--brand)" }}>{trk.status}</span>
                <span style={{ marginLeft:"auto", fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)" }}>
                  {trk.settleWay} · {trk.payer} · <span className="mono">{yen(trk.billed)}</span>
                </span>
              </div>
              <ScRail rail={trk.rail} mark={todo.railStep} />
            </React.Fragment>
          )}
        </ScBlock>

        {/* ② TODO 情報(4 列の表) */}
        <ScBlock title="TODO">
          <TdInfoGrid items={infoItems} />
        </ScBlock>

        {/* ③ 内訳(このTODOを終えるまでの工程の表。操作は「今ここ」の段だけ) */}
        <ScBlock title="内訳" note="このTODOを終えるまで">
          <div style={{ fontSize:"var(--text-body-lg)", fontWeight:700, lineHeight:1.55, marginBottom:10 }}>{micro.lead}</div>
          {!micro.commsReady && !micro.commsErr && <div style={{ fontSize:10, color:"var(--foreground-subtle)", marginBottom:4 }}>連絡・催促／回答の受領を確かめる連絡履歴を読み込み中…</div>}
          {micro.commsErr && <AfAlert variant="notice" title="連絡履歴を読めませんでした">{micro.commsErr}。連絡・催促／回答の受領の判定は行えません（固定の見た目には戻していません）。</AfAlert>}
          <TdStepTable steps={micro.steps.map(x=>({ ...x, act:x.act ? scActLabel(x) : null }))} onAct={microAct} />
          {micro.allDone && !done && (
            <div style={{ display:"flex", alignItems:"center", gap:9, marginTop:9, padding:"8px 11px", borderRadius:"var(--radius-md)",
              background:"var(--success-muted)", border:"1px solid var(--success-border)" }}>
              <span style={{ fontSize:"var(--text-body-md)", fontWeight:600, color:"var(--success)" }}>必要な工程は済んでいます</span>
              <span style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)" }}>残りは完了にするだけ</span>
              <AfButton size="sm" variant="primary" style={{ marginLeft:"auto" }} disabled={saving} onClick={()=>{ setDone(true); finish(true); }}>{saving ? "保存中…" : "完了にする"}</AfButton>
            </div>
          )}
        </ScBlock>

        {/* ④ 書類(K18-28: 表にし、格納は受領のモーダルから) */}
        <ScBlock title="書類" note={micro.all.length ? micro.all.length+"件" : null}
          action={<button onClick={()=>setPane("receive")} style={{ all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)", fontSize:"var(--text-caption)", fontWeight:600, color:"var(--brand)" }}>＋ 格納</button>}>
          <TdFileTable files={micro.all} kindOf={micro.kindOf} onView={f=>setView(f)} onFinalize={f=>{
              window.MStore.updateCase(todo.caseId, n=>{ const x=n.files.find(y=>y.id===f.id); if(x){ x.stage="issued"; x.note="確定"; } });
              /* K-7(決-8・0084): 確定も doc_issues に残す(action='finalized')。
                 確定は社内の操作なので宛先はいない ─ to/actorId は付けない。 */
              if (window.MStore.addDocIssue) window.MStore.addDocIssue(todo.caseId, {
                kind: f.type || micro.kindOf || f.name || "書類", action: "finalized",
                trkId: f.trkId || micro.trkId || null });
              onToast&&onToast(f.name+" を確定にしました"); }} />
          <ScDocIssues rows={cs && cs.docIssues} />
        </ScBlock>

        {/* ⑤ 対応メモ(K18-07 の完了メモ)。連絡ボタン(電話/メール/LINE)は外した ─ 相手とのやり取りは ⑥ の端末画面(K18-29/30)から
             記録する(記録のみ・送らない)。端末画面は社内メモを記録しないので、完了のときに社内メモとして残すこの欄は残す。 */}
        <ScBlock title="対応メモ" note="完了のときに社内メモとして活動履歴に残ります">
          <textarea value={memo} onChange={e=>setMemo(e.target.value)} placeholder="やり取りの結果（完了時に社内メモとして活動履歴に残る）"
            style={{ width:"100%", boxSizing:"border-box", minHeight:64, padding:8, borderRadius:"var(--radius-md)", border:"1px solid var(--input)",
              fontSize:"var(--text-body-md)", fontFamily:"var(--font-sans)", resize:"vertical", color:"var(--foreground)", background:"var(--card)" }} />
          <div style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)", marginTop:5 }}>
            相手とのやり取りは下の活動履歴の端末画面から記録します（記録のみ・送信しません）。工程の連絡・共有は内訳の各工程のボタンから（記録が工程に紐づきます）。</div>
        </ScBlock>

        {/* ⑥ 関係者との活動履歴(左に一覧・右に相手ごとの端末画面。K18-29)。端末画面の入力欄は記録のみ(送らない・K18-30)で、
             記録は連絡モーダルと同じ persistContact の経路(関連の TODO・報告先への報告・トラックの先方担当も同じに付く)。 */}
        <ScBlock title="活動履歴" note="対象者で切替・行を押すと右の端末画面にその記録">
          {comms.err && <AfAlert variant="notice" title="連絡履歴を読めませんでした">{comms.err}</AfAlert>}
          {comms.rows===null && !comms.err && <div style={{ fontSize:"var(--text-body-md)", color:"var(--foreground-subtle)" }}>読み込み中…</div>}
          {comms.rows!==null && (
            <React.Fragment>
              <ScActivity targets={window.devAttachIds ? window.devAttachIds(targets, cs && cs.parties, tContact) : targets}
                entries={history} tab={tab} setTab={setTab} selId={selId} setSelId={setSelId}
                defaultWho={who} emptyNote={todo.trkId?null:"この進行に紐づく連絡記録はありません"} canReport
                onRecord={r => persistContact(r.who, r.ch, r.text, r)} onReload={comms.refresh || comms.reload} />
              {comms.hasMore && <AfButton size="sm" style={{ marginTop:8 }} disabled={comms.loadingMore} onClick={comms.loadMore}>
                {comms.loadingMore?"読み込み中…":"連絡履歴の続きを読む"}</AfButton>}
            </React.Fragment>
          )}
        </ScBlock>
      </div>
      {/* K18-28: 内訳の操作(受領・書類作成・共有)はモーダルで開く(仕様 03 v0.20 追記 2)。閉じるは右上の ✕。 */}
      {pane==="receive" && <TdReceiveModal kindOf={micro.kindOf} where={trk ? trk.id : "この案件"} step={todo.step}
        receivedCount={micro.all.filter(f=>f.stage==="received").length} marksStep={micro.steps.some(x=>x.act==="receive")}
        onPick={receive} onClose={()=>setPane(null)}
        inboxView={inbox.length ? (
          <div style={{ display:"flex", flexDirection:"column", gap:7 }}>
            <div style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.04em", color:"var(--muted-foreground)" }}>受信した添付</div>
            <ScInbox entries={inbox} sel={inSel} taken={takenNames} onToggle={k=>setInSel(s=>s.indexOf(k)>=0?s.filter(x=>x!==k):[...s,k])} />
            <AfButton size="sm" disabled={!inSel.length} onClick={receiveInbox}>{inSel.length ? "選択した"+inSel.length+"件を受領" : "添付を選んで受領"}</AfButton>
          </div>) : null} />}
      {pane==="doc" && <TdDocModal kindOf={micro.kindOf} who={who} step={todo.step}
        onIssue={advance=>issueDoc({ name:(micro.kindOf||"書類"), to:who||"", body:"", advance })}
        onPickRecipient={window.DocModal ? ()=>{ setPane(null); setDoc(true); } : null} onClose={()=>setPane(null)} />}
      {share && !contextBlocked && <ScShareModal need={micro.need} shared={micro.shared} parties={cs && cs.parties}
        files={[...micro.files.filter(f=>f.stage==="issued"), ...micro.files.filter(f=>f.stage!=="issued" && f.stage!=="received")]}
        onClose={()=>setShare(false)} onSend={sendShare} />}
      {doc && window.DocModal && <window.DocModal type={micro.kindOf||"報告書"} d={cs} onClose={()=>setDoc(false)} onIssue={issueDoc} />}
      {view && <ScFileView file={view} onClose={()=>setView(null)}
        onSave={(fid,body)=>{ window.MStore.updateCase(todo.caseId, n=>{ const x=n.files.find(y=>y.id===fid); if(x) x.docBody=body; }); setView(null); onToast&&onToast("保存しました"); }} />}
    </div>
  );
}

/* ===== 2. 取引先詳細 ===== */
/* K18-31: 本体は PartnerDetailV6.jsx(池田さん版の 2 カラムに組み直した・社内担当/契約/確認済みもそこ)。
   ここは呼び口の名前だけを残す(app.jsx・検索結果からの呼び方と、TODO 詳細の切り出しの目印を変えないため)。 */
function PartnerDetail(props) {
  return window.PartnerDetailV6 ? <window.PartnerDetailV6 {...props} /> : null;
}

/* ===== 5. TODO追加（手動TODO＝レール外の雑務専用） ===== */
/* H6(9/25 本番の不具合): 「相手（任意）」を、手番(ball)と督促先(dunning)に分けて保存する。
   手番(tasks.ball・ball_holder)は 8 語しか入らない列で、以前は相手の欄の文字をそのまま入れて送っていた。
   会社名を入れるとサーバが「todo の ball は次のいずれか…」で断り、DB に何も残らないのに、画面には
   「TODOを追加しました」と行が出続けた。会社名・人名は督促先(dunning・自由文 0034)に入れ、手番は種類で入れる:
     空・「自社」          … 手番 自社・督促先なし
     8 語のどれか         … 手番 その語(督促先にも同じ語 ─ 今までどおり)
     名前                … 督促先 その名前。手番は案件の中でその名前が誰か(依頼者・関係者の役割・
                            業者・保険会社・加入者)から決める。案件の中に居なければ「業者」
                            (見本の文字「依頼先・業者など」のうち、依頼先は依頼者の名前で当たるため)
   guessed は「案件の中に居ないので業者とした」印(登録前に画面へ出す)。 */
const SC_BALLS = ["自社","業者","依頼者","居住者","原因者","加入者","保険会社","鑑定会社"];
const SC_ROLE_BALL = { 依頼者:"依頼者", 管理会社:"依頼者", オーナー:"依頼者", 施工会社:"業者", 見積候補:"業者", 見積採用:"業者",
  居住者:"居住者", 原因者:"原因者", 加入者:"加入者", 保険:"保険会社", 保険会社:"保険会社", 鑑定会社:"鑑定会社" };
function scTodoCounterparty(raw, cs) {
  const to = String(raw == null ? "" : raw).replace(/[\s　]+/g, " ").trim();
  if (!to || to === "自社") return { ball:"自社", dunning:null, guessed:false };
  if (SC_BALLS.indexOf(to) >= 0) return { ball:to, dunning:to, guessed:false };
  const key = s => String(s == null ? "" : s).replace(/[\s　]/g, "");
  const same = n => !!n && n !== "—" && key(n) === key(to);
  let ball = null;
  if (cs) {
    if (same(cs.client)) ball = "依頼者";
    if (!ball) { const p = (cs.parties || []).find(x => x && SC_ROLE_BALL[x.role] && same(x.name)); if (p) ball = SC_ROLE_BALL[p.role]; }
    if (!ball && [...(cs.works || []), ...(cs.visits || [])].some(t => t && (same(t.contractor) || same(t.vendor)))) ball = "業者";
    const ins = !cs.ins ? [] : (Array.isArray(cs.ins) ? cs.ins : [cs.ins]);
    if (!ball && ins.some(t => t && same(t.name))) ball = "保険会社";
    if (!ball && ins.some(t => t && same(t.holder))) ball = "加入者";
  }
  return { ball: ball || "業者", dunning: to, guessed: !ball };
}
function TaskAddModal({ defaultCase, onClose, onSave }) {
  const [title, setTitle] = React.useState("");
  const [btype, setBtype] = React.useState("その他");
  const me = window.MStore && window.MStore.me ? window.MStore.me() : null;
  const users = ((window.MStore && window.MStore.masters) ? window.MStore.masters().users : []) || [];
  const meId = me && me.id;
  const meName = (me && (me.displayName || me.name)) || "";
  // K18-49: 期限は年月日の選択(FormParts.jsx MrDatePicker)。値は店の表記(M/D、年がずれる日だけ YYYY/M/D)。
  // 初期値は開いた日。以前は未定義の scTodayYmd を呼んでいて、「＋ TODO追加」を押すと画面全体が落ちていた。
  const [due, setDue] = React.useState(scTodayMd);
  const [to, setTo] = React.useState("");
  const [owner, setOwner] = React.useState(meName);
  const [ownerDbId, setOwnerDbId] = React.useState(() => {
    if (meId && users.some(u => u.id === meId)) return meId;
    const hit = users.find(u => u.name === meName);
    return hit ? hit.id : "";
  });
  const storeNames = ((window.MStore && window.MStore.get && window.MStore.get().cases) || []).map(c => c.name).filter(Boolean);
  const cases = [...new Set([...storeNames, ...(window.BV_ROWS||[]).map(r=>r.prop)])];
  const [caseName, setCaseName] = React.useState(defaultCase && cases.indexOf(defaultCase) >= 0 ? defaultCase : (cases[0] || ""));
  const known = cases.indexOf(caseName) >= 0;
  const ownerName = () => { const hit = users.find(u => u.id === ownerDbId); return (hit && hit.name) || owner; };
  /* H6: 相手の欄から、保存する手番・督促先を決める(scTodoCounterparty)。登録前に画面へ出す。 */
  const selCase = ((window.MStore && window.MStore.get && window.MStore.get().cases) || []).find(c => c.name === caseName) || null;
  const cp = scTodoCounterparty(to, selCase);
  return (
    <ScModal title="TODO追加" onClose={onClose}
      footer={<React.Fragment>
        <AfButton onClick={onClose}>キャンセル</AfButton>
        <AfButton variant="primary" disabled={!known} onClick={()=>known && onSave&&onSave({ title:title||"（無題のTODO）", btype, due, to, ball: cp.ball, dunning: cp.dunning, owner: ownerName(), ownerDbId: ownerDbId || null, caseName })}>登録</AfButton>
      </React.Fragment>}>
      <div>
        <div style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.06em", color:"var(--muted-foreground)", marginBottom:5 }}>TODO内容</div>
        <AfInput value={title} onChange={e=>setTitle(e.target.value)} placeholder="例：施工前の近隣挨拶手配" style={{ width:"100%" }} />
      </div>
      <div>
        <div style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.06em", color:"var(--muted-foreground)", marginBottom:5 }}>種類</div>
        {/* K18-49: 種類はラジオ(池田さん版の Choice type="radio" と同じ見た目・FormParts.jsx MrRadioGroup) */}
        <MrRadioGroup name="mr-task-add-btype" label="種類" value={btype} onChange={setBtype} options={SC_TABS} />
      </div>
      <div className="mr-stack-1" style={{ display:"grid", gridTemplateColumns:"1fr 1fr", gap:12 }}>
        <div>
          <div style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.06em", color:"var(--muted-foreground)", marginBottom:5 }}>対応期限</div>
          <MrDatePicker label="対応期限" value={due} onChange={setDue} required width="100%" />
        </div>
        <div>
          <div style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.06em", color:"var(--muted-foreground)", marginBottom:5 }}>担当</div>
          {users.length
            ? <select aria-label="担当" value={ownerDbId} onChange={e=>{ const id=e.target.value; setOwnerDbId(id); const hit=users.find(u=>u.id===id); setOwner(hit?hit.name:""); }}
                style={{ width:"100%", boxSizing:"border-box", height:32, padding:"0 8px", borderRadius:"var(--radius-md)", border:"1px solid var(--input)",
                  fontFamily:"var(--font-sans)", fontSize:"var(--text-body-md)", color:"var(--foreground)", background:"var(--card)" }}>
                <option value="">案件の本担当のまま</option>
                {users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
              </select>
            : <AfInput value={owner} onChange={e=>setOwner(e.target.value)} placeholder="名簿が無いときは名前のみ（保存できません）" style={{ width:"100%" }} />}
        </div>
      </div>
      <div className="mr-stack-1" style={{ display:"grid", gridTemplateColumns:"1fr 1fr", gap:12 }}>
        <div>
          <div style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.06em", color:"var(--muted-foreground)", marginBottom:5 }}>案件</div>
          {cases.length
            ? <select aria-label="案件" value={caseName} onChange={e=>setCaseName(e.target.value)}
                style={{ width:"100%", boxSizing:"border-box", height:32, padding:"0 8px", borderRadius:"var(--radius-md)", border:"1px solid var(--input)",
                  fontFamily:"var(--font-sans)", fontSize:"var(--text-body-md)", color:"var(--foreground)", background:"var(--card)" }}>
                {cases.map(n => <option key={n} value={n}>{n}</option>)}
              </select>
            : <AfAlert variant="notice" title="案件がありません">先に受付から案件を登録してください。</AfAlert>}
        </div>
        <div>
          <div style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.06em", color:"var(--muted-foreground)", marginBottom:5 }}>相手（任意）</div>
          <AfInput value={to} onChange={e=>setTo(e.target.value)} placeholder="依頼先・業者など" style={{ width:"100%" }} />
          {cp.dunning && cp.dunning !== cp.ball && (
            <div data-todo-to-hint="" style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)", marginTop:4, lineHeight:1.6 }}>
              督促先「{cp.dunning}」・手番「{cp.ball}」で登録します{cp.guessed ? "（この案件の関係者に見つからないため業者として扱います）" : ""}
            </div>
          )}
        </div>
      </div>
      <AfAlert variant="info" title="手動TODO">レール（進行）の工程はここでは起票しません。工程の消込は案件詳細の進行から行います。案件名は一覧から選びます（手入力では登録しません）。</AfAlert>
    </ScModal>
  );
}

/* ===== 6. 連絡モーダル ===== */
function ContactModal({ target, onClose, onToast }) {
  const keys = Object.keys(SC_CH);
  const init = keys.find(k=>SC_CH[k].label===(target&&target.ch)) || "call";
  const [ch, setCh] = React.useState(init);
  const [body, setBody] = React.useState("");
  const [pending, setPending] = React.useState(false);
  const [error, setError] = React.useState("");
  // 通知・連携-4(Slack分・0064)。channel=Slackのときだけ出す確認チェック(既定オフ)。
  // DB側のトリガ(private._notify_on_contact_log)はchannel=Slack・direction=発信の行を
  // 無条件に拾って通知・連携-5の差込口(private._notify)へ渡す設計 ─ communicationsに
  // 「送るかどうか」を持つ列は増やしていない(rpc_apply_case_changesを触らない方針。
  // docs/15-notify.md参照)。このチェックは値を送るためのものではなく、「Slackを選ぶと
  // 保存後に実際にSlackへ投稿されうる」ことを押す前に確認させるゲート
  // (既定オフ+チェックするまで「連絡を記録」を押せない)。
  const [sendSlack, setSendSlack] = React.useState(false);
  // K18-37(0119): 報告先への報告(既定オフ)。呼び出し側が canReport を渡したとき(案件の関係者への連絡)だけ出す。
  // チェックすると、相手に当たる案件の関係者の前回報告日が進む(DB 側 private._rpc_mark_reported)。
  const [report, setReport] = React.useState(false);
  const canReport = !!(target && target.canReport);
  // 通知・連携-4(LINE分・0072・決-33)。Slack分と同じ形の確認チェックだが、
  // LINEはさらに「池田さんのトークンがまだ届いていない」という現実の状態があるため、
  // notify_settings.line_enabledを画面側でも見て、無効な間はチェック自体を出さず
  // 「未接続」と正直に表示する(送れないものにチェックだけ出すと誤解させる)。
  // rpc_line_status()は秘密(webhook_url等)を一切返さない最小のbooleanだけの
  // 公開RPC(0072_line_messaging.sql参照)。
  const [sendLine, setSendLine] = React.useState(false);
  const [lineStatus, setLineStatus] = React.useState(null); // null=未取得
  const isLine = ch === "line";
  React.useEffect(() => {
    if (!isLine || lineStatus) return;
    let alive = true;
    const run = window.MarurouCloud ? window.MarurouCloud.lineStatus() : Promise.resolve(null);
    run.then(r => { if (alive) setLineStatus(r || { lineEnabled: false }); })
       .catch(() => { if (alive) setLineStatus({ lineEnabled: false }); });
    return () => { alive = false; };
  }, [isLine, lineStatus]);
  const lineEnabled = !!(lineStatus && lineStatus.lineEnabled);
  const meta = SC_CH[ch];
  const name = (target&&target.name)||"相手";
  const isSlack = ch === "slack";
  const blocked = (isSlack && !sendSlack) || (isLine && lineEnabled && !sendLine);
  return (
    <ScModal title="連絡" sub={name} onClose={onClose}
      footer={<React.Fragment>
        <AfButton onClick={onClose}>閉じる</AfButton>
        <AfButton variant="primary" disabled={blocked || pending} onClick={async()=>{
          if (pending) return;
          setPending(true); setError("");
          try {
            await Promise.resolve(target&&target.onSent&&target.onSent(meta.label, meta.kind==="call"?"通話":body, { isReport: canReport && report }));
            onToast&&onToast(name+" への"+meta.label+"を記録しました（実際の送信・発信はまだできません）");
            onClose&&onClose();
          } catch (e) {
            setError((e && e.message) || "記録に失敗しました。もう一度お試しください。");
          } finally { setPending(false); }
        }}>
          {pending ? "記録中…" : (meta.kind==="call"?"通話を記録":"連絡を記録")}
        </AfButton>
      </React.Fragment>}>
      <div style={{ display:"flex", gap:5, flexWrap:"wrap" }}>
        {keys.map(k=>(
          <button key={k} onClick={()=>setCh(k)} style={{ all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)",
            fontSize:"var(--text-body-sm)", fontWeight:ch===k?700:500, padding:"5px 12px", borderRadius:"var(--radius-full)",
            border:"1px solid "+(ch===k?"var(--brand)":"var(--border)"),
            background:ch===k?"var(--brand-muted)":"var(--card)", color:ch===k?"var(--brand)":"var(--muted-foreground)" }}>{SC_CH[k].label}</button>
        ))}
      </div>
      {meta.kind==="text"
        ? <div>
            <div style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.06em", color:"var(--muted-foreground)", marginBottom:5 }}>本文</div>
            <textarea value={body} onChange={e=>setBody(e.target.value)} placeholder={meta.label+"の文面（送信はしません）"}
              style={{ width:"100%", minHeight:72, padding:8, borderRadius:"var(--radius-md)", border:"1px solid var(--input)", fontSize:"var(--text-body-md)", fontFamily:"var(--font-sans)", resize:"vertical", color:"var(--foreground)", background:"var(--card)" }} />
          </div>
        : <AfAlert variant="info" title="発信">{meta.hint}</AfAlert>}
      {error && (() => {
        const SaveFail = window.AfSaveFail;
        if (SaveFail) return <SaveFail reason={error} onRetry={() => setError("")} onDismiss={() => setError("")} title="記録できませんでした" />;
        return <AfAlert variant="error" title="記録できません">{error}</AfAlert>;
      })()}
      {canReport && (
        <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={e=>setReport(e.target.checked)} />
          報告先への報告（相手の前回報告日を今日にする）
        </label>
      )}
      {isSlack && (
        <label style={{ display:"flex", alignItems:"center", gap:6, fontSize:"var(--text-body-sm)", color:"var(--foreground)", cursor:"pointer" }}>
          <input type="checkbox" checked={sendSlack} onChange={e=>setSendSlack(e.target.checked)} />
          Slack にも送る
        </label>
      )}
      {isLine && (
        lineEnabled
          ? <label style={{ display:"flex", alignItems:"center", gap:6, fontSize:"var(--text-body-sm)", color:"var(--foreground)", cursor:"pointer" }}>
              <input type="checkbox" checked={sendLine} onChange={e=>setSendLine(e.target.checked)} />
              LINE で送る
            </label>
          : <AfAlert variant="info" title="未接続">未接続(池田さんのトークン待ち)。届き次第つながるので、いまは送れません。</AfAlert>
      )}
      <div style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>
        {isSlack
          ? "この連絡はSlackへの実送信対象です。保存はDB側の差込口(notify_settings)経由で行われ、有効化されるまでは実際には届きません。"
          : isLine
          ? (lineEnabled
              ? "この連絡はLINE公式アカウントからの実送信対象です。相手のLINEが紐づいていない場合は届きません。"
              : "LINEの実送信はまだ有効化されていません(池田さんのチャネルアクセストークン待ち)。この記録はこの案件に残るだけで、相手には届きません。")
          : "相手への送信はまだありません。残るのはこの案件の連絡記録だけです。"}
      </div>
    </ScModal>
  );
}

/* ===== 7. 検索結果（グローバル1窓・種類別） ===== */
function SearchResults({ q, onOpenCase, onOpenTodo, onOpenPartner, clear }) {
  const rows = window.BV_ROWS || [];
  const meta = window.BV_CASEMETA || {};
  // 案件は **案件そのもの**から引く。盤面の行(BV_ROWS)を経由すると
  // TODOが1件も無い案件が検索に出てこない ─ 実データで673件中23件が該当し、
  // しかも直近に受け付けた「調査調整中」「相談中」の案件だった。
  const localCases = (window.MStore ? window.MStore.searchCases(q) : [])
    .map(x=>({ id:x.id, dbId:x.dbId, prop:x.name, m:meta[x.id] || { client:x.client, owner:x.owner, stage:x.stage } }));
  // ページング2段目(決-1・0033)。いま読み込んでいる直近N件には無い、
  // もっと古い案件も検索で引けるようにする(サーバ側 rpc_case_search)。
  // これが無いと「古い案件が探せない」という新しい不便を作る。
  const [remoteCases, setRemoteCases] = React.useState([]);
  React.useEffect(() => {
    let alive = true;
    const run = window.MStore ? window.MStore.searchCasesRemote(q) : Promise.resolve([]);
    run.then(r => { if (alive) setRemoteCases(r || []); })
       .catch(() => { if (alive) setRemoteCases([]); });
    return () => { alive = false; };
  }, [q]);
  const [partners, setPartners] = React.useState([]);
  React.useEffect(() => {
    let alive = true;
    const run = window.pvFetch
      ? window.pvFetch(q, "すべて", 20)
      : Promise.resolve({ rows: [] });
    run.then(r => { if (alive) setPartners((r && r.rows) || []); })
       .catch(() => { if (alive) setPartners([]); });
    return () => { alive = false; };
  }, [q]);
  // ㉒: 取引先名・電話で当たった名簿から、関連案件を辿って案件欄へ足す
  // (rpc_partner_case_ids)。案件番号検索は上の rpc_case_search 側。
  const [partnerCases, setPartnerCases] = React.useState([]);
  React.useEffect(() => {
    let alive = true;
    const cloud = window.MarurouCloud;
    if (!cloud || typeof cloud.listPartnerCaseIds !== "function" || !partners.length) {
      setPartnerCases([]);
      return undefined;
    }
    const top = partners.filter(p => p && p.id).slice(0, 5);
    Promise.all(top.map(p => cloud.listPartnerCaseIds(p.id).then(rows => ({ partner: p.name, rows: rows || [] }))
      .catch(() => ({ partner: p.name, rows: [] }))))
      .then(chunks => {
        if (!alive) return;
        const out = [];
        const seen = new Set();
        chunks.forEach(ch => {
          (ch.rows || []).forEach(r => {
            const dbId = r && (r.dbId || r.id);
            if (!dbId || seen.has(dbId)) return;
            seen.add(dbId);
            out.push({
              id: r.caseNo || dbId, dbId, prop: r.name || r.title || dbId,
              m: { client: ch.partner, owner: "—", stage: null },
              viaPartner: true,
            });
          });
        });
        setPartnerCases(out);
      });
    return () => { alive = false; };
  }, [partners]);
  // 同じ案件が両方に出ることがある(直近N件の中にも一致した場合)。dbId で弾く。
  // 取れていない件は判定材料が無いのでそのまま出す(見えなくするより安全)。
  const localDbIds = new Set(localCases.map(x => x.dbId).filter(Boolean));
  const remoteMapped = remoteCases.filter(x => !x.dbId || !localDbIds.has(x.dbId))
    .map(x => ({ id:x.id, dbId:x.dbId, prop:x.prop,
      m:{ client:x.client, owner:x.owner, stage:null }, remote:true }));
  const known = new Set([...localDbIds, ...remoteMapped.map(x => x.dbId).filter(Boolean)]);
  const viaPartners = partnerCases.filter(x => !x.dbId || !known.has(x.dbId));
  const cases = [...localCases, ...remoteMapped, ...viaPartners];
  const todos = rows.filter(r=> [r.step, r.prop, r.btype, r.trk, r.dunning||""].join(" ").includes(q));
  const total = cases.length+todos.length+partners.length;
  const Sec = ({ label, n, children }) => (
    <div style={{ marginBottom:12 }}>
      <div style={{ fontSize:"var(--text-caption)", fontWeight:700, letterSpacing:"0.04em", color:"var(--muted-foreground)", marginBottom:5 }}>{label} <span className="mono">{n}</span></div>
      <AfCard pad={0} style={{ overflow:"hidden" }}>{children}</AfCard>
    </div>
  );
  const Row = ({ onClick, children }) => (
    <button onClick={onClick} style={{ all:"unset", boxSizing:"border-box", width:"100%", display:"flex", alignItems:"center", gap:8,
      padding:"9px 13px", cursor:"pointer", fontFamily:"var(--font-sans)", borderBottom:"1px solid var(--border)", flexWrap:"wrap",
      transition:"background var(--duration-fast) var(--easing)" }}
      onMouseEnter={e=>e.currentTarget.style.background="var(--accent)"}
      onMouseLeave={e=>e.currentTarget.style.background="transparent"}>{children}</button>
  );
  return (
    <div className="mr-page-pad" style={{ padding:"18px 24px 32px 20px", maxWidth:900 }}>
      <div style={{ display:"flex", alignItems:"baseline", gap:10, marginBottom:12, flexWrap:"wrap" }}>
        <h1 style={{ margin:0, fontSize:"var(--text-display-sm)", fontWeight:700 }}>検索結果</h1>
        <span style={{ fontSize:"var(--text-body-md)", color:"var(--muted-foreground)" }}>「{q}」<span className="mono">{total}</span>件（案件番号・取引先名・電話からも辿れます）</span>
        <button onClick={clear} style={{ all:"unset", marginLeft:"auto", fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)", fontWeight:600, color:"var(--brand)", cursor:"pointer" }}>✕ 検索を閉じる</button>
      </div>
      {total===0 && <AfCard style={{ padding:"24px 14px", textAlign:"center", fontSize:"var(--text-body-md)", color:"var(--foreground-subtle)" }}>該当なし</AfCard>}
      {cases.length>0 && (
        <Sec label="案件" n={cases.length}>
          {cases.map(x=>(
            <Row key={(x.viaPartner?"p:":"")+(x.dbId || x.id)} onClick={()=>onOpenCase&&onOpenCase(x)}>
              <span style={{ fontSize:"var(--text-body-md)", fontWeight:600 }}>{x.prop}</span>
              {x.viaPartner
                ? <ScChip tone="neutral">取引先から</ScChip>
                : x.remote
                ? <ScChip tone="neutral">古い案件</ScChip>
                : <ScChip tone={x.m.stage==="工事"?"brand":x.m.stage==="調査"?"survey":x.m.stage==="完了"?"success":"neutral"}>{x.m.stage||"受付"}</ScChip>}
              <span style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)" }}>{x.m.client}・{x.m.owner}</span>
              <span className="code" style={{ marginLeft:"auto", fontSize:"var(--text-body-sm)", color:"var(--brand)" }}>{x.id}</span>
            </Row>
          ))}
        </Sec>
      )}
      {todos.length>0 && (
        <Sec label="TODO" n={todos.length}>
          {todos.map((t,i)=>(
            <Row key={i} onClick={()=>onOpenTodo&&onOpenTodo(t)}>
              {t.urgent && <ScChip tone="danger" dot>緊急</ScChip>}
              {t.marker==="delay" && <ScChip tone="warn" dot>滞留</ScChip>}
              <span style={{ fontSize:"var(--text-body-md)" }}>{t.step}</span>
              <ScChip>{t.btype}</ScChip>
              <span style={{ fontSize:"var(--text-body-sm)", color:"var(--brand)" }}>{t.prop}</span>
              <span className="mono" style={{ marginLeft:"auto", fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)" }}>{t.due}</span>
            </Row>
          ))}
        </Sec>
      )}
      {partners.length>0 && (
        <Sec label="取引先" n={partners.length}>
          {partners.map(p=>(
            <Row key={p.id||p.name} onClick={()=>onOpenPartner&&onOpenPartner(p)}>
              <span style={{ fontSize:"var(--text-body-md)", fontWeight:600 }}>{p.name}</span>
              <ScChip>{p.type}</ScChip>
              <span style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)" }}>{p.kind}・案件 <span className="mono">{p.cases}</span>{(p.tel||p.phone) ? "・"+ (p.tel||p.phone) : ""}</span>
            </Row>
          ))}
        </Sec>
      )}
    </div>
  );
}

Object.assign(window, { TodoDetail, PartnerDetail, TaskAddModal, ContactModal, SearchResults, ScChip, ScModal, ScActivity, ScRawView, scNorm, useCaseComms, commRowToLog, scShareCandidates, ScShareModal, scRelatedRef,
  // K18-23: TODO一覧の行から完了(BoardV4.jsx bvCompleteRow)が、TODO詳細の finish と同じ道具を使う。
  scFindRailStep, scRecalcTrack, scWriteSnap, scAwaitWrite, scSaveFail });
// H6: TODO追加の相手(任意)を手番・督促先に分ける(app.jsx の onSave も同じ規則を使う)。
Object.assign(window, { scTodoCounterparty });
