// CaseDetailV25 — 案件詳細 v25構成（トップタブ＋フェーズツリー＋明細）
// タブ=[概要][TODO][ファイル][関係者][収支]・概要=フェーズ親行（✓✓◉＋保険併走）＋トラック子行（実務/精算/方法）
// 子行▸で工程レール＋明細を展開・方針タブは「方法」列に吸収。P0編集（消込・インライン編集・添付）とP1（追加・帳票・完了）は維持。

/* 案件データは store（MStore）が単一の真実。この画面は caseId で1件を読み書きする */
const dTodayMd = () => { const d = new Date(); return `${d.getMonth()+1}/${d.getDate()}`; };
const dMkSteps = names => names.map((n,i)=>({ n, s:i===0?"now":"todo", date:null, plan:null }));
const D_EMPTY = { id:"—", name:"—", type:"—", order:"—", accepted:"2026/01/01", owner:"—", client:"—", initial:"—", mode:"—",
  spots:[], surveyTasks:[], visits:[], works:[], ins:null, files:[], parties:[], todos:[], closed:false, building:null };
// 受付-12: 建物住所の未入力プレースホルダ(migration/transform/t12_places.sql・0042_address_norm.sql)。
// buildings.address は NOT NULL 制約のため、住所が読めなかった建物にはこの文字列がそのまま入っている
// (実データ3,033件中340件・9/6実測)。画面の「未登録」と同じ意味なので、生の値のまま出さずに揃える。
const D_ADDR_UNKNOWN = "(住所未登録)";
const dBuildingName = d => (d.building && d.building.name) || "未登録";
const dBuildingAddr = d => (d.building && d.building.address && d.building.address !== D_ADDR_UNKNOWN) ? d.building.address : "未登録";
// 精算-7: 精算方法(tracks.settlement_type)の選択肢。Admin.jsxのSETTLEMENTSと同じ語
// (settlement_type enumそのもの)。rpc_seed(0062)が detail.method へ返す値も同じ文字列
// なので対応表は無い(app/write/mstore-diff.js SETTLE_DETAIL.method参照)。
const D_SETTLEMENTS = ["自費精算", "保険精算", "工事内包", "自社負担", "未定"];
/* TODO → どのパネルを開くか（trkId から解決） */
const dPanelKey = (d, trkId) => {
  if(!trkId) return null;
  const vi = d.visits.findIndex(x=>x.id===trkId); if(vi>=0) return "v"+vi;
  const wi = d.works.findIndex(x=>x.id===trkId); if(wi>=0) return "w"+wi;
  if(dInsList(d).some(i=>i.id===trkId)) return "ins";
  return null;
};
const dDueTone = due => { const st = window.bvDueState ? window.bvDueState(due) : "future"; return st==="over"?"warn" : st==="today"?"warn" : st==="soon"?"soon" : "far"; };
const dDueText = due => (window.bvDueLabel ? window.bvDueLabel(due) : due);

/* 導出 */
/* H2(9/25): 工程(steps)が「無い」トラックで落ちない・完了にしない。
   区分・精算方式・進め方に合う工程の型(route_templates)が無いまま作られた工事は工程 0 本になり、
   読み込み(rpc_seed 0110)はその steps を [] ではなく **null** で返す。t.steps を直に読むと
   案件詳細の描画ごと落ちて、ほかの端末では案件が真っ白になっていた(S2-B2・S3-B1)。
   工程はここを通して読む(null・欠け・配列でない値は「工程が無い」= [])。 */
const dSteps = t => (t && Array.isArray(t.steps)) ? t.steps : [];
/* 工程が 1 つも無い。「完了」でも「打ち切り」でもない(やることが決まっていない)。 */
const dNoSteps = t => dSteps(t).length===0;
/* レールを走り切った = 未完の工程が無い。打ち切りも「もう進まない」ので含める。
   工程が 1 つも無いのは走り切ったのではない(空の every は true になるので除く)。 */
const dAllDone = t => { const s = dSteps(t); return s.length>0 && s.every(x=>x.s==="done"||x.s==="skip"); };
/* ただし **1つも完了していない**(全部打ち切り)のは「完了」ではない。
   失注・適用外で止まったトラックで、実データに 1,701本ある。
   これを「完了」と出すと、やっていない仕事が終わったことになる。 */
const dAllSkipped = t => { const s = dSteps(t); return s.length>0 && s.every(x=>x.s==="skip"); };
const dOpen = t => dSteps(t).find(s=>s.s!=="done"&&s.s!=="skip");
/* 工程の無いトラックの表示(進行表の実務列・状態)。 */
const D_NO_STEPS = "工程なし";
/* 精算にあたる工程は **fact_key で引く。名前で探さない。**
   経路によって名前が割れる ─ 自費は 請求/入金、保険は 充当、内包は 内包先へ計上、
   調査に至っては精算工程がレールに1つも無い。
   /請求/ の名前一致で探していたため、請求工程を持たない 7,822本 で
   findIndex が -1 になり、実務列の走査範囲がレール全長に化けていた。
   実測: invoice_issued 2,719 / payment_received 2,719 / insurance_paid 1,758 /
        insurance_allocated 1,065 / included_billing 27(fact_key は80,689行すべて埋まっている) */
const D_BILL_KEYS = ["invoice_issued"];
const D_PAID_KEYS = ["payment_received","insurance_paid","insurance_allocated","included_billing"];
const D_MONEY_KEYS = D_BILL_KEYS.concat(D_PAID_KEYS);
const dStepBy = (t, keys) => dSteps(t).find(s => keys.indexOf(s.factKey) >= 0);
/* 工程 → 帳票/受領物。ここも **fact_key で決める。**
   名前で書くと、経路ごとに割れた名前を取りこぼす/誤って拾う。実際に起きていたこと:
     ・docType が ctxStep.n==="請求" の完全一致で、請求工程を持たない経路では
       請求書のボタンが DOM に出なかった
     ・receiveLabel の /申請/ が **工事の「保険申請」(1,065本)に誤ヒット**し、
       「申請書類を受領して完了」という保険用のUIを工事に生やしていた
       (保険申請=insurance_filed は自社が出す側。申請書類提出=claim_documents とは別) */
const D_DOC_BY_FACT = { survey_reported:"報告書", work_reported:"報告書",
                        survey_quoted:"見積書", work_quoted:"見積書",
                        invoice_issued:"請求書" };
const D_RECEIVE_BY_FACT = { survey_ordered:"発注書", approval_external:"発注書",
                            survey_reported:"完了報告書・写真", work_reported:"完了報告書・写真",
                            claim_documents:"申請書類", insurance_assessed:"認定通知" };
/* 実務列の終わり = 精算工程が始まる手前。精算工程が無ければレール全体が実務 */
const dBillIdx = t => { const i = dSteps(t).findIndex(s => D_MONEY_KEYS.indexOf(s.factKey) >= 0);
                        return i<0 ? dSteps(t).length : i; };
function dRecalc(t){
  // 工程が無いトラックは「完了」にしない(H2)。進める工程が無いので「工程なし」とだけ出す。
  if(dNoSteps(t)){ t.state = "wait"; t.status = D_NO_STEPS; t.mk=""; t.stall=false; return t; }
  const o = dOpen(t);
  if(!o){ const s = dAllSkipped(t);
          t.state = s ? "closed" : "done"; t.status = s ? "打ち切り" : "完了";
          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;
}
// 保険は複数ありうる(実データで115案件)。調査・工事と同じく配列で扱う。
// 池田さんのモックは単数で持っているので、どちらの形も受ける。
const dInsList = d => !d || !d.ins ? [] : (Array.isArray(d.ins) ? d.ins : [d.ins]);
const dTrack = (d,kind,idx) => kind==="ins" ? dInsList(d)[idx||0] : kind==="work" ? d.works[idx] : d.visits[idx];
/* ── トラックの状態(K18-06・0092/0096/0098) ──
   rpc_seed(0098)は各トラック(visit/work/ins)に trackStatus(進行中|保留|終了)/ outcome / onHold /
   holdReason / resumePlannedOn / endReason / endedOn を載せる(0092 の列と同じ語彙。日付は YYYY-MM-DD)。
   保留・終了のときだけ帯を出し、行をグレーにする。保存はこの 7 キーを書き換えるだけ(act.trackState)─
   mstore-diff.js の diffTrackState が track.hold / track.end / track.resume に翻訳する(必須が欠ければ送らず赤帯)。
   SF フェーズ(t.status・legacy_phase)は列として残し、新しい状態が無い行だけ表示に使う(dStatusText)。 */
const D_TSTATE_OUTCOMES = ["完了","不要","失注","適用外"];
const D_TSTATE_REASON_REQUIRED = ["失注","適用外"];
/* 保留・終了のときだけ状態の組 { status, outcome, holdReason, resumePlannedOn, endReason, endedOn } を返す。 */
const dTstate = t => (t && (t.trackStatus==="保留" || t.trackStatus==="終了"))
  ? { status:t.trackStatus, outcome:t.outcome||null, holdReason:t.holdReason||null,
      resumePlannedOn:t.resumePlannedOn||null, endReason:t.endReason||null, endedOn:t.endedOn||null }
  : null;
const dIsoMd = iso => (iso && /^\d{4}-\d{2}-\d{2}$/.test(iso)) ? iso.slice(5).replace("-","/") : (iso || "");
const dIsoYmd = iso => (iso && /^\d{4}-\d{2}-\d{2}$/.test(iso)) ? iso.replace(/-/g,"/") : (iso || "");
const dTstateLabel = ts => !ts ? null : ts.status==="保留" ? "保留" : "終了・"+(ts.outcome||"");
/* 帯の横の短い補足。保留=再開予定日 / 終了=理由(無ければ終了日)。 */
const dTstateNote = ts => !ts ? "" : ts.status==="保留"
  ? (ts.resumePlannedOn ? "再開 "+dIsoMd(ts.resumePlannedOn) : "")
  : (ts.endReason || (ts.endedOn ? dIsoMd(ts.endedOn) : ""));
/* 状態の表示文字。新しい状態があればそれを、無ければ従来の t.status(SF フェーズ/工程からの導出)。 */
const dStatusText = t => { const ts = dTstate(t); return ts ? dTstateLabel(ts) : ((t && t.status) || "—"); };
const dTodayIso = () => { const d = new Date(); return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0"); };
/* 画面から書く 7 キー(rpc_seed 0098 と同じ形)。onHold はビューの導出値だが、盤面(store.jsx boardRows)が
   すぐ同じ規則で外せるように、保留にした瞬間は「再開予定日が今日以降」で自前に出す。 */
const dTstateKeys = ts => {
  if (!ts) return { trackStatus:"進行中", outcome:null, onHold:false, holdReason:null, resumePlannedOn:null, endReason:null, endedOn:null };
  if (ts.status === "保留") return { trackStatus:"保留", outcome:null, onHold: !!ts.resumePlannedOn && ts.resumePlannedOn >= dTodayIso(),
    holdReason:ts.holdReason||null, resumePlannedOn:ts.resumePlannedOn||null, endReason:null, endedOn:null };
  return { trackStatus:"終了", outcome:ts.outcome||null, onHold:false, holdReason:null, resumePlannedOn:null,
    endReason:ts.endReason||null, endedOn:ts.endedOn||null };
};
/* 実務列：請求より前の工程から現在地を導く */
function dOps(t){
  // 工程が無い(H2)。「完了」と出さない ─ 足した端末で工程 [] の工事が「完了」と出ていた。
  if(dNoSteps(t)) return { text:D_NO_STEPS, tone:"var(--warning)", noSteps:true };
  const b = dBillIdx(t), range = dSteps(t).slice(0,b);
  const o = range.find(s=>s.s!=="done"&&s.s!=="skip");
  if(!o && dAllSkipped(t)) return { text:"打ち切り", tone:"var(--muted-foreground)", done:true };
  if(!o){ const last = [...range].reverse().find(s=>s.s==="done");
          // 移行で入った完了は日付を出さない(全工程が同一時刻のため)ので、
          // ここは大半が空になる。"完了 " と末尾に空白が残らないようにする。
          const dt = last && last.date; return { text: dt ? "完了 "+dt : "完了", tone:"var(--success)", done:true }; }
  if(o.s==="waiting") return { text:o.n+" "+(o.date||""), tone:"var(--warning)", wait:true };
  return { text:o.n+"中 "+(o.date||""), tone:"var(--brand)" };
}
/* 精算列：請求→入金の工程から状態を導く */
function dPay(t){
  const bill = dStepBy(t, D_BILL_KEYS), paid = dStepBy(t, D_PAID_KEYS);
  const d = t.detail || {};
  if(paid && paid.s==="done") return { text:"入金済 "+(paid.date||""), tone:"var(--success)" };
  if(bill && bill.s==="done") return { text:"入金待ち（"+(d.due||(paid&&paid.date)||"—")+"）", tone:"var(--warning)" };
  /* レールに精算工程が無い経路(調査4,972本など)。金額は精算明細にあるので、そちらを出す。
     ここを見ていなかったため、未完了案件の **492本・¥44,266,250** が
     (うち144本は入金済なのに)一律「未請求」と出ていた。
     detail.paid は「入金済」という文字列(paid_total>0のとき)で、金額でも日付でもない。 */
  if(!bill && !paid){
    if(d.paid)   return { text:d.paid+(d.billed?" "+yen(d.billed):""), tone:"var(--success)" };
    if(d.billed) return { text:"請求 "+yen(d.billed)+"（"+(d.due||"—")+"）", tone:"var(--warning)" };
  }
  if(d.billed) return { text:"未請求 "+yen(d.billed), tone:"var(--foreground-subtle)" };
  return { text:"未", tone:"var(--foreground-subtle)" };
}
function dStageInfo(d){
  const v0 = d.visits[0];
  const rep = v0 ? dSteps(v0).find(s=>s.n==="報告") : null;
  const surveyDone = !!rep && rep.s==="done";
  const worksDone = d.works.length>0 && d.works.every(dAllDone);
  const allDone = worksDone && d.visits.every(dAllDone);
  return { surveyDate: surveyDone?rep.date:null, surveyDone, worksDone, allDone,
    stall: d.works.some(w=>w.stall) || dInsList(d).some(i=>i.stall) };
}
const D_STATE = { done:{label:"完了",c:"var(--success)"}, now:{label:"進行中",c:"var(--brand)"}, wait:{label:"着手待ち",c:"var(--foreground-subtle)"} };
const dChipBase = { 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 D_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)"],
  // ⑫: ダッシュボード「遅れている」と同じ赤(決-20)。
  danger:["var(--destructive)","var(--destructive-muted)","var(--destructive-border)"],
  survey:["var(--status-survey)","var(--status-survey-muted)","var(--status-survey-border)"],
  work:["var(--status-progress)","var(--status-progress-muted)","var(--status-progress-border)"],
  // K18-21: 見出しの段階チップ「受付」。盤面(BoardV4 BV_STAGE_TONE)と同じ色。
  intake:["var(--status-intake)","var(--status-intake-muted)","var(--status-intake-border)"],
  neutral:["var(--muted-foreground)","var(--muted)","var(--border)"],
};
function DChip({ tone="neutral", dot, children, style }) {
  const T = D_TONES[tone]||D_TONES.neutral;
  return <span style={{ ...dChipBase, color:T[0], background:T[1], border:"1px solid "+T[2], ...style }}>{dot && <span style={{ fontSize:7 }}>●</span>}{children}</span>;
}
const DMark = ({ mk }) => mk==="delay" ? <DChip tone="warn" dot>滞留</DChip> : mk==="warn" ? <DChip tone="survey" dot>期限注意</DChip> : null;
/* ⑫: ダッシュボード「遅れている」(DashboardV6 SLA_REASON_LABELS)と同じ文言・色。 */
const D_SLA_REASON_LABELS = {
  plan:   { delay:"予定日 超過",     warn:"予定日 まもなく" },
  report: { delay:"報告リズム 超過", warn:"報告リズム そろそろ" },
};
function DSlaBadge({ st }) {
  if(!st || !st.marker) return null;
  const isDelay = st.marker === "delay";
  const label = (D_SLA_REASON_LABELS[st.reasonKind] || D_SLA_REASON_LABELS.plan)[isDelay ? "delay" : "warn"];
  const day = st.overDays == null ? null
    : st.overDays >= 0 ? st.overDays + "日超過" : "あと" + (-st.overDays) + "日";
  return (
    <span style={{ display:"inline-flex", alignItems:"center", gap:6, flexWrap:"wrap" }} data-d-sla-badge>
      <DChip tone={isDelay ? "danger" : "warn"} dot>{label}</DChip>
      {day && <span className="mono" style={{ fontSize:"var(--text-caption)", fontWeight:700, color:isDelay?"var(--destructive)":"var(--warning)" }}>{day}</span>}
    </span>
  );
}
/* K18-14(0107/0108): 被害側の居住制限(3 値)。選ぶとその場で spots[].livingRestriction が変わり、書き込み層が
   spots.update の living_restriction に翻訳する(mstore-diff.js spotToPayload)。is_livable は DB が導く。
   「生活できず避難」(住めない・進め方 P1 の判定値)は赤。進め方が空の保険精算の復旧工事には DB が P1 を入れ、
   入っている進め方は変えない(K18-16・0111)。その工事には DApproachHint が「P1 が目安」を出す。 */
function DLivingRestriction({ value, room, onChange }) {
  const opts = window.LIVING_RESTRICTION_OPTIONS || [];
  const v = value || "";
  const unlivable = v === (window.LIVING_UNLIVABLE || "生活できず避難");
  return (
    <span data-spot-living={v} style={{ display:"inline-flex", alignItems:"center", gap:4, fontSize:"var(--text-caption)",
      fontWeight:unlivable?700:400, color:unlivable?"var(--destructive)":"var(--foreground-subtle)" }}>
      居住
      <select aria-label={`居住制限 ${room}`} value={v} onChange={e=>onChange(e.target.value)}
        title={unlivable ? "住めない。進め方は P1（先行工事）が目安です（進め方が空の保険精算の復旧工事には自動で P1。入っている進め方は担当が決めます）" : undefined}
        style={{ height:22, padding:"0 4px", borderRadius:"var(--radius-md)", borderWidth:1, borderStyle:"solid",
          borderColor:unlivable?"var(--destructive)":"var(--input)", fontSize:"var(--text-caption)", fontFamily:"var(--font-sans)",
          fontWeight:unlivable?700:400, color:unlivable?"var(--destructive)":(v?"var(--foreground)":"var(--foreground-subtle)"),
          background:"var(--card)" }}>
        <option value="">未設定</option>
        {opts.map(o => <option key={o} value={o}>{o}</option>)}
        {v && opts.indexOf(v) < 0 && <option value={v}>{v}</option>}
      </select>
      {unlivable && <DChip tone="danger" dot style={{ fontWeight:700 }}>生活できず避難</DChip>}
    </span>
  );
}
/* K18-16(0111): 住めない部屋がある案件の、保険精算の復旧工事の進め方。判定は store.jsx unlivableApproachHint。
   DB は進め方が空なら P1 を入れ、入っている進め方(規定・手入力)は変えないので、P1 以外(か seed に進め方が無く
   分からない)ときは「P1 が目安」の注意を出して人が決める。切り替えの操作はここには無い(工程の引き直しは K18-73)。 */
function DApproachHint({ c, w }) {
  const h = window.unlivableApproachHint ? window.unlivableApproachHint(c, w) : null;
  if(!h) return null;
  const label = a => (window.afApproachLabel ? window.afApproachLabel(a) : a);
  const rooms = h.rooms.join("・");
  return (
    <DDef label="進め方">
      <span data-approach-hint={h.needsAttention ? "warn" : "ok"}
        style={{ display:"inline-flex", alignItems:"center", gap:6, flexWrap:"wrap", fontSize:"var(--text-body-sm)" }}>
        {h.approach && <b style={{ color:"var(--foreground)" }}>{label(h.approach)}</b>}
        {h.needsAttention
          ? <>
              <DChip tone="danger" dot style={{ fontWeight:700 }}>住めない部屋あり</DChip>
              <span style={{ color:"var(--destructive)", fontWeight:600 }}>
                {h.approach
                  ? `住めない部屋（${rooms}）があるので、進め方は P1（先行工事）が目安です。入っている進め方は自動では切り替えません。`
                  : `住めない部屋（${rooms}）があるので、進め方は P1（先行工事）が目安です。進め方が空なら自動で P1 が入り、入っていれば自動では切り替えません。`}
              </span>
            </>
          : <DChip tone="danger">住めない部屋（{rooms}）あり・P1</DChip>}
      </span>
    </DDef>
  );
}
/* K18-15(0107/0108): 原因側の利用制限(複数選択 6 値)。選んだ値をチップで出し、「選ぶ」で 6 つの
   チェックボックスを開く。切り替えは store.jsx toggleUsageRestriction(「制限なし」と制限ありは同時に持たない ─
   DB のトリガと同じ規則)。変更は spots[].usageRestrictions → spots.update の usage_restrictions。
   inline(K18-22 の原因/被害の表): 表は横にはみ出すと横スクロールの枠に入るので、浮かせた選択肢は枠で
   切れる。表の中では選択肢をその場(セルの中)に開く。 */
function DUsageRestrictions({ value, room, onChange, inline }) {
  const [open, setOpen] = React.useState(false);
  const opts = window.USAGE_RESTRICTION_OPTIONS || [];
  const cur = Array.isArray(value) ? value : [];
  const toggle = v => onChange(window.toggleUsageRestriction ? window.toggleUsageRestriction(cur, v) : cur);
  return (
    <span data-spot-usage={cur.join(",")} style={{ position:"relative", display:"inline-flex", alignItems:"center", gap:4,
      flexWrap:"wrap", fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>
      利用制限
      {cur.length === 0 && <span>—</span>}
      {cur.map(v => <DChip key={v} tone={v==="制限なし" ? "neutral" : "warn"}>{v}</DChip>)}
      <button type="button" aria-expanded={open} aria-label={`利用制限を選ぶ ${room}`} onClick={()=>setOpen(o=>!o)}
        style={{ all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)", fontWeight:600, color:"var(--brand)" }}>{open ? "閉じる" : "選ぶ"}</button>
      {open && (
        <span role="group" aria-label={`利用制限 ${room}`} style={{ ...(inline
            ? { flexBasis:"100%", marginTop:2 }
            : { position:"absolute", top:"100%", left:0, zIndex:30, marginTop:4, boxShadow:"0 6px 18px rgba(0,0,0,0.12)" }),
          padding:"8px 10px", display:"flex", flexDirection:"column", gap:5, background:"var(--card)", color:"var(--foreground)",
          border:"1px solid var(--border)", borderRadius:"var(--radius-md)", whiteSpace:"nowrap" }}>
          {opts.map(v => (
            <label key={v} style={{ display:"flex", alignItems:"center", gap:6, cursor:"pointer", fontSize:"var(--text-body-sm)" }}>
              <input type="checkbox" checked={cur.indexOf(v) >= 0} onChange={()=>toggle(v)} />{v}
            </label>
          ))}
        </span>
      )}
    </span>
  );
}
/* ══ K18-21/22: 概要タブ(池田さん版 CaseDetailV26.jsx の 段階サマリ・案件カード・原因／被害の表・タイムライン) ══
   並べ方は池田さん版に合わせ、main にしかない項目(副担当・営業担当・建物・住所・依頼先の一致、表の 種別・入館・鍵・
   備考・削除、住めない→P1 の注意)は残す。値はすべて rpc_seed(0110)と案件を開いたときの個別取得にあるもの ─
   新しい列・RPC は足していない。 */
const D_STAGES = ["受付","調査","工事","完了"];
const D_STAGE_TONE = { 受付:"intake", 調査:"survey", 工事:"work", 完了:"success" };
/* 案件の段階。盤面・ダッシュボードの段階と同じ規則(store.jsx msStage)を読む。 */
const dCaseStage = d => (window.msStage ? window.msStage(d) : (d && d.closed ? "完了" : "受付"));
/* 工事の区分の短い呼び名(池田さん版の 原因箇所/被害復旧)。値は rpc_seed の works[].kind(= work_category・0110)。
   区分の無い工事(区分を持たない古い控え)は決め打ちせず「工事」とだけ出す。 */
const dWorkKindShort = w => w.kind==="原因箇所工事" ? "原因箇所" : w.kind==="被害箇所工事" ? "被害復旧" : (w.kind || "工事");
/* 段階サマリの工事の行。進行表の行名(号室+工事名)と同じ文字だけの要素にしない(区分を前に付ける)。 */
const dWorkSummaryLabel = w => dWorkKindShort(w) + "　" + [w.room, w.work].filter(x => x && x !== "—").join(" ");
/* 実務の済み = 精算より前の工程をすべて終えた(dOps と同じ範囲)。見送りだけで終わったトラックは済みにしない。 */
function dPracticeOf(t){
  const range = dSteps(t).slice(0, dBillIdx(t));
  const open = range.find(s=>s.s!=="done"&&s.s!=="skip");
  const skippedAll = range.length>0 && range.every(s=>s.s==="skip");
  const last = [...range].reverse().find(s=>s.s==="done");
  return { ok: range.length>0 && !open && !skippedAll, skipped: !open && skippedAll, date: !open && last ? (last.date||null) : null,
           noSteps: dNoSteps(t) };
}
/* 精算の済み = 入金にあたる工程(fact_key。dPay と同じ D_PAID_KEYS)の完了。レールに無ければ精算明細の detail.paid。 */
function dPaidOf(t){
  const p = dStepBy(t, D_PAID_KEYS);
  if(p) return { ok: p.s==="done", date: p.s==="done" ? (p.date||null) : null };
  return { ok: !!(t.detail && t.detail.paid), date: null };
}
/* 済み/未の印。未は「!」(ConfirmMark の注意)にしない ─ まだ来ていない工程は注意ではない。 */
function DDoneDot({ ok }) {
  return <span aria-hidden="true" style={{ display:"inline-flex", alignItems:"center", justifyContent:"center", width:15, height:15, borderRadius:999,
    boxSizing:"border-box", flexShrink:0, fontSize:9, fontWeight:700, lineHeight:1,
    background: ok ? "var(--success-muted)" : "var(--card)", color:"var(--success)",
    border:"1px solid "+(ok ? "var(--success-border)" : "var(--border-strong)") }}>{ok ? "✓" : ""}</span>;
}
/* 段階サマリの 1 行(印・名前・日付か補足)。 */
function DProgLi({ ok, label, note, noteTone, children }) {
  return (
    <span data-prog-li={ok ? "done" : "open"} style={{ display:"grid", gridTemplateColumns:"16px minmax(0,1fr) auto", gap:"0 8px", alignItems:"center", fontSize:"var(--text-body-sm)" }}>
      <DDoneDot ok={ok} />
      <span style={{ minWidth:0, display:"inline-flex", alignItems:"center", gap:6, flexWrap:"wrap", color: ok ? "var(--muted-foreground)" : "var(--foreground)" }}>
        <span style={{ overflowWrap:"anywhere" }}>{label}</span>{children}
      </span>
      <span className="mono" style={{ fontSize:"var(--text-caption)", whiteSpace:"nowrap", color: noteTone || "var(--muted-foreground)" }}>{note || ""}</span>
    </span>
  );
}
function DProgSec({ k, children }) {
  return (
    <div style={{ display:"grid", gridTemplateColumns:"40px minmax(0,1fr)", gap:"0 12px", alignItems:"start", padding:"10px 14px", borderTop:"1px solid var(--border)" }}>
      <span style={{ fontSize:"var(--text-caption)", fontWeight:600, color:"var(--muted-foreground)", paddingTop:1 }}>{k}</span>
      <div style={{ display:"flex", flexDirection:"column", gap:6, minWidth:0 }}>{children}</div>
    </div>
  );
}
function DProgBox({ k, state, children }) {
  const cur = state==="cur";
  return (
    <div data-stage-box={k} data-stage-state={state} style={{ minWidth:0, borderRadius:"var(--radius-lg)", overflow:"hidden", background:"var(--card)",
      border:"1px solid "+(cur ? "var(--brand)" : "var(--border)"), boxShadow: cur ? "0 0 0 1px var(--brand) inset" : "none" }}>
      <div style={{ padding:"7px 14px", fontSize:"var(--text-caption)", fontWeight:700, letterSpacing:"0.04em",
        color: cur ? "var(--brand)" : state==="done" ? "var(--foreground)" : "var(--muted-foreground)" }}>{k}</div>
      {children}
    </div>
  );
}
/* K18-22: 段階サマリ(池田さん版 DStageSummary)。受付→調査→工事→完了 の 4 点と、調査・工事の箱(実務・精算)。
   段階は盤面と同じ msStage、閉じた案件は完了。住めない部屋がある保険精算の復旧工事には、工事の行に
   「P1 が目安」(K18-16・判定は store.jsx unlivableApproachHint)を添える ─ 詳しい注意は進行表の工事を開いた中(DApproachHint)。 */
function DStageSummary({ d, closed }) {
  const visits = d.visits||[], works = d.works||[], tasks = d.surveyTasks||[];
  const stage = closed ? "完了" : dCaseStage(d);
  const cur = Math.max(0, D_STAGES.indexOf(stage));
  const stOf = i => i<cur ? "done" : i===cur ? "cur" : "fut";
  // 原因が分かった = 調査チェックリストの「原因特定」が済み、または調査の報告工程が済み(池田さん版 dCauseFound)。
  const causeFound = tasks.some(x => /原因/.test(x.name||"") && x.state==="done")
    || visits.some(v => (v.steps||[]).some(s => (s.factKey==="survey_reported" || s.n==="報告") && s.s==="done"));
  const vPaid = visits.map(dPaidOf);
  const vAllPaid = visits.length>0 && vPaid.every(p=>p.ok);
  const vPaidDate = vAllPaid ? (vPaid.map(p=>p.date).filter(Boolean).pop() || "") : "";
  const tsNote = t => { const ts = dTstate(t); return ts ? dTstateLabel(ts) : null; };
  const hint = w => (window.unlivableApproachHint ? window.unlivableApproachHint(d, w) : null);
  const muted = <span style={{ fontSize:"var(--text-body-sm)", color:"var(--foreground-subtle)" }}>—</span>;
  return (
    <AfCard pad={0}>
      <div data-stage-summary={stage} style={{ padding:"14px 16px 16px", display:"flex", flexDirection:"column", gap:12 }}>
        <div style={{ position:"relative", display:"grid", gridTemplateColumns:"repeat(4,minmax(0,1fr))", gap:"0 12px" }}>
          <span aria-hidden="true" style={{ position:"absolute", left:"12.5%", right:"12.5%", top:10, borderTop:"1px solid var(--border-strong)" }} />
          {D_STAGES.map((s,i)=>{
            const st = stOf(i);
            return (
              <div key={s} data-stage-step={s} data-stage-state={st} aria-current={st==="cur" ? "step" : undefined}
                style={{ position:"relative", zIndex:1, display:"flex", flexDirection:"column", alignItems:"center", gap:5 }}>
                <span style={{ width:20, height:20, borderRadius:"50%", boxSizing:"border-box", display:"flex", alignItems:"center", justifyContent:"center",
                  fontSize:11, fontWeight:700, lineHeight:1,
                  border:"1px "+(st==="fut" ? "dashed" : "solid")+" "+(st==="done" ? "var(--foreground)" : st==="cur" ? "var(--brand)" : "var(--border-strong)"),
                  background: st==="done" ? "var(--foreground)" : st==="cur" ? "var(--brand)" : "var(--card)",
                  color: st==="fut" ? "var(--muted-foreground)" : "var(--card)",
                  boxShadow: st==="cur" ? "0 0 0 4px var(--brand-muted)" : "none" }}>{st==="done" ? "✓" : i+1}</span>
                <span style={{ fontSize:"var(--text-body-sm)", fontWeight:600, padding:"0 4px", background:"var(--card)",
                  color: st==="cur" ? "var(--brand)" : st==="fut" ? "var(--foreground-subtle)" : "var(--foreground)" }}>{s}</span>
              </div>
            );
          })}
        </div>
        <div className="mr-stack-1" style={{ display:"grid", gridTemplateColumns:"repeat(2,minmax(0,1fr))", gap:12 }}>
          <DProgBox k="調査" state={stOf(1)}>
            <DProgSec k="実務">
              {tasks.map((x,i)=><DProgLi key={x.name||i} ok={x.state==="done"} label={x.name} note={x.state==="done" ? (x.date||"") : ""} />)}
              {!tasks.length && muted}
            </DProgSec>
            <DProgSec k="精算">
              {visits.length ? <DProgLi ok={vAllPaid} label={"調査 "+visits.length+"件"} note={vPaidDate} /> : muted}
            </DProgSec>
          </DProgBox>
          <DProgBox k="工事" state={stOf(2)}>
            <DProgSec k="実務">
              {works.map((w,i)=>{
                const p = dPracticeOf(w), ts = tsNote(w), h = hint(w);
                return (
                  <DProgLi key={w.id||i} ok={p.ok} label={dWorkSummaryLabel(w)}
                    note={ts || (p.noSteps ? D_NO_STEPS : p.skipped ? "打ち切り" : p.ok ? (p.date||"") : "")}
                    noteTone={ts || p.skipped ? "var(--foreground-subtle)" : p.noSteps ? "var(--warning)" : undefined}>
                    {h && h.needsAttention && (
                      <span data-stage-approach-hint title={`住めない部屋（${h.rooms.join("・")}）があるので、進め方は P1（先行工事）が目安です。詳しくは下の進行表でこの工事を開いてください。`}>
                        <DChip tone="danger" dot>P1 が目安</DChip>
                      </span>
                    )}
                  </DProgLi>
                );
              })}
              {!works.length && <span style={{ fontSize:"var(--text-body-sm)", color:"var(--foreground-subtle)" }}>{causeFound ? "工事トラックなし" : "未確定（調査結果待ち）"}</span>}
            </DProgSec>
            <DProgSec k="精算">
              {works.map((w,i)=>{ const p = dPaidOf(w); return <DProgLi key={w.id||i} ok={p.ok} label={dWorkSummaryLabel(w)} note={p.date||""} />; })}
              {!works.length && muted}
            </DProgSec>
          </DProgBox>
        </div>
      </div>
    </AfCard>
  );
}
/* カードの見出し(池田さん版 Card の title / count / action)。 */
function DCardHead({ title, count, sub, action }) {
  return (
    <div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap", padding:"10px 16px", borderBottom:"1px solid var(--border)" }}>
      <h2 style={{ margin:0, fontSize:"var(--text-body-md)", fontWeight:700, letterSpacing:"0.02em" }}>{title}</h2>
      {count!=null && <span className="mono" style={{ fontSize:"var(--text-caption)", fontWeight:700, color:"var(--muted-foreground)" }}>{count}</span>}
      {sub && <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>{sub}</span>}
      {action && <span style={{ marginLeft:"auto", display:"inline-flex", alignItems:"center", gap:10 }}>{action}</span>}
    </div>
  );
}
/* 案件カードの 1 項目(池田さん版 Field: 上にラベル・下に値)。ラベルは span、値はその次の要素 ─
   E2E(numbers.spec「案件詳細の内訳」)はラベルの span の次の要素を値として読む。wide は 3 列をまたぐ。 */
function DField({ label, children, wide, labelColor, labelWeight }) {
  return (
    <div data-case-field={label} style={{ display:"flex", flexDirection:"column", gap:4, minWidth:0, gridColumn: wide ? "1 / -1" : undefined }}>
      <span style={{ fontSize:"var(--text-caption)", fontWeight:labelWeight||600, color:labelColor||"var(--muted-foreground)" }}>{label}</span>
      <div style={{ fontSize:"var(--text-body-md)", lineHeight:1.5, color:"var(--foreground)", minWidth:0, overflowWrap:"anywhere" }}>{children}</div>
    </div>
  );
}
/* K18-21: タイムライン(池田さん版 Timeline)。連絡履歴(communications・0027)の新しい順 8 件。点の色は向き
   (発信=ブランド・受信=緑・社内メモ=灰)。 */
const D_TL_DOT = { out:"var(--brand)", in:"var(--success)", memo:"var(--border-strong)" };
function DTimeline({ items }) {
  return (
    <div style={{ display:"flex", flexDirection:"column" }}>
      {items.map((it,i)=>(
        <div key={it.id||i} data-timeline-item style={{ display:"flex", gap:10 }}>
          <div style={{ display:"flex", flexDirection:"column", alignItems:"center", flexShrink:0 }}>
            <span style={{ width:9, height:9, borderRadius:999, marginTop:5, background:D_TL_DOT[it.dir]||"var(--border-strong)",
              border:"2px solid var(--card)", boxShadow:"0 0 0 1px "+(D_TL_DOT[it.dir]||"var(--border-strong)") }} />
            {i<items.length-1 && <span style={{ flex:1, width:1, background:"var(--border)", minHeight:18 }} />}
          </div>
          <div style={{ paddingBottom:12, minWidth:0 }}>
            <div style={{ fontSize:"var(--text-body-sm)", fontWeight:500, overflowWrap:"anywhere",
              overflow:"hidden", display:"-webkit-box", WebkitLineClamp:2, WebkitBoxOrient:"vertical" }}>{it.title}</div>
            <div className="mono" style={{ fontSize:10, color:"var(--muted-foreground)" }}>{it.time}{it.by ? "／"+it.by : ""}</div>
          </div>
        </div>
      ))}
    </div>
  );
}

const dDueColor = t => t==="warn"?"var(--warning)" : t==="soon"?"var(--status-survey)" : "var(--muted-foreground)";
function dCt(ct){
  if(ct.kind==="self") return ct.sub==="確定" ? { text:"自社・確定", tone:"success" } : { text:"自社・提案", tone:"brand" };
  if(ct.kind==="other") return { text:"他社", tone:"neutral" };
  return { text:"保留", tone:"warn" };
}

const DFieldCtx = React.createContext(false);
/* 工程レール：点タップで消込。明細のゲート工程には ⌗ が付き、明細タップで相互ハイライト */
function DRail({ steps, onStep, markers, activeStep }) {
  const fld = React.useContext(DFieldCtx);
  // H2: 工程が「無い」(null)トラックでも落ちない。無いことは DExpand が言葉で出す。
  const list = Array.isArray(steps) ? steps : [];
  return (
    <div className="mr-rail" style={{ display:"flex", alignItems:"flex-start", gap:2, flexWrap:"wrap", marginTop:6 }}>
      {list.map((st,i)=>{
        const done=st.s==="done", now=st.s==="now", wait=st.s==="waiting", skip=st.s==="skip", hot=now||wait;
        // 打ち切り(skip)は todo と同じ色だったため「まだやることがある」と読めていた。
        // 実データでは 19,390工程(原因箇所工事の57%)が打ち切りで、
        // 失注・案件クローズ済・適用外などの理由が全行に入っている。
        const col = done?"var(--success)" : now?"var(--brand)" : wait?"var(--warning)"
                  : skip?"var(--border)" : "var(--border-strong)";
        const marked = markers && markers.indexOf(i)>=0, active = activeStep===i;
        // 決-9: 見送り工程も開ける(取り消す導線)。消込そのものはしない。
        const clickable = !!onStep;
        const label = skip
          ? (st.skipReason ? st.n+" — "+st.skipReason+"（タップで取り消し可）" : st.n+" — 打ち切り（タップで取り消し可）")
          : onStep ? st.n+" を消込む" : undefined;
        return (
          <React.Fragment key={i}>
            <button onClick={clickable?e=>{ e.stopPropagation(); onStep(i); }:undefined} title={label}
              style={{ all:"unset", boxSizing:"border-box", display:"flex", flexDirection:"column", alignItems:"center", gap:3,
                minWidth:fld?62:(hot?56:44), minHeight:fld?56:undefined, justifyContent:"center", padding:fld?"6px 2px":"2px 1px",
                borderRadius:"var(--radius-sm)", fontFamily:"var(--font-sans)", background:active?"var(--brand-muted)":"transparent",
                opacity:skip?0.55:1,
                cursor:clickable?"pointer":"default", transition:"background var(--duration-fast) var(--easing)" }}
              onMouseEnter={e=>clickable&&!active&&(e.currentTarget.style.background="var(--accent)")}
              onMouseLeave={e=>clickable&&!active&&(e.currentTarget.style.background="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>}
                {skip && <span style={{ fontSize:7, color:"var(--muted-foreground)", fontWeight:700, lineHeight:1 }}>–</span>}
              </span>
              <span style={{ fontSize:"var(--text-caption)", fontWeight:hot?700:400, whiteSpace:"nowrap", textAlign:"center", lineHeight:1.3,
                textDecoration:skip?"line-through":"none",
                color: done?"var(--muted-foreground)" : now?"var(--foreground)" : wait?"var(--warning)" : "var(--foreground-subtle)" }}>
                {st.n}{marked && <span style={{ color:"var(--brand)", fontWeight:800 }}> ⌗</span>}
                {!!st.needsAssignee && <span style={{ color:"var(--warning)", fontWeight:700 }}> ・</span>}</span>
              <span className="mono" style={{ fontSize:9, whiteSpace:"nowrap",
                color: skip?"var(--muted-foreground)"
                     : st.needsAssignee?"var(--warning)"
                     : done?"var(--success)"
                     : now?"var(--brand)"
                     : wait?"var(--warning)"
                     : st.plan?"var(--muted-foreground)"
                     : "transparent" }}>
                {skip?"打切":st.needsAssignee?"未担当"
                  : done?(st.date||"·")
                  : st.plan?("予"+st.plan)
                  : (st.date||"·")}</span>
            </button>
            {i<steps.length-1 && <span style={{ height:2, flex:"1 0 6px", maxWidth:18, borderRadius:1, marginTop:hot?5:3,
              background: done?"var(--success-border)":"var(--border)" }} />}
          </React.Fragment>
        );
      })}
    </div>
  );
}

/* 明細テーブル（302工事＝主作業＋付随費用） */
const D_ITEM_COLS = "minmax(96px,1.2fr) minmax(86px,1fr) 76px 74px 84px";
function DItems({ items, activeItem, activeStep, onItemTap }) {
  const total = items.reduce((a,x)=>a+(x.amt||0),0);
  const toneC = t => t==="warn"?"var(--warning)" : t==="green"?"var(--success)" : "var(--foreground-subtle)";
  return (
    <div style={{ marginTop:8 }}>
      <div style={{ display:"grid", gridTemplateColumns:D_ITEM_COLS, gap:6, padding:"4px 2px", borderBottom:"1px solid var(--border-strong)" }}>
        {["作業","業者","金額","確定期限","状態"].map((h,i)=>(
          <span key={h} style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.03em", color:"var(--muted-foreground)", textAlign:i===2?"right":"left" }}>{h}</span>
        ))}
      </div>
      {items.map(x=>{
        const hl = activeItem===x.id || (activeStep!=null && x.gate===activeStep);
        return (
          <div key={x.id} onClick={e=>{ if(x.gate!=null && onItemTap){ e.stopPropagation(); onItemTap(x); } }}
            style={{ display:"grid", gridTemplateColumns:D_ITEM_COLS, gap:6, padding:"7px 2px", alignItems:"center",
              borderBottom:"1px solid var(--border)", cursor:x.gate!=null?"pointer":"default",
              background:hl?"var(--brand-muted)":"transparent", borderRadius:hl?"var(--radius-sm)":0 }}>
            <span style={{ display:"flex", alignItems:"center", gap:5, minWidth:0 }}>
              <span style={{ fontSize:"var(--text-body-md)", fontWeight:600, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{x.name}</span>
              {x.ins && <DChip tone="warn" dot>保険</DChip>}
            </span>
            <span style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{x.vendor}</span>
            <span className="mono" style={{ fontSize:"var(--text-body-sm)", textAlign:"right" }}>{yen(x.amt)}</span>
            <span style={{ fontSize:"var(--text-caption)", color:x.gate!=null?"var(--brand)":"var(--foreground-subtle)", whiteSpace:"nowrap" }}>{x.gateLabel}{x.gate!=null?" ⌗":""}</span>
            <span style={{ fontSize:"var(--text-caption)", fontWeight:600, color:toneC(x.tone), whiteSpace:"nowrap" }}>{x.state}</span>
          </div>
        );
      })}
      <div style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)", padding:"6px 2px 0", lineHeight:1.7 }}>
        合計 <b className="mono" style={{ color:"var(--foreground)" }}>{yen(total)}</b>　●＝保険充当　明細タップで工程 ⌗ を相互表示
      </div>
    </div>
  );
}

/* ===== フェーズツリー ===== */
const D_TREE = "18px minmax(190px,1.15fr) minmax(126px,0.95fr) minmax(158px,1.1fr) minmax(122px,0.9fr) 30px";
function DPhaseRow({ state, label, note, noteTone, onClick, open, first }) {
  const col = state==="done"?"var(--success)" : state==="now"?"var(--brand)" : state==="warn"?"var(--warning)" : "var(--border-strong)";
  const Tag = onClick ? "button" : "div";
  return (
    <Tag onClick={onClick} style={{ all:"unset", boxSizing:"border-box", width:"100%", display:"flex", alignItems:"center", gap:8,
      padding:first?"0 2px 7px":"16px 2px 7px", fontFamily:"var(--font-sans)", cursor:onClick?"pointer":"default" }}>
      <span style={{ width:14, height:14, borderRadius:999, boxSizing:"border-box", flexShrink:0,
        background: state==="todo"?"var(--card)":col, border:"2px solid "+col,
        display:"inline-flex", alignItems:"center", justifyContent:"center" }}>
        {state==="done" && <span style={{ fontSize:8, color:"#fff", fontWeight:700, lineHeight:1 }}>✓</span>}
        {(state==="now"||state==="warn") && <span style={{ width:5, height:5, borderRadius:999, background:"#fff" }} />}
      </span>
      <span style={{ fontSize:"var(--text-body-md)", fontWeight:700, letterSpacing:"0.05em" }}>{label}</span>
      {note && <span className={/^[\d/]+$/.test(note)?"mono":""} style={{ fontSize:"var(--text-body-sm)", color:noteTone||"var(--muted-foreground)" }}>{note}</span>}
      {onClick && <span style={{ fontSize:9, color:"var(--foreground-subtle)" }}>{open?"▾":"▸"}</span>}
    </Tag>
  );
}
/* ts(トラックの状態・K18-06): 保留・終了のときは実務列に状態の帯(保留=再開予定日 / 終了=終了結果)を出し、
   行をグレーにして滞りの印も出さない(02 §8.7)。進行中(ts 無し)は従来どおり工程からの導出(ops)。 */
function DTreeRow({ open, name, sub, ops, pay, method, mk, onToggle, jump, ts }) {
  const [hov, setHov] = React.useState(false);
  return (
    <div onClick={onToggle} onMouseEnter={()=>setHov(true)} onMouseLeave={()=>setHov(false)}
      data-track-state={ts ? ts.status : "進行中"}
      style={{ display:"grid", gridTemplateColumns:D_TREE, gap:7, alignItems:"center", padding:"9px 8px 9px 4px", cursor:"pointer",
        marginLeft:21, borderTop:"1px solid var(--border)",
        background:open?"var(--brand-muted)":hov?"var(--accent)":"transparent",
        color: ts ? "var(--muted-foreground)" : undefined, opacity: ts ? 0.72 : 1,
        transition:"background var(--duration-fast) var(--easing)" }}>
      <span style={{ fontSize:9, color:open?"var(--brand)":"var(--foreground-subtle)", textAlign:"center" }}>{open?"▾":"▸"}</span>
      <span style={{ fontSize:"var(--text-body-md)", fontWeight:600, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap", minWidth:0 }}>{name}</span>
      {ts ? (
        <span style={{ display:"flex", alignItems:"center", gap:5, minWidth:0 }}>
          <DChip tone={ts.status==="終了" ? "neutral" : "brand"}>{dTstateLabel(ts)}</DChip>
          <span style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{dTstateNote(ts)}</span>
        </span>
      ) : (
      <span style={{ display:"flex", alignItems:"center", gap:5, minWidth:0 }}>
        <span style={{ fontSize:"var(--text-body-sm)", fontWeight:600, color:ops.tone, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{ops.text}</span>
        {mk==="delay" && <span title="滞留" style={{ fontSize:7, color:"var(--warning)", flexShrink:0 }}>●</span>}
      </span>
      )}
      <span style={{ fontSize:"var(--text-body-sm)", color:pay.tone, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{pay.text}</span>
      <span style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{method}</span>
      <span style={{ textAlign:"right" }}>
        {jump && <button onClick={e=>{ e.stopPropagation(); jump(); }} title="横断の進行一覧で見る" style={{ all:"unset", fontFamily:"var(--font-sans)", fontSize:11, fontWeight:600, color:"var(--brand)", cursor:"pointer", whiteSpace:"nowrap" }}>›</button>}
      </span>
    </div>
  );
}
function DTreeBody({ children }) {
  return <div style={{ marginLeft:21, padding:"11px 14px 13px", background:"var(--accent)", borderTop:"1px solid var(--border)" }}>{children}</div>;
}
/* 精算-7: 精算方法(tracks.settlement_type)の編集欄。値は「—」(未設定)か
   D_SETTLEMENTSのどれか(rpc_seedが返すのと同じ文字列。対応表は無い)。 */
function DMethodSelect({ value, label, onChange }) {
  return (
    <select aria-label={label} value={value === "—" ? "" : (value || "")}
      onChange={e => onChange(e.target.value || null)}
      style={{ height:26, padding:"0 6px", borderRadius:"var(--radius-md)",
        borderWidth:1, borderStyle:"solid", borderColor:"var(--input)",
        fontSize:"var(--text-body-sm)", fontFamily:"var(--font-sans)",
        color:"var(--foreground)", background:"var(--card)" }}>
      <option value="">未設定（方針未確定）</option>
      {D_SETTLEMENTS.map(s => <option key={s} value={s}>{s}</option>)}
    </select>
  );
}
/* 展開部の定義行（ラベル幅を揃える） */
function DDef({ label, children }) {
  return (
    <div style={{ display:"flex", gap:10, padding:"3px 0", fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)", flexWrap:"wrap", alignItems:"center" }}>
      <span style={{ width:52, flexShrink:0, fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>{label}</span>
      <span style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap", minWidth:0 }}>{children}</span>
    </div>
  );
}
/* トラックの状態(保留・終了・再開)の操作(K18-06)。工程レールとは別に「止まっている／どう終わったか」を持つ
   (仕様 05 tracks.status/outcome・02 §8.7)。保留=理由+再開予定日必須 / 終了=終了結果(+失注・適用外は理由必須)。
   「適用外」は保険トラックだけ(0092 CHECK・0096【設計判断4】)。終了中は「進行中に戻す」だけ
   (終了→保留はサーバが 400。0096【設計判断3】)。保存は onChange(状態の組) → act.trackState → 7 キーの書き換え。 */
function DTrackStateModal({ mode, kind, label, onClose, onSave }) {
  const isIns = kind === "ins";
  const [reason, setReason] = React.useState("");
  const [resumeOn, setResumeOn] = React.useState("");
  const [outcome, setOutcome] = React.useState("完了");
  const [endedOn, setEndedOn] = React.useState(dTodayIso());
  const outcomes = D_TSTATE_OUTCOMES.filter(o => o !== "適用外" || isIns);
  const reasonRequired = mode === "保留" || D_TSTATE_REASON_REQUIRED.indexOf(outcome) >= 0;
  const valid = mode === "保留"
    ? (reason.trim() !== "" && /^\d{4}-\d{2}-\d{2}$/.test(resumeOn))
    : (!!outcome && (!reasonRequired || reason.trim() !== ""));
  const save = () => {
    if (!valid) return;
    onSave(mode === "保留"
      ? { status:"保留", holdReason:reason.trim(), resumePlannedOn:resumeOn, outcome:null, endReason:null, endedOn:null }
      : { status:"終了", outcome, endReason:reason.trim() || null, endedOn:endedOn || dTodayIso(), holdReason:null, resumePlannedOn:null });
  };
  const inputStyle = { height:28, padding:"0 8px", borderRadius:"var(--radius-md)", border:"1px solid var(--input)", background:"var(--card)", fontSize:"var(--text-body-md)", color:"var(--foreground)", fontFamily:"var(--font-mono)" };
  return (
    <ScModal title={mode === "保留" ? "保留にする" : "終了する"} sub={label} onClose={onClose}
      footer={<React.Fragment>
        <AfButton onClick={onClose}>キャンセル</AfButton>
        <AfButton variant="primary" disabled={!valid} onClick={save}>{mode === "保留" ? "保留にする" : "終了する"}</AfButton>
      </React.Fragment>}>
      {mode === "終了" && (
        <div style={{ display:"flex", flexDirection:"column", gap:6 }}>
          <div style={{ fontSize:"var(--text-caption)", fontWeight:600, color:"var(--muted-foreground)" }}>終了結果（必須）</div>
          <div role="radiogroup" aria-label="終了結果" style={{ display:"flex", gap:12, flexWrap:"wrap" }}>
            {outcomes.map(o => (
              <label key={o} style={{ display:"inline-flex", alignItems:"center", gap:4, fontSize:"var(--text-body-md)", cursor:"pointer" }}>
                <input type="radio" name="track-outcome" value={o} checked={outcome===o} onChange={()=>setOutcome(o)} />{o}
              </label>
            ))}
          </div>
          {!isIns && <div style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>「適用外」は保険トラックだけに使えます（調査・工事は「不要」か「失注」）。</div>}
        </div>
      )}
      <div style={{ display:"flex", flexDirection:"column", gap:6 }}>
        <div style={{ fontSize:"var(--text-caption)", fontWeight:600, color:"var(--muted-foreground)" }}>
          {mode === "保留" ? "保留理由（必須）" : reasonRequired ? "理由（失注・適用外は必須）" : "理由（任意）"}
        </div>
        <AfInput aria-label={mode === "保留" ? "保留理由" : "終了理由"} value={reason} onChange={e=>setReason(e.target.value)}
          placeholder={mode === "保留" ? "例：501退去後に実施" : "例：他社決定"} style={{ width:"100%" }} />
      </div>
      {mode === "保留" ? (
        <div style={{ display:"flex", flexDirection:"column", gap:6 }}>
          <div style={{ fontSize:"var(--text-caption)", fontWeight:600, color:"var(--muted-foreground)" }}>再開予定日（必須）</div>
          <input type="date" aria-label="再開予定日" value={resumeOn} onChange={e=>setResumeOn(e.target.value)} style={inputStyle} />
          <div style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>再開予定日まで滞りの対象から外れます。過ぎると進行中の扱いに戻ります。</div>
        </div>
      ) : (
        <div style={{ display:"flex", flexDirection:"column", gap:6 }}>
          <div style={{ fontSize:"var(--text-caption)", fontWeight:600, color:"var(--muted-foreground)" }}>終了日</div>
          <input type="date" aria-label="終了日" value={endedOn} onChange={e=>setEndedOn(e.target.value)} style={inputStyle} />
        </div>
      )}
    </ScModal>
  );
}
function DTrackState({ t, kind, label, onChange }) {
  const ts = dTstate(t);
  const [mode, setMode] = React.useState(null);
  const btn = { all:"unset", fontFamily:"var(--font-sans)", fontSize:"var(--text-caption)", fontWeight:600, color:"var(--brand)", cursor:"pointer", whiteSpace:"nowrap" };
  return (
    <DDef label="状態">
      <span data-track-state-panel={ts ? ts.status : "進行中"} style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap" }}>
        {ts ? (
          <React.Fragment>
            <DChip tone={ts.status==="終了" ? "neutral" : "brand"}>{dTstateLabel(ts)}</DChip>
            {ts.status==="保留" && ts.holdReason && <span style={{ color:"var(--foreground)" }}>{ts.holdReason}</span>}
            {ts.status==="保留" && ts.resumePlannedOn && <span className="mono">再開予定 {dIsoYmd(ts.resumePlannedOn)}</span>}
            {ts.status==="終了" && ts.endReason && <span style={{ color:"var(--foreground)" }}>{ts.endReason}</span>}
            {ts.status==="終了" && ts.endedOn && <span className="mono">{dIsoYmd(ts.endedOn)}</span>}
            <button onClick={()=>onChange(null)} style={btn}>{ts.status==="保留" ? "再開する" : "進行中に戻す"}</button>
            {ts.status==="保留" && <button onClick={()=>setMode("終了")} style={btn}>終了する</button>}
          </React.Fragment>
        ) : (
          <React.Fragment>
            <span>進行中</span>
            {/* SF フェーズ(legacy_phase)・工程からの導出は補助表示に落とす(列は残す) */}
            {t && t.status && t.status !== "—" && <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>（{t.status}）</span>}
            <button onClick={()=>setMode("保留")} style={btn}>保留にする</button>
            <button onClick={()=>setMode("終了")} style={btn}>終了する</button>
          </React.Fragment>
        )}
      </span>
      {mode && <DTrackStateModal mode={mode} kind={kind} label={label} onClose={()=>setMode(null)}
        onSave={next=>{ setMode(null); onChange(next); }} />}
    </DDef>
  );
}
/* 訪問予定・実績(detail.visits・精算-10・0073)。最小限の一覧+追加/取消。
   予定日・種別・メモの3つだけ編集できる(statusは表示だけ ─ 取消はvisit.removeで
   status='cancelled'に落ちる。実施済み(SF由来の実績)はstatusで見分けて印を出す)。 */
function DVisits({ visits, onAdd, onEdit, onRemove }) {
  const list = visits || [];
  if (!list.length) {
    return <DDef label="訪問予定"><button onClick={onAdd}
      style={{ all:"unset", fontFamily:"var(--font-sans)", fontSize:"var(--text-caption)", fontWeight:600, color:"var(--brand)", cursor:"pointer" }}>＋ 訪問予定を追加</button></DDef>;
  }
  return (
    <DDef label="訪問予定">
      <div style={{ display:"flex", flexDirection:"column", gap:5, width:"100%" }}>
        {list.map((v,i)=>(
          <div key={v.id} style={{ display:"flex", alignItems:"center", gap:6, flexWrap:"wrap" }}>
            <EdText value={v.plannedOn||""} mono size="var(--text-caption)" placeholder="予定日" width={78}
              onSave={val=>onEdit(i,{ plannedOn: val||null })} />
            <EdText value={v.kind||""} size="var(--text-caption)" placeholder="種別" width={88}
              onSave={val=>onEdit(i,{ kind: val })} />
            <EdText value={v.note||""} size="var(--text-caption)" placeholder="メモ"
              onSave={val=>onEdit(i,{ note: val })} />
            {v.status==="done" && <DChip tone="success">実施済み{v.visitedOn?" "+v.visitedOn:""}</DChip>}
            {v.status==="cancelled" && <DChip>取消済み</DChip>}
            {v.status!=="cancelled" && <button onClick={()=>onRemove(i)} title="この訪問予定を取り消す"
              style={{ all:"unset", cursor:"pointer", color:"var(--muted-foreground)", fontSize:"var(--text-caption)" }}>✕</button>}
          </div>
        ))}
        <button onClick={onAdd} style={{ all:"unset", fontFamily:"var(--font-sans)", fontSize:"var(--text-caption)", fontWeight:600, color:"var(--brand)", cursor:"pointer" }}>＋ 訪問予定を追加</button>
      </div>
    </DDef>
  );
}
/* 保険会社の検索・選び直し(書き込み経路-5)。
   手本はPayment.jsxのPaymentPayerEditor(請求先の検索・選択)と同じ作り ─
   window.payPartnerFetch(Payment.jsxがwindowへ公開している rpc_list_partners の
   薄いラッパ)を再利用する。読み込み順(index.htmlはPaymentをCaseDetailV25より
   後に読む)は問題にならない ─ ここでの参照はクリック時のコールバック内であり、
   実行時にはどちらのスクリプトも既に読み込み済みになっている。 */
function DInsurerPick({ value, dbId, onSave }) {
  const shown = value && value !== "—" ? value : "";
  const listId = React.useId();
  const [editing, setEditing] = React.useState(false);
  const [query, setQuery] = React.useState("");
  const [rows, setRows] = React.useState(null);
  const [error, setError] = React.useState("");

  React.useEffect(() => {
    if (!editing) return undefined;
    let live = true;
    setRows(null);
    setError("");
    const timer = setTimeout(async () => {
      const fetcher = window.payPartnerFetch;
      try {
        const result = typeof fetcher === "function" ? await fetcher(query, 30) : { rows: [], offline: true };
        if (!live) return;
        setRows((result && result.rows) || []);
        if (result && result.offline) setError("取引先マスタ未取得");
      } catch (e) {
        if (!live) return;
        setRows([]);
        setError((e && e.message) || "取引先を読めませんでした");
      }
    }, query ? 250 : 0);
    return () => { live = false; clearTimeout(timer); };
  }, [editing, query]);

  if (!editing) return (
    <button onClick={()=>{ setQuery(""); setEditing(true); }} title="クリックで保険会社を変更"
      style={{ all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)",
        color:shown?"var(--foreground)":"var(--foreground-subtle)",
        borderBottom:"1px dashed var(--border-strong)" }}>
      {shown || "未設定"} <span style={{ fontSize:"var(--text-caption)" }}>✎</span>
    </button>
  );

  return (
    <div style={{ width:220, padding:"5px 0" }}>
      <div style={{ display:"flex", gap:4 }}>
        <input autoFocus role="combobox" aria-label="保険会社を検索" aria-expanded="true" aria-controls={listId} value={query}
          onChange={e => setQuery(e.target.value)} onKeyDown={e => { if (e.key === "Escape") setEditing(false); }}
          placeholder="会社名で検索" style={{ boxSizing:"border-box", width:180, padding:"4px 7px",
            border:"1px solid var(--brand)", borderRadius:"var(--radius-sm)", outline:"none",
            boxShadow:"var(--ring-focus)", background:"var(--card)", color:"var(--foreground)" }} />
        <button type="button" aria-label="保険会社の変更をやめる" onClick={() => setEditing(false)}
          style={{ all:"unset", cursor:"pointer", color:"var(--muted-foreground)", padding:"0 4px" }}>×</button>
      </div>
      <div id={listId} role="listbox" aria-label="保険会社の候補" style={{ maxHeight:150, overflowY:"auto", marginTop:4,
        border:"1px solid var(--border)", borderRadius:"var(--radius-sm)", background:"var(--card)" }}>
        {rows === null && <div style={{ padding:7, color:"var(--foreground-subtle)" }}>検索中…</div>}
        {error && <div role="alert" style={{ padding:7, color:"var(--destructive)" }}>{error}</div>}
        {rows && !rows.length && !error && <div style={{ padding:7, color:"var(--foreground-subtle)" }}>該当する取引先はありません</div>}
        {rows && rows.map(org => (
          <button type="button" role="option" aria-selected={org.id === dbId}
            key={org.id || org.name} onClick={() => { if (org.id && org.name) { onSave && onSave(org); setEditing(false); } }}
            style={{ all:"unset", boxSizing:"border-box", display:"block", width:"100%", cursor:"pointer", padding:"6px 8px",
              borderBottom:"1px solid var(--border)", fontWeight:org.id === dbId?700:500,
              color:"var(--foreground)", background:org.id === dbId?"var(--brand-muted)":"transparent" }}>
            {org.name}
          </button>
        ))}
      </div>
    </div>
  );
}

/* ⑧ 業者(調査業者・施工者)の検索・選び直し(精算-6・0069・決-27)。
   DInsurerPick と同型。候補は rpc_list_partners(kind=業者)。名前だけ変えず
   vendorDbId を必ず載せる(mstore-diff が track.update{ vendor_org_id } に翻訳)。 */
function DVendorPick({ value, dbId, onSave, label }) {
  const shown = value && value !== "—" && value !== "未定" ? value : "";
  const listId = React.useId();
  const aria = label || "業者";
  const [editing, setEditing] = React.useState(false);
  const [query, setQuery] = React.useState("");
  const [rows, setRows] = React.useState(null);
  const [error, setError] = React.useState("");

  React.useEffect(() => {
    if (!editing) return undefined;
    let live = true;
    setRows(null);
    setError("");
    const timer = setTimeout(async () => {
      const cloud = window.MarurouCloud;
      try {
        let result;
        if (cloud && typeof cloud.listPartners === "function") {
          result = await cloud.listPartners({ query: query || null, kind: "業者", limit: 30 });
        } else {
          result = { rows: [], offline: true };
        }
        if (!live) return;
        setRows((result && result.rows) || []);
        if (result && result.offline) setError("取引先マスタ未取得");
      } catch (e) {
        if (!live) return;
        setRows([]);
        setError((e && e.message) || "業者を読めませんでした");
      }
    }, query ? 250 : 0);
    return () => { live = false; clearTimeout(timer); };
  }, [editing, query]);

  if (!editing) return (
    <button onClick={()=>{ setQuery(""); setEditing(true); }} title={"クリックで" + aria + "を変更"}
      style={{ all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)",
        color:shown?"var(--foreground)":"var(--foreground-subtle)",
        borderBottom:"1px dashed var(--border-strong)" }}>
      {shown || "未設定"} <span style={{ fontSize:"var(--text-caption)" }}>✎</span>
    </button>
  );

  return (
    <div style={{ width:220, padding:"5px 0" }}>
      <div style={{ display:"flex", gap:4 }}>
        <input autoFocus role="combobox" aria-label={aria + "を検索"} aria-expanded="true" aria-controls={listId} value={query}
          onChange={e => setQuery(e.target.value)} onKeyDown={e => { if (e.key === "Escape") setEditing(false); }}
          placeholder="業者名で検索" style={{ boxSizing:"border-box", width:180, padding:"4px 7px",
            border:"1px solid var(--brand)", borderRadius:"var(--radius-sm)", outline:"none",
            boxShadow:"var(--ring-focus)", background:"var(--card)", color:"var(--foreground)" }} />
        <button type="button" aria-label={aria + "の変更をやめる"} onClick={() => setEditing(false)}
          style={{ all:"unset", cursor:"pointer", color:"var(--muted-foreground)", padding:"0 4px" }}>×</button>
      </div>
      <div id={listId} role="listbox" aria-label={aria + "の候補"} style={{ maxHeight:150, overflowY:"auto", marginTop:4,
        border:"1px solid var(--border)", borderRadius:"var(--radius-sm)", background:"var(--card)" }}>
        {rows === null && <div style={{ padding:7, color:"var(--foreground-subtle)" }}>検索中…</div>}
        {error && <div role="alert" style={{ padding:7, color:"var(--destructive)" }}>{error}</div>}
        {rows && !rows.length && !error && <div style={{ padding:7, color:"var(--foreground-subtle)" }}>該当する業者はありません</div>}
        {rows && rows.map(org => (
          <button type="button" role="option" aria-selected={org.id === dbId}
            key={org.id || org.name} onClick={() => { if (org.id && org.name) { onSave && onSave(org); setEditing(false); } }}
            style={{ all:"unset", boxSizing:"border-box", display:"block", width:"100%", cursor:"pointer", padding:"6px 8px",
              borderBottom:"1px solid var(--border)", fontWeight:org.id === dbId?700:500,
              color:"var(--foreground)", background:org.id === dbId?"var(--brand-muted)":"transparent" }}>
            {org.name}
          </button>
        ))}
      </div>
    </div>
  );
}

/* ⑲: 関係者の会社を名簿から選び直し(0086 actor.update)。
   orgDbId を必ず載せる ─ 0078 の parties は orgDbId を返さないので、
   画面が書いて初めて mstore-diff が actor.update を出す。
   kind は立場に合わせる(施工会社→業者、他は絞らず検索)。 */
function DPartyOrgPick({ value, dbId, role, onSave, label }) {
  const shown = value && value !== "—" ? value : "";
  const listId = React.useId();
  const aria = label || "関係者";
  const partnerKind = (role === "施工会社" || role === "業者" || role === "施工者") ? "業者" : null;
  const [editing, setEditing] = React.useState(false);
  const [query, setQuery] = React.useState("");
  const [rows, setRows] = React.useState(null);
  const [error, setError] = React.useState("");

  React.useEffect(() => {
    if (!editing) return undefined;
    let live = true;
    setRows(null);
    setError("");
    const timer = setTimeout(async () => {
      const cloud = window.MarurouCloud;
      try {
        let result;
        if (cloud && typeof cloud.listPartners === "function") {
          result = await cloud.listPartners({ query: query || null, kind: partnerKind, limit: 30 });
        } else {
          result = { rows: [], offline: true };
        }
        if (!live) return;
        setRows((result && result.rows) || []);
        if (result && result.offline) setError("取引先マスタ未取得");
      } catch (e) {
        if (!live) return;
        setRows([]);
        setError((e && e.message) || "名簿を読めませんでした");
      }
    }, query ? 250 : 0);
    return () => { live = false; clearTimeout(timer); };
  }, [editing, query, partnerKind]);

  if (!editing) return (
    <button type="button" onClick={()=>{ setQuery(""); setEditing(true); }} title={"クリックで" + aria + "を名簿から選び直す"}
      style={{ all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)",
        color:shown?"var(--foreground)":"var(--foreground-subtle)",
        borderBottom:"1px dashed var(--border-strong)" }}>
      {shown || "未設定"} <span style={{ fontSize:"var(--text-caption)" }}>名簿から</span>
    </button>
  );

  return (
    <div style={{ width:240, padding:"5px 0" }}>
      <div style={{ display:"flex", gap:4 }}>
        <input autoFocus role="combobox" aria-label={aria + "を検索"} aria-expanded="true" aria-controls={listId} value={query}
          onChange={e => setQuery(e.target.value)} onKeyDown={e => { if (e.key === "Escape") setEditing(false); }}
          placeholder="会社名・電話で検索" style={{ boxSizing:"border-box", width:200, padding:"4px 7px",
            border:"1px solid var(--brand)", borderRadius:"var(--radius-sm)", outline:"none",
            boxShadow:"var(--ring-focus)", background:"var(--card)", color:"var(--foreground)" }} />
        <button type="button" aria-label={aria + "の変更をやめる"} onClick={() => setEditing(false)}
          style={{ all:"unset", cursor:"pointer", color:"var(--muted-foreground)", padding:"0 4px" }}>×</button>
      </div>
      <div id={listId} role="listbox" aria-label={aria + "の候補"} style={{ maxHeight:150, overflowY:"auto", marginTop:4,
        border:"1px solid var(--border)", borderRadius:"var(--radius-sm)", background:"var(--card)" }}>
        {rows === null && <div style={{ padding:7, color:"var(--foreground-subtle)" }}>検索中…</div>}
        {error && <div role="alert" style={{ padding:7, color:"var(--destructive)" }}>{error}</div>}
        {rows && !rows.length && !error && <div style={{ padding:7, color:"var(--foreground-subtle)" }}>該当する取引先はありません</div>}
        {rows && rows.map(org => (
          <button type="button" role="option" aria-selected={org.id === dbId}
            key={org.id || org.name} onClick={() => { if (org.id && org.name) { onSave && onSave(org); setEditing(false); } }}
            style={{ all:"unset", boxSizing:"border-box", display:"block", width:"100%", cursor:"pointer", padding:"6px 8px",
              borderBottom:"1px solid var(--border)", fontWeight:org.id === dbId?700:500,
              color:"var(--foreground)", background:org.id === dbId?"var(--brand-muted)":"transparent" }}>
            {org.name}{org.tel ? <span style={{ color:"var(--muted-foreground)", fontWeight:500 }}> · {org.tel}</span> : null}
          </button>
        ))}
      </div>
    </div>
  );
}

/* ㉖: 業者差し替え履歴(0069 rpc_track_vendor_changes)。いつ・誰が・前→後。 */
function DVendorHistory({ trackDbId, rows }) {
  if (!trackDbId) return null;
  const list = (rows || []).filter(r => r && r.trackId === trackDbId);
  if (!list.length) return null;
  const fmt = (iso) => {
    if (!iso) return "—";
    const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
    if (!m) return iso;
    return Number(m[2]) + "/" + Number(m[3]) + " " + m[4] + ":" + m[5];
  };
  const nm = (n) => (n && String(n).trim()) ? n : "未設定";
  return (
    <div style={{ marginTop: 4, fontSize: "var(--text-caption)", color: "var(--muted-foreground)", lineHeight: 1.6 }}>
      <div style={{ fontWeight: 600, marginBottom: 2 }}>差し替え履歴</div>
      {list.map(r => (
        <div key={r.id || (r.changedAt + r.newVendorOrgId)}>
          {fmt(r.changedAt)} · {r.changedByName || "—"} · {nm(r.prevVendorOrgName)} → {nm(r.newVendorOrgName)}
        </div>
      ))}
    </div>
  );
}

/* K18-20(0112): トラックごとの先方担当(業者側/保険会社側)。値は rpc_case_detail_context の trackContacts を
   店(MStore.trackContacts)から読む(部品と規則は TrackContacts.jsx)。案件の関係者(parties)とは別物で、
   ここから関係者へは足さない。個別取得が済むまで出さない。
   K-28(0117): 「付け替え/設定」から担当を選び直せる(TrackContactPicker.jsx が track.contact を積む)。
   orgDbId は候補の会社(業者 = 選んだ業者・保険会社 = 保険会社の名簿 ID)。分からなければ名前で探す。 */
function DTrackContacts({ caseId, trackDbId, ready, kind, orgDbId, orgName, onToast }) {
  if (window.TrackContactField) {
    return <TrackContactField caseId={caseId} trackDbId={trackDbId} ready={ready} kind={kind}
      orgDbId={orgDbId} orgName={orgName} onToast={onToast} Row={DDef} />;
  }
  const list = ready && window.trackContactsOf ? window.trackContactsOf(caseId, trackDbId) : null;
  if (!list || !list.length) return null;
  return <DDef label="先方担当"><TrackContactList contacts={list} /></DDef>;
}

/* H2: 工程が 1 つも無いトラックの説明。区分・精算方式・進め方に合う工程の型が無いまま作られた工事
   (直す前の工事の追加・0109)で起きる。工程を後から引き直す口はまだ無い(K18-73)ので、
   「状態」から終了にして、精算方式を選んで追加し直す手順を書く。 */
function DNoSteps({ t }) {
  return (
    <div data-no-steps style={{ marginTop:6, fontSize:"var(--text-body-sm)", lineHeight:1.6, color:"var(--warning)" }}>
      <b>工程がありません。</b>
      <span style={{ color:"var(--muted-foreground)" }}>
        精算方式・進め方に合う工程の型が無いまま作られたため、進める工程が 1 つもありません（完了ではありません）。
        {t && t.dbId ? "工程を後から作り直す操作はまだ無いので、要らなければ上の「状態」で終了（不要）にし、精算方式を選んで追加し直してください。" : ""}
      </span>
    </div>
  );
}

/* 展開部：工程レール（＋明細） */
function DExpand({ t, onStep }) {
  const [activeItem, setActiveItem] = React.useState(null);
  const [activeStep, setActiveStep] = React.useState(null);
  const items = t.detail && t.detail.items;
  const markers = items ? items.filter(x=>x.gate!=null).map(x=>x.gate) : null;
  return (
    <div style={{ marginTop:9, padding:"9px 11px 11px", background:"var(--card)", border:"1px solid var(--border)", borderRadius:"var(--radius-md)" }}>
      <div style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>工程（受注→入金）・点をタップで消込</div>
      {dNoSteps(t) ? <DNoSteps t={t} />
        : <DRail steps={dSteps(t)} onStep={onStep} markers={markers} activeStep={activeStep} />}
      {items && <DItems items={items} activeItem={activeItem} activeStep={activeStep}
        onItemTap={x=>{ const on = activeItem===x.id; setActiveItem(on?null:x.id); setActiveStep(on?null:x.gate); }} />}
    </div>
  );
}

function DTab({ k, label, count, tab, setTab }) {
  const [hov, setHov] = React.useState(false);
  const on = tab===k;
  return (
    <button onClick={()=>setTab(k)} onMouseEnter={()=>setHov(true)} onMouseLeave={()=>setHov(false)}
      aria-current={on?"page":undefined}
      style={{ all:"unset", boxSizing:"border-box", cursor:"pointer", fontFamily:"var(--font-sans)", display:"flex", alignItems:"center", gap:5,
        fontSize:"var(--text-body-md)", fontWeight:on?700:500, padding:"5px 13px", borderRadius:"var(--radius-md)", whiteSpace:"nowrap",
        color:on?"var(--foreground)":hov?"var(--foreground)":"var(--muted-foreground)",
        background:on?"var(--card)":hov?"var(--accent)":"transparent",
        border:"1px solid "+(on?"var(--border)":"transparent"),
        boxShadow:on?"var(--shadow-sm)":"none",
        transition:"all var(--duration-fast) var(--easing)" }}>
      {label}
      {count>0 && <span className="mono" style={{ fontSize:"var(--text-caption)", fontWeight:700, padding:"0 5px", borderRadius:"var(--radius-full)",
        color:on?"var(--brand)":"var(--muted-foreground)", background:on?"var(--brand-muted)":"var(--muted)",
        border:"1px solid "+(on?"var(--brand-border)":"var(--border)") }}>{count}</span>}
    </button>
  );
}
function DTabs({ tab, setTab, counts }) {
  const items = [["overview","概要"],["todo","TODO"],["files","ファイル"],["parties","関係者"],["money","収支"],["comms","過去のやり取り"]];
  return (
    <div style={{ display:"flex", alignItems:"center", gap:8, margin:"16px 0 14px", flexWrap:"wrap" }}>
      <div style={{ display:"inline-flex", gap:2, padding:3, borderRadius:"var(--radius-lg)", background:"var(--muted)", border:"1px solid var(--border)", maxWidth:"100%", overflowX:"auto" }}>
        {items.map(([k,label])=><DTab key={k} k={k} label={label} count={(counts||{})[k]} tab={tab} setTab={setTab} />)}
      </div>
      <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>タブで切替</span>
    </div>
  );
}
const D_PL_COLS = "22px 1.3fr 76px 70px 74px 96px 84px";

function CaseDetail({ c, caseId, onBack, onDispatch, onPayment, onToast, onAddTask, onContact, onJump, initialStage, initialPanel }) {
  const cid = caseId || (c && c.id) || (c && c.caseId) || "CASE-2471";
  const store = window.useMStore ? window.useMStore() : null;
  const d = React.useMemo(()=>{
    const hit = window.MStore ? window.MStore.case(cid) : null;
    return hit ? { ...D_EMPTY, ...hit } : D_EMPTY;
  }, [cid, store]);
  const [tab, setTab] = React.useState("overview");
  const [openKeys, setOpenKeys] = React.useState(initialPanel?[initialPanel]:[]);
  const [stepCtx, setStepCtx] = React.useState(null);
  const [viewFile, setViewFile] = React.useState(null);
  const [viewFileLoading, setViewFileLoading] = React.useState(false);
  const [addKind, setAddKind] = React.useState(null);
  const [docType, setDocType] = React.useState(null);
  const closed = !!d.closed;
  const [field, setField] = React.useState(false);
  const [activeTodo, setActiveTodo] = React.useState(null);
  const [plOpen, setPlOpen] = React.useState(false);
  const [ptab, setPtab] = React.useState("すべて");
  const pName = p => (window.scNorm||(x=>x))(p.name);
  const [psel, setPsel] = React.useState(null);
  const [detailContext, setDetailContext] = React.useState({ status:"idle" });
  const [vendorChanges, setVendorChanges] = React.useState([]);
  // K18-21: 見出しの「＋ トラック」で開く小さな選択(調査・工事・保険)。
  const [trackMenu, setTrackMenu] = React.useState(false);
  // 開いている間は、外側を押すか Esc で閉じる(選ぶまで開きっぱなしにしない)。
  const trackMenuRef = React.useRef(null);
  React.useEffect(() => {
    if (!trackMenu) return;
    const onDown = e => { if (trackMenuRef.current && !trackMenuRef.current.contains(e.target)) setTrackMenu(false); };
    const onKey = e => { if (e.key === "Escape") setTrackMenu(false); };
    document.addEventListener("mousedown", onDown);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); };
  }, [trackMenu]);
  const stage = dStageInfo(d);
  // K18-21: 見出しの段階チップ(盤面と同じ msStage。閉じた案件は完了)。
  const caseStage = closed ? "完了" : dCaseStage(d);
  // K18-13(0105): 漏水状況の選択肢(SF の picklist 8 値)と「継続中」(常時・時々)。どちらも store.jsx の
  // 1 か所(LEAK_STATUS_OPTIONS / isLeakContinuing)を読む ─ 盤面・ダッシュボードの印と同じ規則。
  // 語彙に無い値が入っている案件は、その値を選択肢に足して落とさない(CHECK は未定・docs/18 E-13(2))。
  const leakStatus = d.leakStatus || "";
  const leakOptions = React.useMemo(() => {
    const base = window.LEAK_STATUS_OPTIONS || [];
    return leakStatus && base.indexOf(leakStatus) < 0 ? base.concat([leakStatus]) : base;
  }, [leakStatus]);
  const leakContinuing = !!(window.isLeakContinuing && window.isLeakContinuing(d));
  // ⑫: planOverDays/reportOverDays → ヘッダの赤/黄(Dashboard と同じ判定・文言)。完了案件は出さない。
  const slaSt = React.useMemo(() => {
    if(d.closed || !window.slaCaseStatus) return null;
    const sla = window.slaConfig && window.MStore ? window.slaConfig(window.MStore.get()) : window.SLA_DEFAULTS;
    return window.slaCaseStatus(d, sla);
  }, [d]);
  /* 連絡履歴(0027 rpc_list_communications・書き込み経路-10)。案件単位・1ページだけ取り、
     続きは comms.loadMore()。window.useMStore と同じ「無ければ何もしない」の流儀。 */
  const comms = window.useCaseComms ? window.useCaseComms(d.dbId) : { rows:[], err:"", hasMore:false, loadingMore:false, loadMore(){} };
  React.useEffect(()=>{ if(initialPanel){ setTab("overview"); setOpenKeys(k=>k.indexOf(initialPanel)>=0?k:[...k,initialPanel]); } }, [initialPanel]);
  // 【ページング1段目】盤面データがブラウザの控え上限を超えたため、工程レールから
  // stepId/factKey/gate/skipReason を外した(0030_rail_paging.sql)。案件を開いたら
  // rpc_case_steps() で1回だけ補う。失敗しても実データの表示は止めない
  // (架空データに落ちない ─ 05 §5.10「実データを読めないときは赤い画面で止める」の
  // 対象はあくまで案件一覧そのものが読めないときで、ここは案件は既に開けている状態の
  // 追加取得なので、失敗は警告に留めて画面はそのまま使わせる)。
  // stepId はこの後の書き戻し(mstore-diff.js の step.complete)に必須。補充が
  // 終わる前に完了操作をすると、既存のとおり「工程がDBの行と結び付いていない」
  // 警告に出る(黙って捨てない・mstore-diff.js:diffSteps)。
  // **その警告が出ること自体が事故**なので、工程を触る導線は awaitStepIds() で
  // 補充を待ってから進める(下の mutateStep ほか)。ここは先読みに徹する。
  const dbId = d && d.dbId;
  // H1(9/25): 案件をサーバの値で差し替えた(409 の読み直し・起動時・建物関係者の版合わせ)回数。
  // 差し替えると開いたときに補った工程の stepId・添付・建物関係者などが案件から消えるので、
  // 下の読み直しはこれも見て、開いたままでも取り直す(store.jsx applyServerCase / replaceCasesFromSeed)。
  const caseRev = window.MStore && window.MStore.caseRev ? window.MStore.caseRev(cid) : 0;
  React.useEffect(() => {
    if (!dbId || !window.MStore || !window.MStore.ensureCaseSteps) return;
    // assigneeUserId / needsAssignee / plannedOn も mergeCaseSteps が書く(0036/0038)。
    window.MStore.ensureCaseSteps(cid, dbId).catch(err => {
      console.warn("[案件詳細] 工程の追加情報(stepId等)を取得できませんでした。" +
        "工程の完了操作は保存できない場合があります。", err);
    });
  }, [cid, dbId, caseRev]);
  // ファイル・帳票-6(0044): 案件を開いたら共有履歴(file_shares)を読み直し、
  // MStore.mergeFileShares で反映する。上のloadCaseSteps→mergeCaseStepsと同じ並び
  // (**利用者の編集ではない** ─ mergeFileSharesが__MARUROU_APPLYING_REMOTE__を立てる)。
  // 失敗しても実データの表示は止めない(共有履歴が古いまま出るだけ)。
  React.useEffect(() => {
    if (!dbId || !window.MarurouCloud || !window.MarurouCloud.listFileShares) return;
    let cancelled = false;
    window.MarurouCloud.listFileShares(dbId).then(resp => {
      if (cancelled || !window.MStore) return;
      window.MStore.mergeFileShares(cid, (resp && resp.rows) || []);
    }).catch(err => {
      console.warn("[案件詳細] 共有履歴(file_shares)を取得できませんでした。" +
        "画面には前回までの共有記録が出ます。", err);
    });
    return () => { cancelled = true; };
  }, [cid, dbId, caseRev]);
  // ファイル・帳票-2(0058): 案件を開いたら添付ファイル一覧(attachments)を読み直し、
  // MStore.mergeAttachments で反映する。上のlistFileShares→mergeFileSharesと同じ並び
  // (**利用者の編集ではない** ─ mergeAttachmentsが__MARUROU_APPLYING_REMOTE__を立てる)。
  // 失敗しても実データの表示は止めない(添付一覧が前回までのまま出るだけ)。
  React.useEffect(() => {
    if (!dbId || !window.MarurouCloud || !window.MarurouCloud.listAttachments) return;
    let cancelled = false;
    window.MarurouCloud.listAttachments(dbId).then(resp => {
      if (cancelled || !window.MStore) return;
      window.MStore.mergeAttachments(cid, (resp && resp.rows) || []);
    }).catch(err => {
      console.warn("[案件詳細] 添付ファイル一覧を取得できませんでした。" +
        "画面には前回までの一覧が出ます。", err);
    });
    return () => { cancelled = true; };
  }, [cid, dbId, caseRev]);
  // K-7(決-8・0084): 案件を開いたら帳票の発行記録(doc_issues)を読み直し、
  // MStore.mergeDocIssues で反映する。上のlistAttachments→mergeAttachmentsと同じ並び
  // (**利用者の編集ではない** ─ mergeDocIssuesが__MARUROU_APPLYING_REMOTE__を立てる)。
  // 失敗しても実データの表示は止めない(発行の記録が前回までのまま出るだけ)。
  React.useEffect(() => {
    if (!dbId || !window.MarurouCloud || !window.MarurouCloud.listDocIssues) return;
    let cancelled = false;
    window.MarurouCloud.listDocIssues(dbId).then(resp => {
      if (cancelled || !window.MStore) return;
      window.MStore.mergeDocIssues(cid, (resp && resp.rows) || []);
    }).catch(err => {
      console.warn("[案件詳細] 帳票の発行記録を取得できませんでした。" +
        "画面には前回までの記録が出ます。", err);
    });
    return () => { cancelled = true; };
  }, [cid, dbId, caseRev]);
  // 案件詳細-14・受付-8(0060): 案件を開いたら調査チェックリスト
  // (応急止水/原因特定/被害対応)を読み直し、MStore.mergeSurveyTasks で反映する。
  // 上のlistFileShares→mergeFileSharesと同じ並び(**利用者の編集ではない** ─
  // mergeSurveyTasksが__MARUROU_APPLYING_REMOTE__を立てる)。失敗しても実データの
  // 表示は止めない(前回までの内容が出るだけ)。
  React.useEffect(() => {
    if (!dbId || !window.MarurouCloud || !window.MarurouCloud.listSurveyTasks) return;
    let cancelled = false;
    window.MarurouCloud.listSurveyTasks(dbId).then(rows => {
      if (cancelled || !window.MStore) return;
      window.MStore.mergeSurveyTasks(cid, rows || []);
    }).catch(err => {
      console.warn("[案件詳細] 調査チェックリストを取得できませんでした。" +
        "画面には前回までの内容が出ます。", err);
    });
    return () => { cancelled = true; };
  }, [cid, dbId, caseRev]);
  // 盤面DB-2(0063): 案件対象部屋の構造化列(原因種別・立入方法・鍵情報・備考)を
  // 案件を開いたときに補う(listSurveyTasks→mergeSurveyTasksと同じ並び)。
  React.useEffect(() => {
    if (!dbId || !window.MarurouCloud || !window.MarurouCloud.listCaseTargetRooms) return;
    let cancelled = false;
    window.MarurouCloud.listCaseTargetRooms(dbId).then(rows => {
      if (cancelled || !window.MStore) return;
      window.MStore.mergeCaseTargetRoomDetails(cid, rows || []);
    }).catch(err => {
      console.warn("[案件詳細] 対象部屋の詳細(原因種別・立入方法・鍵情報・備考)を" +
        "取得できませんでした。画面には前回までの内容が出ます。", err);
    });
    return () => { cancelled = true; };
  }, [cid, dbId, caseRev]);
  // 精算-10(0073): 案件を開いたら訪問予定・実績(site_visits)を読み直し、
  // MStore.mergeTrackVisits で各トラックのdetail.visitsへ反映する。
  // 上のlistCaseTargetRooms→mergeCaseTargetRoomDetailsと同じ並び(**利用者の編集
  // ではない** ─ mergeTrackVisitsが__MARUROU_APPLYING_REMOTE__を立てる)。
  // 失敗しても実データの表示は止めない(訪問予定欄が前回までのまま出るだけ)。
  React.useEffect(() => {
    if (!dbId || !window.MarurouCloud || !window.MarurouCloud.listTrackVisits) return;
    let cancelled = false;
    window.MarurouCloud.listTrackVisits(dbId).then(resp => {
      if (cancelled || !window.MStore) return;
      window.MStore.mergeTrackVisits(cid, (resp && resp.rows) || []);
    }).catch(err => {
      console.warn("[案件詳細] 訪問予定・実績を取得できませんでした。" +
        "画面には前回までの内容が出ます。", err);
    });
    return () => { cancelled = true; };
  }, [cid, dbId, caseRev]);
  // ㉖: 業者差し替え履歴(0069)。失敗しても業者欄の表示は止めない。
  React.useEffect(() => {
    if (!dbId || !window.MarurouCloud || !window.MarurouCloud.listTrackVendorChanges) return;
    let cancelled = false;
    window.MarurouCloud.listTrackVendorChanges(dbId).then(resp => {
      if (cancelled) return;
      setVendorChanges((resp && resp.rows) || []);
    }).catch(err => {
      console.warn("[案件詳細] 業者差し替え履歴を取得できませんでした。", err);
      if (!cancelled) setVendorChanges([]);
    });
    return () => { cancelled = true; };
  }, [cid, dbId, caseRev]);
  // 盤面容量対策(詳細コンテキスト)。0051導入後は {building, parties} を
  // 案件を開いた時だけ取得し、MStore.mergeCaseDetailContext が反映する。
  // 取得済みや保存済みとは別に扱い、取得失敗でも一覧の案件表示は維持する。
  React.useEffect(() => {
    if (!dbId || !window.MStore || !window.MStore.ensureCaseDetailContext) return;
    let alive = true;
    setDetailContext({ status:"loading" });
    window.MStore.ensureCaseDetailContext(cid, dbId).then(() => {
      if (alive) setDetailContext({ status:"ready" });
    }).catch(error => {
      if (alive) setDetailContext({ status:"error", error });
    });
    return () => { alive = false; };
  }, [cid, dbId, caseRev]);
  const toggle = k => setOpenKeys(ks => ks.indexOf(k)>=0 ? ks.filter(x=>x!==k) : [...ks, k]);
  const isOpen = k => openKeys.indexOf(k)>=0;
  const upd = fn => { if(window.MStore) window.MStore.updateCase(cid, fn); };
  const act = {
    // 工程の点を押した時点で、まだ結び付いていなければ取りに行かせる
    // (案件を開いたときの先読みが失敗していた場合の取り直し。awaitStepIds が
    //  この1本に相乗りするので、押してから待つ時間はここで先に削れる)。
    step: (kind, idx, i) => { setStepCtx({ kind, idx, i });
      if (window.MStore && window.MStore.ensureCaseSteps) window.MStore.ensureCaseSteps(cid, dbId).catch(() => {}); },
    money: (kind, idx, f, val) => upd(n => { dTrack(n,kind,idx).detail[f] = val; }),
    // 精算-7: 精算方法(tracks.settlement_typeへ翻訳される。app/write/mstore-diff.js
    // SETTLE_DETAIL.method参照)。保険精算から他へ変えるときだけ、保険充当
    // (insurance_allocations)が残っている可能性を一言添える ─ この画面は
    // 充当の有無を持っていないので、削除も判定もせず注意を出すだけに留める
    // (docs/13 精算-7・決めごと「保存は通しつつ注意文」)。
    method: (kind, idx, was, val) => {
      upd(n => { dTrack(n,kind,idx).detail.method = val; });
      if (was === "保険精算" && val !== "保険精算") {
        onToast && onToast("精算方法を保険精算から変更しました。保険充当が残っている場合は精算画面でご確認ください。");
      }
    },
    // トラック本体(detailの外)の任意の列を直に上書きする。今は保険の証券番号
    // (policyNo)だけが使う(書き込み経路-5)。
    text: (kind, idx, f, val) => upd(n => { dTrack(n,kind,idx)[f] = val; }),
    // K18-06: トラックの状態。null は「進行中に戻す」(track.resume)。保留・終了は
    // DTrackStateModal が必須を確かめた tstate をそのまま置く(mstore-diff.js diffTrackState が
    // track.hold / track.end に翻訳する。dbId が無い・サーバが拒否したときは書き込み層が赤帯を出す)。
    trackState: (kind, idx, ts) => {
      upd(n => { const t = dTrack(n, kind, idx); if (t) Object.assign(t, dTstateKeys(ts)); });
      onToast && onToast(!ts ? "トラックを進行中に戻しました" : ts.status === "保留"
        ? "トラックを保留にしました（再開予定 " + dIsoYmd(ts.resumePlannedOn) + "）"
        : "トラックを終了にしました（" + ts.outcome + "）");
    },
    // 書き込み経路-5: 保険会社は名前＋取引先マスタID。client/ownerと同じ型
    // (IDが無い名前だけの変更はmstore-diffがunsupportedに回す)。
    insurer: (idx, org) => upd(n => { const t = dInsList(n)[idx];
      t.name = (org && org.name) || "—"; t.insurerDbId = (org && org.id) || null; }),
    // ⑧: 業者(調査業者・施工者)。名簿ID必須。表示名は top-level vendor と
    // detail.vendor(工事の施工者欄)の両方に載せる(mstore-diff は vendorDbId を見る)。
    vendor: (kind, idx, org) => upd(n => {
      const t = dTrack(n, kind, idx);
      const name = (org && org.name) || "—";
      t.vendor = name;
      t.vendorDbId = (org && org.id) || null;
      t.detail = t.detail || {};
      t.detail.vendor = name;
    }),
    work: (idx, patch) => upd(n => Object.assign(n.works[idx], patch)),
    task: (idx, patch) => upd(n => Object.assign(n.surveyTasks[idx], patch)),
    spot: (idx, patch) => upd(n => Object.assign(n.spots[idx], patch)),
    // 案件詳細-12(K-11・0085): 対象部屋を消す。spots[] から行を落とすと
    // mstore-diff.js が spots.remove(論理削除・deleted_at)へ翻訳する。
    spotRemove: idx => upd(n => { n.spots.splice(idx, 1); }),
    // 書き込み経路-2: 依頼先は名前＋取引先マスタID。IDが無い名前だけの変更は
    // mstore-diff が unsupported に回す。Intake の取引先ピッカーと同じ型。
    client: (name, dbId) => upd(n => { n.client = name; n.clientDbId = dbId || null; }),
    // 書き込み経路-1: 案件の本担当(lead_user_id)。工程の担当(assignee_user_id)とは別。
    // 名簿から選んだIDを ownerDbId に載せる。名前だけの変更は保存できない。
    owner: (name, dbId) => upd(n => { n.owner = name || "—"; n.ownerDbId = dbId || null; }),
    // 決-21(追-4-3): 副担当・営業担当。owner と同じ型(名簿のIDで解決)。
    sub:   (name, dbId) => upd(n => { n.sub = name || "—"; n.subDbId = dbId || null; }),
    sales: (name, dbId) => upd(n => { n.sales = name || "—"; n.salesDbId = dbId || null; }),
    // K18-02(0093/0094): 案件カードの 重要事項(keyNotes)・状況(situation)・次回連絡事項
    // (nextContactNote)。案件直下の文字列をそのまま書き換えると、書き込み層が
    // case.update の key_notes / situation / next_contact_note に翻訳する
    // (app/write/mstore-diff.js CASE_FIELD_MAP)。空文字は NULL で保存される。
    caseText: (key, val) => upd(n => { n[key] = val; }),
    // ⑲(0086): 関係者を名簿から差し替え。orgDbId を書くと actor.update になる。
    // 管理会社は cases.mgmt_org_id も揃える(mgmtDbId → case.update)。
    // 会社を変えたら旧担当者(contactDbId)は外す(別会社の担当者IDが残らないように)。
    partyReplace: (idx, org) => upd(n => {
      const p = (n.parties || [])[idx];
      if (!p || !org || !org.id) return;
      p.name = org.name || p.name;
      p.orgDbId = org.id;
      p.contactDbId = null;
      p.phone = org.tel || p.phone || null;
      if (p.role === "管理会社") n.mgmtDbId = org.id;
    }),
    // ⑲: 関係者を外す(actor.remove・論理削除)。管理会社は mgmtDbId も空に。
    partyRemove: idx => upd(n => {
      const p = (n.parties || [])[idx];
      if (!p) return;
      if (p.role === "管理会社") n.mgmtDbId = null;
      n.parties = n.parties.filter((_, j) => j !== idx);
    }),
    doc: type => setDocType(type),
    add: kind => setAddKind(kind),
    contractor: idx => upd(n => { const ct = n.works[idx].contractor;
      ct.kind==="self" && ct.sub==="確定" ? (ct.sub="提案") : ct.kind==="self" ? (ct.kind="other", ct.sub=null) : (ct.kind="self", ct.sub="確定"); }),
    // 精算-10(0073): 訪問予定・実績(detail.visits)。追加は空欄のまま送ると
    // kindがNOT NULLで保存が丸ごと弾かれるため、既定値(現地対応)を入れておく
    // (画面からすぐ変えられる)。
    visitAdd: (kind, idx) => upd(n => {
      const t = dTrack(n, kind, idx);
      t.detail = t.detail || {};
      t.detail.visits = Array.isArray(t.detail.visits) ? t.detail.visits : [];
      t.detail.visits.push({ id: "SVN-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6),
        plannedOn: null, kind: "現地対応", note: "" });
    }),
    visit: (kind, idx, i, patch) => upd(n => { Object.assign(dTrack(n, kind, idx).detail.visits[i], patch); }),
    visitRemove: (kind, idx, i) => upd(n => { dTrack(n, kind, idx).detail.visits.splice(i, 1); }),
  };
  const orgs = (window.MStore && window.MStore.masters ? window.MStore.masters().orgs : null) || [];
  const users = (window.MStore && window.MStore.masters ? window.MStore.masters().users : null) || [];
  const clientDbId = d.clientDbId || ((orgs.find(o => o.name === d.client) || {}).id) || null;
  // seed は owner 名だけ。表示用に名簿からIDを拾う(書き込みは選んだIDを使う。名前一致で保存しない)。
  const ownerDbId = d.ownerDbId || ((users.find(u => u.name === d.owner) || {}).id) || null;
  // 決-21(追-4-3): 副担当・営業担当。seed が sub_user_id/sales_user_id を直接返す
  // (owner と違い、名前一致に頼らずIDをそのまま持てる。0050_sub_sales_users.sql)。
  // 名簿(masters().users)は在職者のみ ─ 退職者が就いている場合は一致せず、
  // 「名簿から選び直すと保存できます」の注記が出る(本担当と同じ仕組み)。
  const subDbId = d.subDbId || ((users.find(u => u.name === d.sub) || {}).id) || null;
  const salesDbId = d.salesDbId || ((users.find(u => u.name === d.sales) || {}).id) || null;
  const orgListId = "mr-case-org-list-"+cid;
  const ctxTrack = stepCtx ? dTrack(d, stepCtx.kind, stepCtx.idx) : null;
  const ctxStep = ctxTrack ? (dSteps(ctxTrack)[stepCtx.i] || null) : null;
  const trackLabel = !ctxTrack ? "" : stepCtx.kind==="ins" ? "保険 "+((dInsList(d)[stepCtx.idx||0]||{}).name||"") : stepCtx.kind==="work" ? "工事 "+ctxTrack.room+" "+ctxTrack.work : "調査 "+ctxTrack.vendor;
  const closeStep = () => setStepCtx(null);
  /* 工程を触る前に、レールが track_steps と結び付く(stepId が入る)のを待つ。
     【なぜ待つのか ─ 2026-09-07 実測】
     rpc_case_steps(【ページング1段目】)は案件を開いた**後**に届く。届く前に
     完了を押すと stepId が無く、書き込み層(mstore-diff.js diffSteps)が
     「DBの工程行と結び付いていない」として捨てる ─ **画面は完了に見えるのに
     DBに残らない**。rpc_case_steps を8秒遅らせて完了を押すと、E2E P0-2 の
     失敗(unsupported: steps.◯◯ / status / state / stall)がそのまま再現する。
     速い機械では素通りするので、実行順や回線速度で落ちる形になっていた。
     ふつうは ensureCaseSteps が即座に返る(先読み済み)ので待ち時間は0。
     取得に失敗した・待っても埋まらないときは**そのまま進める** ─ 押した操作を
     握りつぶさないため。保存できなければ従来どおり書き込み層が理由を出す
     (「保存できません」と出すだけにしない・黙って捨てない、の両立)。 */
  const awaitStepIds = async () => {
    if (!window.MStore || !window.MStore.ensureCaseSteps) return;
    try {
      const waiting = Promise.race([
        window.MStore.ensureCaseSteps(cid, dbId),
        // 通信が返らないときに操作を握ったままにしない(押した本人が固まる)。
        new Promise(r => setTimeout(r, 8000)),
      ]);
      // 待っている間も「送信待ち」に数えさせる(押した変更はまだキューに
      // 載っていないだけ。数えないと送信待ち表示が0で嘘になる)。
      await (window.MarurouWrite && window.MarurouWrite.prepare
        ? window.MarurouWrite.prepare(waiting) : waiting);
    } catch (err) {
      console.warn("[案件詳細] 工程の追加情報(stepId等)を取得できませんでした。" +
        "この操作は保存できない場合があります。", err);
    }
  };
  // モーダルは押した瞬間に閉じる(待つのは**保存の下ごしらえ**だけ。押した手応えは
  // 従来どおり即座に返す)。upd() は待ったあとの新しい state に対して走るので、
  // 間に補充(mergeCaseSteps)が挟まっても捕まえた kind/idx/i はそのまま使える。
  const mutateStep = async fn => { const { kind, idx, i } = stepCtx; closeStep(); await awaitStepIds();
    upd(n => { const t = dTrack(n,kind,idx); const s = dSteps(t)[i]; if(!s) return; fn(t, s, i); dRecalc(t); }); };
  const stepDone = (date, memo) => { const name = ctxStep.n; mutateStep((t,s)=>{ s.s="done"; s.date=date; const nx=t.steps.find(x=>x.s!=="done"&&x.s!=="skip"); if(nx&&nx.s==="todo") nx.s="now"; });
    onToast&&onToast(name+" を完了にしました（"+date+"）"+(memo?"・メモ記録":"")); };
  // 後続を todo に戻すとき、**打ち切り(skip)は触らない。**
  //   ・skip は「やらないと決めた」事実で、完了を戻したからといって復活しない
  //   ・見送りの取り消しは stepUnskip(決-9)だけが経路。完了戻しでは触らない
  // 完了工程より後ろに skip があるトラックは 2,193本。化けるのは
  // 入金1,635 / 請求1,248 / 充当525 / 保険金請求327 / 認定288 など。
  const stepUndo = () => { const name = ctxStep.n; mutateStep((t,s,i)=>{ s.s="now"; s.date=null; t.steps.forEach((x,j)=>{ if(j>i && x.s!=="done" && x.s!=="skip") x.s="todo"; }); }); onToast&&onToast(name+" の完了を戻しました"); };
  // 決-9: 見送り取り消し。skipReason は消さない(なぜ見送ったかの記録を残す)。
  const stepUnskip = () => {
    const name = ctxStep.n;
    mutateStep((t,s)=>{ s.s="todo"; /* skipReason は残す */ });
    onToast&&onToast(name+" の見送りを取り消しました（理由の記録は残ります）");
  };
  // K18-08: 新しく見送りにする(理由は必須・StepModal が空を押させない)。s="skip" と skipReason を
  // 書き換えるだけで、書き込み翻訳(mstore-diff.js diffSteps)が step.skip{stepId, reason} に直す。
  // 相手待ちの工程を見送るときは待ちの表示も外す(サーバ 0097 が waiting_* を消すのに揃える)。
  // 見送った工程が現在地なら次の未着手を「進行中」にする(stepDone と同じ導き方・dRecalc が確定)。
  // 保存に失敗したときは書き込み層が赤帯(showSaveFail)を出す ─ 他の工程操作と同じ。
  const stepSkip = (reason) => {
    const name = ctxStep.n;
    const why = (reason == null ? "" : String(reason)).trim();
    if (!why) return;
    mutateStep((t,s)=>{ s.s="skip"; s.skipReason=why; s.waitingReason=null; s.waitingSince=null;
      const nx=t.steps.find(x=>x.s!=="done"&&x.s!=="skip"); if(nx&&nx.s==="todo") nx.s="now"; });
    onToast&&onToast(name+" を見送りにしました（"+why+"）");
  };
  // 決-10: 工程の担当。必須にしない。null で未アサインに戻せる。
  // step.assign も stepId で送る(0036)ので、完了と同じく補充を待ってから書く。
  const stepAssign = async (userId) => {
    const { kind, idx, i } = stepCtx;
    await awaitStepIds();
    upd(n => { const t = dTrack(n, kind, idx); t.steps[i].assigneeUserId = userId || null;
      // 画面上の needsAssignee 警告は、アサインしたら消す(サーバ生成列は再読込で確定)。
      if (userId) t.steps[i].needsAssignee = false;
    });
  };
  // 予定日(step.plan・0038)も stepId で送る。完了と同じ理由で補充を待つ。
  const stepPlan = async plan => { const name = ctxStep.n; const { kind, idx, i } = stepCtx; closeStep();
    onToast&&onToast(name+" の予定日を "+(plan||"未定")+" にしました");
    await awaitStepIds();
    upd(n => { const t = dTrack(n, kind, idx); t.steps[i].plan = plan || null; }); };
  // ファイル・帳票-8: 工程消込モーダル(StepModal onReceive)の受領物も、案件詳細
  // 「＋ファイルを追加」(addFiles・ファイル・帳票-2・0058)と同じ uploadAttachment
  // 経路でStorageへ実体を保存する。stepId(ctxStep.stepId=track_steps.id)を
  // 乗せることで、mstore-diff.js の diffFileAttachments が attachment.add.stepId
  // へ翻訳し(0058の attachments.step_id列。stepIdがtrackIdも自動解決するので
  // trkId は表示用にだけ渡す)、どの工程の受領物かをDBにも残す。
  // 工程の消込(step.complete)とは別opのまま、ただし**同じ保存バッチに並べる**
  // (アップロード完了を待ってから1回のupd()で両方まとめて差分に含める ─
  // upd()を2回に分けると、アップロード中はstep.completeだけ先に別バッチで飛んでしまう)。
  // アップロードそのものは実体を先にStorageへ置く別経路なので、そこだけ先に待つ。
  // 受領物にも stepId を乗せる(下)ので、ここでも補充を待ってから読む ─
  // 待たないと添付が「どの工程の受領物か」を失ったままDBに入る。
  const stepReceive = async (list, date, memo) => {
    const name = ctxStep.n, kind = stepCtx.kind, idx = stepCtx.idx, i = stepCtx.i;
    await awaitStepIds();
    // 待ったあとの store から読み直す(d はこの呼び出しの時点の描画に固定されていて、
    // 補充で入った stepId をまだ持っていない)。
    const fresh = (window.MStore && window.MStore.case(cid)) || d;
    const stepId = (((dTrack(fresh, kind, idx) || {}).steps || [])[i] || {}).stepId || null;
    const t0 = kind==="work" ? d.works[idx] : kind==="ins" ? dInsList(d)[idx||0] : d.visits[idx];
    const trkId = t0 ? t0.id : null;
    const finish = added => {
      upd(n => {
        if (added.length) n.files = [...n.files, ...added];
        const t = dTrack(n, kind, idx), s = t.steps[i];
        s.s = "done"; s.date = date;
        const nx = t.steps.find(x=>x.s!=="done"&&x.s!=="skip"); if(nx&&nx.s==="todo") nx.s="now";
        dRecalc(t);
      });
      closeStep();
      onToast&&onToast(list.length+"件を受領し "+name+" を完了にしました"+(memo?"・メモ記録":""));
    };
    if (window.MarurouCloud && window.MarurouCloud.uploadAttachment && dbId) {
      Promise.all(list.map(file =>
        window.MarurouCloud.uploadAttachment(file, { caseId: dbId }).then(meta => ({
          id: "ATT-"+Date.now()+"-"+Math.random().toString(36).slice(2,7), dbId: null,
          name: meta.fileName || file.name || "", type: guessKind(file), date: dTodayMd(),
          storagePath: meta.storagePath || null, mime: meta.mime || null,
          sizeBytes: meta.sizeBytes == null ? null : Number(meta.sizeBytes),
          trkId, stepId, stage: "received",
        })).catch(err => {
          onToast && onToast(file.name+" の添付に失敗しました("+((err&&err.message)||err)+")");
          return null;
        })
      )).then(rows => finish(rows.filter(Boolean)));
    } else {
      // 開発モード(Supabase未接続)は従来どおりローカル表示のみ(実体は保存されない)。
      const ctxLabel = kind==="work" ? (d.works[idx].room+"工事") : kind==="ins" ? "保険" : "調査";
      const added = list.map((file,k)=>({ id:"R"+Date.now()+k, name:file.name, date, ctx:ctxLabel, note:"受領", stage:"received",
        trkId, stepId, type: guessKind(file), url:URL.createObjectURL(file), mime:file.type, link:ctxLabel }));
      finish(added);
    }
  };
  // 案件詳細-16: 相手待ち。理由(reason)・いつから(since)はどちらも任意
  // (mstore-diff.jsのdiffStepsがstep.waitへ翻訳する。0065冒頭の設計判断1により
  // DB側はsinceだけ必ず今日の日付を補うが、画面のdateは従来どおり理由が
  // あればそれを、無ければ「待ち」を表示に使う ─ dOps(行87)の表示を壊さない)。
  const stepWait = (reason, since) => {
    const name = ctxStep.n;
    mutateStep((t,s)=>{ s.s="waiting"; s.date = reason || "待ち";
      s.waitingReason = reason || null; s.waitingSince = since || null; });
    onToast&&onToast(name+" を相手待ちにしました");
  };
  // 相手待ちの解除。stepUnskip(決-9)と同じく s を"todo"に戻す
  // (dRecalcが最初の未完了工程を"now"へ自動昇格させる。行67)。
  const stepResume = () => {
    const name = ctxStep.n;
    mutateStep((t,s)=>{ s.s="todo"; s.date=null; s.waitingReason=null; s.waitingSince=null; });
    onToast&&onToast(name+" の相手待ちを解除しました");
  };
  const stepReject = memo => { const name = ctxStep.n; mutateStep((t,s,i)=>{ s.s="todo"; s.date=null; if(i>0){ const p=t.steps[i-1]; p.s="now"; p.date=null; } });
    onToast&&onToast(name+" を差し戻しました"+(memo?"："+memo:"")); };
  const addRecord = r => {
    setAddKind(null);
    // 増えたトラックの工程行はいまDBにできる。「この案件は取得済み」を捨てて
    // 取り直せるようにする(書き戻し onApplied が届かなかったときの保険)。
    if (window.MStore && window.MStore.invalidateCaseSteps) window.MStore.invalidateCaseSteps(cid);
    upd(n => {
      // id はDBへの保存(app/write/mstore-diff.js の track.create 翻訳)がこのトラックを
      // 名指しするための画面内キー。保存が終わると dbId が書き戻る(case.create と同じ経路)。
      // 精算区分・方法(settle/method)は保存前の見た目のためだけの決め打ち文言で、
      // DBには置き場が無く保存できない値だった(05 §5.5「起きていないことを言わない」)。
      // 保存できるまでは空にし、採算欄は dPay の「未」表示に任せる。
      if(r.kind==="visit") n.visits.push({ id:"V"+Date.now(), no:n.visits.length+1, kind:"調査", vendor:r.a, rooms:r.b, title:r.b+" 追加調査", status:"受注中", mk:"",
        steps:dMkSteps(P1_KINDS.visit.steps), detail:{ billed:null, cost:null, due:null, paid:null } });
      // K18-12(0109): kind は工事の区分(原因箇所工事/被害箇所工事)。mstore-diff.js が track.create の
      // workCategory に、room を roomText に翻訳する(作ったあとで区分・号室は変えられない)。
      // H2(9/25): 精算方式(detail.method)と進め方(approach)は追加のモーダルで選んだ値。mstore-diff.js が
      // track.create の settlementType / approach に翻訳する(工程の型の無い組み合わせはモーダルが止める)。
      if(r.kind==="work") n.works.push({ id:"W"+Date.now(), kind:r.workKind||"被害箇所工事", room:r.room, cause:r.b, work:r.a, contractor:{kind:"self",sub:"提案"}, state:"now", stall:false, status:"見積中", mk:"",
        ...(r.approach ? { approach:r.approach } : {}),
        steps:dMkSteps(P1_KINDS.work.steps), detail:{ vendor:"未定", billed:null, cost:null, due:null, paid:null, visits:[],
          ...(r.settlement ? { method:r.settlement } : {}) } });
      // 書き込み経路-5(0062): insurerDbId(組織マスタのUUID)・policyNo(証券番号)を
      // 保険会社名(name)・保険種類(policy)と一緒に持つ(片方だけだと保存できない
      // ─ mstore-diff.js の INS_MAP/INS_NAME_REASON 参照)。
      if(r.kind==="ins") n.ins = [...dInsList(n), { id:"I"+Date.now(), holder:r.b, name:r.a,
        insurerDbId:r.insurerDbId||null, policy:r.policy, policyNo:r.policyNo||null,
        target:"", targetShort:"", remarks:"", status:"申請", mk:"",
        steps:dMkSteps(P1_KINDS.ins.steps), detail:{ approved:null, billed:null, due:null, paid:null } }];
      if(r.kind==="spot") n.spots.push({ kind:r.spotKind, room:r.room, detail:r.a, owner:r.b, isClient:false });
      if(r.kind==="party") n.parties.push({ role:r.role, name:r.a, wait:null, log:[{ d:dTodayMd(), dir:"out", t:"案件の関係者として登録" }] });
    });
    onToast && onToast(P1_KINDS[r.kind].label+"を追加しました");
  };
  const issueDoc = doc => {
    setDocType(null);
    // 工程を進められたかどうか。**進んでいないのに「発行しました」と言わない。**
    let advanced = false;
    upd(n => {
      const tt = n.works.find(w=>dSteps(w).some(s=>s.s==="now")) || n.works[0] || n.visits[0] || null;
      n.files = [...n.files, { id:"D"+Date.now(), name:doc.name, date:dTodayMd(), type:doc.type, sub:doc.type==="報告書"?"調査":doc.type==="見積書"?"工事":null,
        ctx:doc.type==="請求書"?"精算":"調査", note:doc.advance?"発行":"下書き", stage:doc.advance?"issued":"draft",
        trkId:tt?tt.id:null, sharedTo:[], link:doc.to }];
      if(doc.advance){
        // **その帳票にあたる工程を持つトラックを探す。**
        // 以前は 見積書→works[0] / それ以外→visits[0] と決め打ちで、
        // 請求書を出しても調査に「請求」工程が無いため何も起きず、
        // それでも下の onToast は無条件に「請求書を発行しました」と言っていた。
        const want = Object.keys(D_DOC_BY_FACT).filter(k => D_DOC_BY_FACT[k]===doc.type);
        const all = [...(n.works||[]), ...(n.visits||[]), ...dInsList(n)];
        const target = all.find(t => dSteps(t).some(
                         x => want.indexOf(x.factKey)>=0 && x.s!=="done" && x.s!=="skip"))
                    || all.find(t => dSteps(t).some(x => want.indexOf(x.factKey)>=0));
        const st = target && dSteps(target).find(x => want.indexOf(x.factKey)>=0);
        advanced = !!(st && st.s!=="done");
        if(st && st.s!=="done"){ st.s="done"; st.date=null; const nx=target.steps.find(x=>x.s!=="done"&&x.s!=="skip"); if(nx&&nx.s==="todo") nx.s="now"; dRecalc(target); }
      }
    });
    // 決-8: 報告書・見積は社外で作り、ここは「いつ・誰に・何を」の記録だけ。
    // 送付は人がシステム外で行う。**発行＝送付と読める文言にしない。**
    onToast && onToast(
      !doc.advance ? doc.name+"の下書きを記録しました"
      : advanced   ? doc.name+"の発行を記録し、対応する工程を進めました（送付はシステム外です）"
      // 記録はできたが、対応する工程がこの案件のどのトラックにも無かった
      // (または既に完了していた)。**黙って「発行しました」と言わない。**
      : doc.name+"の発行を記録しました（送付はシステム外です。対応する工程が無いため工程は進めていません）");
  };
  /* opts は 2 つの系統の持ち物を 1 つで運ぶ:
     K18-37(0119): isReport ─ 報告先への報告(ContactModal のチェック)。相手の関係者の前回報告日が進む(発信だけ)。
     K18-30: 端末画面の入力欄の 向き(dir)・件名・通話の結果・記録した時刻(at)・見出し(label)(Screens.jsx persistContact と同じ)。 */
  const logContact = (name, ch, body, opts) => upd(n => {
    if (detailContext.status !== "ready") { onToast && onToast("関係者を読み込み中のため、連絡記録を作成できません。"); return; }
    const p = n.parties.find(x=>x.name===name || x.name.indexOf(name)>=0);
    const x = opts || {};
    const entry = { d:dTodayMd(), dir:x.dir==="in"?"in":"out", t:ch+"："+(body||"連絡"),
      ...(x.subject?{ subject:x.subject }:{}), ...(x.callResult?{ callResult:x.callResult }:{}), ...(x.at?{ at:x.at }:{}), ...(x.label?{ label:x.label }:{}) };
    if (x.isReport && entry.dir === "out") entry.isReport = true;
    if(p){ p.log = [entry, ...p.log]; p.wait = null; } else n.parties.push({ role:"関係者", name, wait:null, log:[entry] });
  });
  const contact = t => { if(onContact) onContact({ ...t, canReport:true, onSent:(ch,body,opts)=>logContact(t.name, ch, body, opts) }); };
  /* 通知・連携-2(決-33 A・0071/0078): 関係者タブの PhoneLink を押した瞬間に
     「電話：Zoomで発信: 名前 番号」を1件残す。取引先詳細(Screens.jsx PartnerDetail の
     onPhoneCall)と**同じ文言・同じ形**にする ─ 記録の作り方はここ(呼び出し側)の
     責務で、PhoneLink 自身は書き込まない(atoms.jsx 冒頭)。
     発信そのものは記録の成否に関わらず走る(Zoom側の発信を止めない)。 */
  const onPartyCall = p => {
    const who = pName(p);
    // 記録できない状態(関係者の取得が未完了)は logContact 自身が理由を出す。
    // ここで重ねて「記録しました」と言わない ─ 嘘の完了通知を出さない。
    if (detailContext.status !== "ready") { logContact(who, "電話", ""); return; }
    logContact(who, "電話", "Zoomで発信: " + who + " " + p.phone);
    onToast && onToast(who + "への発信を記録しました");
  };
  // ファイル・帳票-2(0058)。ファイル名からの種類推測は従来のロジックをそのまま流用。
  const guessKind = file => {
    const isImg = file.type && file.type.startsWith("image");
    return isImg?"写真" : /請求/.test(file.name)?"請求書" : /見積/.test(file.name)?"見積書"
      : /発注/.test(file.name)?"発注書" : /報告/.test(file.name)?"報告書" : "その他";
  };
  const addFiles = list => {
    // Supabase接続済み(本番・E2E相当)は実体をStorageへ送ってから保存する。
    // 受付(Intake.jsx・㉚)でも登録直後に同じ uploadAttachment → addAttachment を使う。
    if (window.MarurouCloud && window.MarurouCloud.uploadAttachment && dbId) {
      list.forEach(file => {
        window.MarurouCloud.uploadAttachment(file, { caseId: dbId }).then(meta => {
          window.MStore.addAttachment(cid, { ...meta, kind: guessKind(file) });
          onToast && onToast(file.name+" を添付しました");
        }).catch(err => {
          onToast && onToast(file.name+" の添付に失敗しました("+((err&&err.message)||err)+")");
        });
      });
      return;
    }
    // 開発モード(Supabase未接続)は従来どおりローカル表示のみ(実体は保存されない)。
    const added = list.map((file,k)=>({ id:"U"+Date.now()+k, name:file.name, date:dTodayMd(), ctx:"案件全体",
      type: guessKind(file), note:"追加", url:URL.createObjectURL(file), mime:file.type, link:"案件全体" }));
    upd(n => { n.files = [...n.files, ...added]; });
    onToast && onToast(added.length+"件のファイルを添付しました(開発モード：保存はされません)");
  };
  // 添付の削除(論理削除)。dbIdを持つ行はmstore-diff.jsのdiffFileAttachmentsが
  // attachment.removeへ翻訳する。dbIdが無い行(開発モードの表示専用添付・
  // アップロード直後で保存が往復する前)はローカルから消えるだけでよい。
  const removeFile = file => {
    window.MStore.removeAttachment(cid, file.id);
    onToast && onToast(file.name+" を削除しました");
  };
  // ⑭: 行タップで FileViewer。storagePath があれば getAttachmentUrl(署名・60分)で url を足す。
  // 既に url がある(開発モードの objectURL 等)ときはそのまま開く。
  const openFilePreview = file => {
    if(!file) return;
    if(file.url || !file.storagePath || !window.MarurouCloud || !window.MarurouCloud.getAttachmentUrl){
      setViewFile(file);
      return;
    }
    setViewFileLoading(true);
    window.MarurouCloud.getAttachmentUrl(file.storagePath).then(url => {
      setViewFile({ ...file, url });
    }).catch(err => {
      console.warn("[案件詳細] 添付のプレビューURLを取得できませんでした。", err);
      setViewFile(file);
      onToast && onToast("プレビュー用のURLを取得できませんでした");
    }).finally(() => setViewFileLoading(false));
  };
  /* 活動履歴：この案件の連絡履歴(communications・0027、生画面つき)＋関係者ログ（テキスト） */
  const norm = window.scNorm || (x=>x);
  const partyEntries = React.useMemo(()=>{
    const logs = comms.rows===null ? [] : comms.rows.map(window.commRowToLog);
    const seen = new Set(logs.map(x=>x.d+x.t));
    const plain = [];
    d.parties.forEach(p=>p.log.forEach((l,j)=>{ const t = l.label || l.t; if(!seen.has(l.d+t)) plain.push({ id:"P"+p.name+j, d:l.d, dir:l.dir, who:norm(p.name), t }); }));
    return [...logs, ...plain];
  }, [d.parties, cid, comms.rows]);
  /* K18-21: 概要の右列のタイムライン(直近 8 件)。連絡履歴(0027)を新しい順に並べ、その前に今日の日付の
     関係者ログ(この画面で記録した連絡・追加した関係者 ─ 連絡履歴をまだ読み直していないもの)を置く。
     それより前の関係者ログ(「案件の関係者として登録」など)は年の無い日付しか持たず、連絡履歴と並べる順を
     決められないので混ぜない(関係者タブの活動履歴には出る)。 */
  const timeline = React.useMemo(()=>{
    const toLog = window.commRowToLog || (r => ({ id:r.id, d:"", time:"", dir:"memo", who:"", t:r.subject||r.body||"" }));
    const logs = (comms.rows||[]).slice()
      .sort((a,b)=>String(b.occurredAt||"").localeCompare(String(a.occurredAt||"")))
      .map(toLog);
    const seen = new Set(logs.map(x=>x.d+x.t));
    const today = dTodayMd();
    const fresh = [];
    (d.parties||[]).forEach(p=>(p.log||[]).forEach((l,j)=>{
      if(l.d===today && !seen.has(l.d+l.t)) fresh.push({ id:"P"+p.name+j, d:l.d, time:"", dir:l.dir, who:norm(p.name), t:l.t });
    }));
    return [...fresh, ...logs].slice(0,8).map(e=>({ id:e.id, title:e.t, by:e.who, dir:e.dir, time:e.d+(e.time?" "+e.time:"") }));
  }, [d.parties, comms.rows]);
  const insWork = d.works.find(w=>/保険/.test(w.detail.method||"") || w.detail.settle==="保険連動");
  const detailItems = (d.works.find(w=>w.detail.items) || { detail:{} }).detail.items || [];
  const insList = dInsList(d);
  // 保険は複数ありうるので合計する。認定済みは認定額、未認定は申請額。
  const insOne = i => (i.detail.approved!=null ? i.detail.approved : i.detail.billed) || 0;
  const insAmt = insList.reduce((n,i)=>n+insOne(i), 0);
  const pl = [
    // K18-06: 収支の「状態」列も新しい状態(保留・終了)を優先し、無ければ従来の t.status(SF フェーズ/導出)。
    ...d.visits.map(v=>({ name:"調査（"+v.vendor+"）", billed:v.detail.billed, cost:v.detail.cost, in:dStatusText(v), inWarn:!dAllDone(v), out:"月末締め・未払" })),
    ...d.works.map(w=>({ name:w.room+" "+w.work, billed:w.detail.billed, cost:w.detail.cost,
      in:dAllDone(w)?"入金済":w===insWork?"保険へ →":"未請求（実施後）", inWarn:w===insWork, out:"完了後・未", items:w.detail.items })),
    ...insList.map(i=>({ name:i.holder+" の保険", billed:null, cost:null,
      refAmt:"（"+(i.detail.approved!=null?yen(i.detail.approved)+" 認定":yen(i.detail.billed)+" 充当")+"）",
      in:dStatusText(i), inWarn:!dTstate(i), out:"—", isIns:true })),
  ];
  const totalBilled = pl.reduce((a,r)=>a+(r.billed||0),0), totalCost = pl.reduce((a,r)=>a+(r.cost||0),0);
  const caseTodos = (d.todos||[]).map(t=>({ id:t.id, label:t.step, to:t.dunning||t.ball, note:t.dunning?("督促 "+t.dunning):(t.ball+"の手番"),
    mk:t.marker==="delay"?"delay":t.marker==="warn"?"warn":"", key:dPanelKey(d,t.trkId), due:dDueText(t.due), dueTone:dDueTone(t.due), done:!!t.done }));
  const todos = [...caseTodos.filter(t=>!t.done), ...caseTodos.filter(t=>t.done)];
  const todoCount = caseTodos.filter(t=>!t.done).length;
  const insOps = insList.map(i=>dOps(i));
  /* ── K18-21: 案件カード(池田さん版 CaseDetailV26.jsx:336-348 の 3 列)。以前は進行表の「受付」を開いた中の
     定義行と、見出しの下の 重要事項・状況・次回連絡事項・漏水状況 の枠に分かれていたものを 1 枚にまとめた。
     main にしかない 副担当・営業担当(決-21)・建物・住所・依頼先(取引先マスタ)の一致 は残す。
     3 列目は人(本担当・副担当・営業担当)、1・2 列目は物件と案件。重要事項などの文章は 3 列をまたぐ。 */
  const dSelectStyle = { width:"100%", height:28, padding:"0 8px", borderRadius:"var(--radius-md)",
    borderWidth:1, borderStyle:"solid", borderColor:"var(--input)", fontSize:"var(--text-body-md)", fontFamily:"var(--font-sans)",
    color:"var(--foreground)", background:"var(--card)" };
  const dNote = (tone, text) => <span style={{ fontSize:"var(--text-caption)", lineHeight:1.5, color:tone }}>{text}</span>;
  const caseCard = (
    <AfCard pad={0}>
      <DCardHead title="案件" />
      <div className="mr-stack-1" data-case-card style={{ display:"grid", gridTemplateColumns:"repeat(3,minmax(0,1fr))", gap:"14px 16px", padding:"12px 16px 16px" }}>
        <DField label="物件"><b>{d.name}</b>{d.propKind ? "（"+d.propKind+"）" : ""}</DField>
        {/* 書き込み経路-2: 依頼者(依頼先)は名前＋取引先マスタID。IDが無い名前だけの変更は保存できない。 */}
        <DField label="依頼者">
          <span style={{ display:"flex", flexDirection:"column", gap:5 }}>
            <AfInput list={orgListId} aria-label="依頼者（取引先）" value={d.client==="—"?"":d.client}
              onChange={e=>{ const v = e.target.value; const hit = orgs.find(o => o.name === v);
                act.client(v, hit ? hit.id : null); }}
              placeholder={orgs.length ? "取引先を選ぶ、または新しい名前を入力" : "取引先名（マスタ未取得）"}
              style={{ width:"100%" }} />
            <datalist id={orgListId}>
              {orgs.map(o => <option key={o.id} value={o.name} />)}
            </datalist>
            {clientDbId
              ? dNote("var(--success)", "取引先マスタと一致（保存できます）"+(d.clientNote?" · "+d.clientNote:""))
              : (d.client && d.client!=="—")
                ? dNote("var(--warning)", "マスタに未登録のため、名前だけの変更はまだ保存できません")
                : dNote("var(--foreground-subtle)", orgs.length ? orgs.length+"件の取引先から選べます" : "取引先マスタを読み込めていません")}
          </span>
        </DField>
        {/* 書き込み経路-1: 案件の本担当(lead_user_id)。工程の担当とは別ラベル。
            名簿は masters().users をそのまま出す(SFシステムアカウントの除外はDB側で解く)。 */}
        <DField label="案件の本担当">
          <span style={{ display:"flex", flexDirection:"column", gap:5 }}>
            <select aria-label="案件の本担当" value={ownerDbId || ""} style={dSelectStyle}
              onChange={e=>{ const id = e.target.value || null; const hit = users.find(u => u.id === id);
                act.owner(hit ? hit.name : "", id); }}>
              <option value="">本担当を選ぶ（あとで決める）</option>
              {users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
            </select>
            {ownerDbId
              ? dNote("var(--success)", "名簿と一致（保存できます・案件全体の持ち主。工程の担当とは別）")
              : (d.owner && d.owner!=="—")
                ? dNote("var(--warning)", "案件の本担当は、名簿から選び直すと保存できます（名前だけの変更は保存できません。退職済みの可能性があります）")
                : dNote("var(--foreground-subtle)", users.length ? users.length+"人の名簿から選べます" : "担当者の名簿を読み込めていません")}
          </span>
        </DField>
        {/* 受付-12: 建物・住所は表示のみ(編集は別タスク・受付での紐付けは0015で完了済み)。
            案件名に建物名が入っていることが多い(ETLが案件名から切り出した。実データで
            案件名の100%が建物名を含む・93%は前方一致)ため、見出しには重ねて出さない。 */}
        <DField label="建物"><b style={{ color:d.building?"var(--foreground)":"var(--foreground-subtle)" }}>{dBuildingName(d)}</b></DField>
        <DField label="住所"><span style={{ color:(d.building&&d.building.address&&d.building.address!==D_ADDR_UNKNOWN)?"var(--foreground)":"var(--foreground-subtle)" }}>{dBuildingAddr(d)}</span></DField>
        {/* 決-21(追-4-3): 副担当(sub_user_id)。案件の本担当と同じ部品・同じ流儀。 */}
        <DField label="副担当">
          <span style={{ display:"flex", flexDirection:"column", gap:5 }}>
            <select aria-label="副担当" value={subDbId || ""} style={dSelectStyle}
              onChange={e=>{ const id = e.target.value || null; const hit = users.find(u => u.id === id);
                act.sub(hit ? hit.name : "", id); }}>
              <option value="">副担当を選ぶ（未定でも可）</option>
              {users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
            </select>
            {subDbId
              ? dNote("var(--success)", "名簿と一致（保存できます）")
              : (d.sub && d.sub!=="—")
                ? dNote("var(--warning)", "現在の副担当「"+d.sub+"」は名簿(在職者)に見当たりません。選び直すと保存できます（退職済みの可能性があります）")
                : dNote("var(--foreground-subtle)", "未設定")}
          </span>
        </DField>
        <DField label="受付"><span className="mono">{d.accepted}</span></DField>
        <DField label="依頼内容">{d.order}</DField>
        {/* 決-21(追-4-3): 営業担当(sales_user_id)。同上。 */}
        <DField label="営業担当">
          <span style={{ display:"flex", flexDirection:"column", gap:5 }}>
            <select aria-label="営業担当" value={salesDbId || ""} style={dSelectStyle}
              onChange={e=>{ const id = e.target.value || null; const hit = users.find(u => u.id === id);
                act.sales(hit ? hit.name : "", id); }}>
              <option value="">営業担当を選ぶ（未定でも可）</option>
              {users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
            </select>
            {salesDbId
              ? dNote("var(--success)", "名簿と一致（保存できます）")
              : (d.sales && d.sales!=="—")
                ? dNote("var(--warning)", "現在の営業担当「"+d.sales+"」は名簿(在職者)に見当たりません。選び直すと保存できます（退職済みの可能性があります）")
                : dNote("var(--foreground-subtle)", "未設定")}
          </span>
        </DField>
        <DField label="対応モード">{d.mode}</DField>
        {/* K18-13(0105): 漏水状況(cases.leak_status)。SF の picklist から選ぶ(『漏水なし』は答えが出るまで残す)。
            選ぶとそのまま案件直下の leakStatus が変わり、書き込み層が case.update の leak_status に翻訳する
            (mstore-diff.js CASE_FIELD_MAP。未設定 '' は NULL)。「継続中」は 常時・時々 のとき ─
            盤面・ダッシュボードの印と同じ規則(store.jsx isLeakContinuing)。 */}
        <DField label="漏水状況" labelColor={leakContinuing?"var(--destructive)":undefined} labelWeight={leakContinuing?700:undefined}>
          <span data-case-leak={leakStatus} style={{ display:"inline-flex", alignItems:"center", gap:6, minWidth:0, flexWrap:"wrap" }}>
            <select aria-label="漏水状況" value={leakStatus} onChange={e=>act.caseText("leakStatus", e.target.value)}
              style={{ ...dSelectStyle, width:"auto", maxWidth:"100%", fontSize:"var(--text-body-sm)",
                color:leakStatus?"var(--foreground)":"var(--foreground-subtle)" }}>
              <option value="">未設定</option>
              {leakOptions.map(v => <option key={v} value={v}>{v}</option>)}
            </select>
            {leakContinuing && <DChip tone="danger" dot style={{ fontWeight:700 }}>継続中</DChip>}
          </span>
        </DField>
        <DField label="初期報">{d.initial}</DField>
        {/* K18-02(0093/0094): 重要事項・状況・次回連絡事項。SF の juuyoujikou__c / joukyou__c /
            jikai_renrakujikou__c の受け皿(cases.key_notes / situation / next_contact_note)。
            3 つは別々の列(1 欄に結合しない)。クリックでその場で書き換え(EdText・多行)、
            書き込み層が case.update に翻訳する(mstore-diff.js CASE_FIELD_MAP)。保存に失敗すれば
            他の欄と同じく書き込み層が赤帯(showSaveFail)を出す ─ ここで別の合図は出さない。
            改行を保つため white-space は pre-wrap(EdText の表示側は継承する)。
            重要事項が入っている案件はラベルを警告色にする(要注意の経緯・クレーム履歴の置き場)。 */}
        {[
          ["keyNotes",        "重要事項",     "要点・経緯・注意点（例：被害復旧のみ依頼、クレーム履歴）", !!d.keyNotes],
          ["situation",       "状況",         "いまの状況", false],
          ["nextContactNote", "次回連絡事項", "次に連絡すること", false],
        ].map(([key, label, ph, warn]) => (
          <DField key={key} label={label} wide labelColor={warn?"var(--warning)":undefined} labelWeight={warn?700:undefined}>
            <span data-case-note={key} style={{ whiteSpace:"pre-wrap", display:"block", minWidth:0, overflowWrap:"anywhere" }}>
              <EdText multiline value={d[key]||""} placeholder={ph} size="var(--text-body-sm)"
                color={d[key]?"var(--foreground)":undefined} onSave={val=>act.caseText(key, val)} />
            </span>
          </DField>
        ))}
        <DField label="報告先" wide>
          <span style={{ display:"flex", alignItems:"center", gap:5, flexWrap:"wrap" }}>
            {(d.reportTo||[]).map((n,i)=>(
              <span key={i} style={{ display:"inline-flex", alignItems:"center", gap:4 }}>
                <DChip tone="brand">{n}</DChip>
                <button onClick={()=>upd(x=>{ x.reportTo = (x.reportTo||[]).filter(y=>y!==n); })}
                  title="報告先から外す" style={{ all:"unset", cursor:"pointer", fontSize:9, color:"var(--foreground-subtle)" }}>✕</button>
              </span>
            ))}
            {(d.reportTo||[]).length===0 && <span style={{ fontSize:"var(--text-body-sm)", color:"var(--foreground-subtle)" }}>未設定</span>}
            {(()=>{
              const nm = x => (window.scNorm ? window.scNorm(x) : x);
              const have = (d.reportTo||[]).map(nm);
              const cands = [...new Set(d.parties.map(p=>nm(p.name)))].filter(n=>have.indexOf(n)<0);
              return cands.map((n,i)=>(
                <button key={i} onClick={()=>upd(x=>{ x.reportTo = [...new Set([...(x.reportTo||[]), n])]; })}
                  style={{ all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)", fontSize:"var(--text-caption)", fontWeight:600, color:"var(--brand)" }}>＋{n}</button>
              ));
            })()}
          </span>
        </DField>
      </div>
    </AfCard>
  );
  /* ── K18-21: タイムライン(右列・直近 8 件)。全件は「過去のやり取り」タブ。読めないときは黙って空にしない。 */
  const timelineCard = (
    <AfCard pad={0}>
      <DCardHead title="タイムライン" sub="直近 8 件"
        action={<button type="button" onClick={()=>setTab("comms")} style={{ all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)",
          fontSize:"var(--text-caption)", fontWeight:600, color:"var(--brand)" }}>すべて見る</button>} />
      <div data-case-timeline style={{ padding:"12px 16px 4px" }}>
        {comms.err
          ? <div style={{ paddingBottom:12 }}><AfAlert variant="notice" title="連絡履歴を読めませんでした">{comms.err}</AfAlert></div>
          : comms.rows===null && !timeline.length
            ? <div style={{ paddingBottom:12, fontSize:"var(--text-body-sm)", color:"var(--foreground-subtle)" }}>連絡履歴を読み込み中…</div>
            : timeline.length
              ? <DTimeline items={timeline} />
              : <div style={{ paddingBottom:12, fontSize:"var(--text-body-sm)", color:"var(--foreground-subtle)" }}>連絡の記録はまだありません</div>}
      </div>
    </AfCard>
  );
  /* ── K18-22: 原因／被害の表(池田さん版 CaseDetailV26.jsx:248-251 の 区分・号室・箇所・所有者・制限)と「＋ 箇所」。
     main の 種別・入館・鍵・備考・削除 は表の列として残す(盤面DB-2・0063 の構造化列と K-11・0085 の論理削除)。
     制限は 被害/両方 の行に居住制限(K18-14・3 値)、原因/両方 の行に利用制限(K18-15・複数選択)。
     「生活できず避難」(住めない)は赤。その案件の保険精算の復旧工事には段階サマリと進行表に「P1 が目安」(K18-16)。 */
  const spotTh = { textAlign:"left", padding:"7px 8px", fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.03em",
    color:"var(--muted-foreground)", borderBottom:"1px solid var(--border-strong)", whiteSpace:"nowrap" };
  const spotTd = { padding:"7px 8px", verticalAlign:"top", borderBottom:"1px solid var(--border)" };
  const spotSub = { fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" };
  const spotsCard = (
    <AfCard pad={0}>
      <DCardHead title="原因／被害" count={d.spots.length}
        action={<button type="button" onClick={()=>act.add("spot")} style={{ all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)",
          fontSize:"var(--text-caption)", fontWeight:600, color:"var(--brand)" }}>＋ 箇所</button>} />
      {d.spots.length===0
        ? <div style={{ padding:"12px 16px 14px", fontSize:"var(--text-body-sm)", color:"var(--foreground-subtle)" }}>箇所が未登録</div>
        : (
          <div style={{ overflowX:"auto", padding:"0 8px 6px" }}>
            <table data-spots-table style={{ width:"100%", minWidth:820, borderCollapse:"collapse", fontSize:"var(--text-body-sm)" }}>
              <thead>
                <tr>{["区分","号室","箇所","所有者","制限","種別","入館","鍵","備考"].map(h=><th key={h} scope="col" style={spotTh}>{h}</th>)}
                  <th scope="col" style={spotTh}><span style={{ position:"absolute", width:1, height:1, overflow:"hidden", clip:"rect(0 0 0 0)" }}>削除</span></th></tr>
              </thead>
              <tbody>
                {d.spots.map((s,i)=>(
                  <tr key={i} data-spot-row={s.room} data-spot-kind={s.kind}>
                    <td data-spot-col="区分" style={spotTd}><DChip tone={s.kind==="原因" ? "work" : s.kind==="被害" ? "brand" : "neutral"}>{s.kind}</DChip></td>
                    <td data-spot-col="号室" style={{ ...spotTd, fontWeight:700, whiteSpace:"nowrap" }} className="mono">{s.room}</td>
                    {/* 案件詳細-12(0029): 箇所はその場で書き換え(spots.update)。 */}
                    <td data-spot-col="箇所" style={{ ...spotTd, minWidth:130 }}><EdText value={s.detail} bold color="var(--foreground)" size="var(--text-body-sm)" onSave={val=>act.spot(i,{ detail:val })} /></td>
                    <td data-spot-col="所有者" style={{ ...spotTd, minWidth:90 }}>
                      <span style={{ display:"inline-flex", alignItems:"center", gap:5, flexWrap:"wrap" }}>
                        <span style={{ color:"var(--muted-foreground)" }}>{s.owner}</span>
                        {s.isClient && <DChip tone="success">依頼者</DChip>}
                      </span>
                    </td>
                    <td data-spot-col="制限" style={{ ...spotTd, minWidth:190 }}>
                      <span style={{ display:"flex", flexDirection:"column", alignItems:"flex-start", gap:5 }}>
                        {/* K18-14(0107/0108): 居住制限(被害/両方の対象部屋だけ)。旧「住めない」チップ(is_livable)は
                            ここに統合した ─ is_livable は居住制限から DB が導く値になったため。 */}
                        {s.kind !== "原因" && <DLivingRestriction value={s.livingRestriction} room={s.room}
                          onChange={val=>act.spot(i,{ livingRestriction:val })} />}
                        {/* K18-15(0107/0108): 利用制限(原因/両方の対象部屋だけ・複数選択)。表の中なので選択肢はその場に開く。 */}
                        {s.kind !== "被害" && <DUsageRestrictions inline value={s.usageRestrictions} room={s.room}
                          onChange={val=>act.spot(i,{ usageRestrictions:val })} />}
                      </span>
                    </td>
                    {/* 盤面DB-2(0063): 原因種別・立入方法・鍵情報・備考。case_target_rooms の構造化列にそのまま対応する
                        (対応表はdocs/02-data-model.md)。原因種別は「立場=原因」grouping(0002)に合わせ、被害のみの行では
                        出さない(spotToPayloadの書き込み側ガードと揃える)。 */}
                    <td data-spot-col="種別" style={spotTd}>{s.kind !== "被害"
                      ? <EdText value={s.causeKind} placeholder="—" size="var(--text-caption)" onSave={val=>act.spot(i,{ causeKind:val })} />
                      : <span style={spotSub}>—</span>}</td>
                    <td data-spot-col="入館" style={spotTd}><EdText value={s.entryMethod} placeholder="—" size="var(--text-caption)" onSave={val=>act.spot(i,{ entryMethod:val })} /></td>
                    <td data-spot-col="鍵" style={spotTd}><EdText value={s.keyInfo} placeholder="—" size="var(--text-caption)" onSave={val=>act.spot(i,{ keyInfo:val })} /></td>
                    <td data-spot-col="備考" style={spotTd}><EdText value={s.note} placeholder="—" size="var(--text-caption)" onSave={val=>act.spot(i,{ note:val })} /></td>
                    {/* 案件詳細-12(K-11・0085): この対象部屋を消す。押した瞬間に保存へ回る
                        (spots.remove ─ 論理削除なので訪問・添付の記録は残る)。 */}
                    <td data-spot-col="削除" style={{ ...spotTd, textAlign:"right" }}>
                      <button onClick={()=>act.spotRemove(i)} aria-label={`対象部屋を削除 ${s.room}`} title="この箇所を削除"
                        style={{ all:"unset", cursor:"pointer", padding:"0 4px", fontFamily:"var(--font-sans)",
                          fontSize:"var(--text-caption)", fontWeight:700, color:"var(--foreground-subtle)" }}>×</button>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
    </AfCard>
  );
  return (
    <DFieldCtx.Provider value={field}>
    {/* K18-21: 概要を 2 列(案件カード｜タイムライン)にしたので、池田さん版(最大 1280)に寄せて広げた(以前は 980)。 */}
    <div className={field?"mr-field mr-page-pad":"mr-page-pad"} style={{ padding:"18px 24px 32px 20px", maxWidth:1200 }}>
      {detailContext.status!=="ready" && <div role="status" style={{ marginBottom:10, 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(cid, dbId).then(()=>setDetailContext({status:"ready"})).catch(error=>setDetailContext({status:"error",error})); }}>再試行</AfButton>}
      </div>}
      {field && <style>{".mr-field button,.mr-field [role=button],.mr-field [role=tab],.mr-field [role=radio]{min-height:var(--height-topnav)!important}.mr-field .mr-rail button{min-height:var(--height-topnav)!important}.mr-field input,.mr-field textarea{min-height:var(--height-topnav)!important;font-size:var(--text-body-lg)}"}</style>}
      <div className="mr-flex-col-mobile" style={{ display:"flex", alignItems:"center", gap:10, paddingBottom:9, marginBottom:11, borderBottom:"1px solid var(--border)", flexWrap:"wrap" }}>
        <button onClick={onBack} style={{ all:"unset", fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)", fontWeight:600, color:"var(--brand)", cursor:"pointer", whiteSpace:"nowrap", flexShrink:0 }}>← 案件一覧</button>
        <button onClick={()=>setField(!field)} title="現場モード：タップ領域を48px以上に拡大"
          style={{ all:"unset", marginLeft:"auto", fontFamily:"var(--font-sans)", fontSize:"var(--text-caption)", fontWeight:700, cursor:"pointer",
            minHeight:"var(--height-tap)", padding:"4px 10px", display:"inline-flex", alignItems:"center",
            borderRadius:"var(--radius-full)", border:"1px solid "+(field?"var(--brand)":"var(--border)"),
            background:field?"var(--brand-muted)":"transparent", color:field?"var(--brand)":"var(--muted-foreground)" }}>現場モード {field?"ON":"OFF"}</button>
        <span style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)", whiteSpace:"nowrap" }}>案件の本担当 {d.owner}　<span className="mono">今日 {window.bvTodayLabel ? window.bvTodayLabel() : ""}</span></span>
      </div>

      <div style={{ minWidth:0 }}>
        <div style={{ display:"flex", alignItems:"center", gap:7, rowGap:3, flexWrap:"wrap", fontSize:"var(--text-caption)", lineHeight:1.6, color:"var(--foreground-subtle)" }}>
          <span className="code" style={{ color:"var(--brand)", fontWeight:600 }}>{d.id}</span>
          <span>{d.type}</span><span>·</span><span>{d.order}</span><span>·</span>
          <span>受付 <span className="mono">{d.accepted}</span></span><span>·</span>
          {/* K18-17(0110): 受付チャネル(cases.intake_channel)。受付で選んで保存する。ここは表示だけ。 */}
          <span data-case-intake-channel={d.intakeChannel||""}>受付チャネル {d.intakeChannel
            ? <b style={{ color:"var(--foreground)", fontWeight:600 }}>{d.intakeChannel}</b>
            : <span style={{ color:"var(--foreground-subtle)" }}>未設定</span>}</span><span>·</span>
          <span>案件の本担当 {d.owner}</span>
          <span>·</span>
          <span>Drive {d.driveUrl
            ? <a href={d.driveUrl} target="_blank" rel="noreferrer" style={{ color:"var(--brand)" }}>開く</a>
            : <span style={{ color:"var(--foreground-subtle)" }}>未設定</span>}</span>
          <span>·</span>
          <span>Slack {d.slackUrl
            ? <a href={d.slackUrl} target="_blank" rel="noreferrer" style={{ color:"var(--brand)" }}>開く</a>
            : <span style={{ color:"var(--foreground-subtle)" }}>未設定</span>}</span>
        </div>
        <div style={{ display:"flex", alignItems:"center", gap:10, marginTop:4, flexWrap:"wrap" }}>
          <h1 style={{ margin:0, fontSize:"var(--text-display-md)", fontWeight:700, letterSpacing:"var(--tracking-tight)", lineHeight:1.3 }}>{d.name}</h1>
          {/* K18-21: 段階のチップ(池田さん版の見出しの Chip)。盤面の段階と同じ規則・同じ色。 */}
          <span data-case-stage={caseStage}><DChip tone={D_STAGE_TONE[caseStage]||"neutral"}>{caseStage}</DChip></span>
          <DSlaBadge st={slaSt} />
          {stage.stall && <DMark mk="delay" />}
          {/* K18-05(0098): 全トラックが終了(失注・適用外を含む)しているのに閉じていない案件の印。
              閉じる操作は下の「完了」(closedFlag)のまま ─ ここは印だけ。 */}
          {!closed && d.derivedEnded && (
            <span title={d.derivedDone ? "全トラックが終了(完了・不要)しています" : "全トラックが終了しています(失注・適用外を含む)"}
              style={{ ...dChipBase, color:"var(--success)", background:"var(--success-muted)", border:"1px solid var(--success-border)" }}>
              全部終わっています。閉じますか
            </span>
          )}
          {/* K18-21: 池田さん版の見出しの ＋TODO / ＋トラック。TODO は TODO タブの「＋ TODO追加」と同じモーダル、
              トラックは種類(調査・工事・保険)を選んで、進行表の「＋ 〜を追加」と同じ追加モーダル(AddRecordModal)を開く。 */}
          <span ref={trackMenuRef} style={{ marginLeft:"auto", display:"inline-flex", alignItems:"center", gap:8, position:"relative" }}>
            {onAddTask && <AfButton onClick={()=>onAddTask()}>＋ TODO</AfButton>}
            <AfButton variant="primary" aria-expanded={trackMenu} aria-haspopup="true"
              onClick={()=>setTrackMenu(v=>!v)}>＋ トラック</AfButton>
            {trackMenu && (
              <span role="group" aria-label="追加するトラックの種類" style={{ position:"absolute", top:"100%", right:0, zIndex:30, marginTop:4,
                display:"flex", flexDirection:"column", minWidth:150, padding:4, background:"var(--card)",
                border:"1px solid var(--border)", borderRadius:"var(--radius-md)", boxShadow:"0 6px 18px rgba(0,0,0,0.12)" }}>
                {[["visit","調査（稼働）を追加"],["work","工事を追加"],["ins","保険を追加"]].map(([k,label])=>(
                  <button key={k} type="button" onClick={()=>{ setTrackMenu(false); act.add(k); }}
                    style={{ all:"unset", cursor:"pointer", padding:"7px 10px", borderRadius:"var(--radius-sm)", whiteSpace:"nowrap",
                      fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)", fontWeight:600, color:"var(--foreground)" }}
                    onMouseEnter={e=>e.currentTarget.style.background="var(--accent)"}
                    onMouseLeave={e=>e.currentTarget.style.background="transparent"}>{label}</button>
                ))}
              </span>
            )}
          </span>
        </div>
      </div>

      {/* K18-18: 依頼者規定の帯(ヘッダ直下・どのタブでも出す)。取引条件・初動対応注意点と、補足の報告投稿先。
          値は依頼者の取引先から読むだけ(rpc_case_detail_context の clientRules・0106)で、案件側に写さない。
          部品と読み方は ClientRulesBand.jsx に 1 か所。値が無ければ何も出さない。 */}
      <CaseClientRulesBand where="case" caseId={cid} clientDbId={d.clientDbId || null} clientName={d.client}
        ready={detailContext.status==="ready"} fields={["tradeTerms","firstResponseNotes"]} showSystem style={{ marginTop:11 }} />

      <DTabs tab={tab} setTab={setTab} counts={{ todo:todoCount, files:d.files.length, parties:d.parties.length, comms:(comms.rows||[]).length }} />

      {tab==="overview" && (
        <div style={{ display:"flex", flexDirection:"column", gap:14 }}>
        {/* K18-22: 段階サマリ(受付→調査→工事→完了 と 調査・工事の箱)。 */}
        <DStageSummary d={d} closed={closed} />
        {/* 進行(調査・工事・保険・完了)。受付の定義行は K18-21 で下の案件カードへ移した。 */}
        <AfCard pad={0}>
          <div style={{ padding:"14px 16px 16px", overflowX:"auto" }}>
            <div style={{ minWidth:712 }}>
            <div style={{ display:"grid", gridTemplateColumns:D_TREE, gap:7, padding:"0 8px 6px 4px", marginLeft:21, borderBottom:"1px solid var(--border-strong)" }}>
              <span></span>
              <h2 style={{ margin:0, fontSize:"var(--text-body-md)", fontWeight:700, letterSpacing:"0.02em" }}>進行</h2>
              {["実務","精算","方法"].map(h=><span key={h} style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.04em", color:"var(--muted-foreground)" }}>{h}</span>)}
              <span></span>
            </div>

            {/* 調査。報告日が無い調査が実データにある(SFに日付が入っていない)。
                そのまま連結すると「完了 null」と出るので、日付が無ければ「完了」だけにする。 */}
            <DPhaseRow first state={stage.surveyDone?"done":d.visits.length?"now":"todo"} label="調査"
              note={stage.surveyDone?("完了"+(stage.surveyDate?" "+stage.surveyDate:"")):d.visits.length?"対応中":"未着手"}
              noteTone={stage.surveyDone?"var(--success)":d.visits.length?"var(--brand)":"var(--foreground-subtle)"} />
            {d.visits.map((v,i)=>{
              const k = "v"+i;
              return (
                <React.Fragment key={k}>
                  <DTreeRow open={isOpen(k)} name={v.title||v.vendor} mk={v.mk} ops={dOps(v)} pay={dPay(v)} method={v.detail.method}
                    ts={dTstate(v)} onToggle={()=>toggle(k)} jump={onJump?()=>onJump("survey"):null} />
                  {isOpen(k) && (
                    <DTreeBody>
                      <DTrackState t={v} kind="visit" label={"調査 "+(v.title||v.vendor||"")} onChange={ts=>act.trackState("visit",i,ts)} />
                      <div style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.06em", color:"var(--foreground-subtle)", marginBottom:5 }}>依頼内容</div>
                      <div style={{ display:"flex", flexDirection:"column", gap:5, marginBottom:10, paddingBottom:9, borderBottom:"1px solid var(--border)" }}>
                        {d.surveyTasks.map((t,j)=>{
                          const done = t.state==="done";
                          return (
                            <div key={j} style={{ display:"flex", alignItems:"baseline", gap:8, flexWrap:"wrap" }}>
                              <button onClick={()=>act.task(j,{ state:done?"todo":"done", date:done?null:dTodayMd() })} title={done?"完了を戻す":"完了にする"}
                                style={{ all:"unset", cursor:"pointer", transform:"translateY(1px)", flexShrink:0 }}><ConfirmMark ok={done} /></button>
                              <span style={{ fontSize:"var(--text-body-md)", fontWeight:700, minWidth:56 }}>{t.name}</span>
                              {done ? <span className="mono" style={{ fontSize:"var(--text-caption)", color:"var(--success)" }}>完了 {t.date}</span>
                                    : <span style={{ fontSize:"var(--text-caption)", color:"var(--brand)" }}>次調査で対応</span>}
                              <span style={{ flex:1, minWidth:150, fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)" }}>
                                {/* 案件詳細-14(0060): survey.updateで保存できるようになったため、
                                    「下書き」表示(EdDraft)を外した。 */}
                                <EdText value={t.memo} size="var(--text-body-sm)" onSave={val=>act.task(j,{ memo:val })} placeholder="一言メモ" />
                              </span>
                            </div>
                          );
                        })}
                      </div>
                      <DDef label="稼働"><DChip>{v.kind}{v.no>1?" "+v.no:""}</DChip>
                        <div>
                          <DVendorPick value={v.vendor} dbId={v.vendorDbId} label="調査業者"
                            onSave={org=>act.vendor("visit",i,org)} />
                          <DVendorHistory trackDbId={v.dbId} rows={vendorChanges} />
                        </div>
                        <span>対象 <b className="mono" style={{ color:"var(--foreground)" }}>{v.rooms}</b></span></DDef>
                      <DTrackContacts caseId={cid} trackDbId={v.dbId} ready={detailContext.status==="ready"} kind="survey" orgDbId={v.vendorDbId} onToast={onToast} />
                      <DDef label="採算"><span style={{ display:"flex", alignItems:"center", gap:5 }}>請求額 <EdMoney value={v.detail.billed} label="請求額" onSave={val=>act.money("visit",i,"billed",val)} /></span>
                        <span style={{ display:"flex", alignItems:"center", gap:5 }}>原価 <EdMoney value={v.detail.cost} label="原価" onSave={val=>act.money("visit",i,"cost",val)} /></span></DDef>
                      <DDef label="精算方法">
                        <DMethodSelect value={v.detail.method} label={(v.title||v.vendor)+"の精算方法"}
                          onChange={val=>act.method("visit",i,v.detail.method,val)} />
                      </DDef>
                      <DVisits visits={v.detail.visits} onAdd={()=>act.visitAdd("visit",i)}
                        onEdit={(j,patch)=>act.visit("visit",i,j,patch)} onRemove={j=>act.visitRemove("visit",i,j)} />
                      <DExpand t={v} onStep={i2=>act.step("visit",i,i2)} />
                    </DTreeBody>
                  )}
                </React.Fragment>
              );
            })}
            <button onClick={()=>act.add("visit")} style={{ all:"unset", fontFamily:"var(--font-sans)", fontSize:"var(--text-caption)", fontWeight:600, color:"var(--brand)", cursor:"pointer", padding:"7px 0 0 25px", display:"inline-block" }}>＋ 調査（稼働）を追加</button>

            {/* 工事 */}
            <DPhaseRow state={stage.worksDone?"done":d.works.length?"now":"todo"} label="工事"
              note={stage.worksDone?"完了":d.works.length?"対応中":"未着手"}
              noteTone={stage.worksDone?"var(--success)":d.works.length?"var(--brand)":"var(--foreground-subtle)"} />
            {d.works.map((w,i)=>{
              const k = "w"+i, ct = dCt(w.contractor);
              return (
                <React.Fragment key={k}>
                  <DTreeRow open={isOpen(k)} name={w.room+" "+w.work} mk={w.mk} ops={dOps(w)} pay={dPay(w)} method={w.detail.method}
                    ts={dTstate(w)} onToggle={()=>toggle(k)} jump={onJump?()=>onJump("work"):null} />
                  {isOpen(k) && (
                    <DTreeBody>
                      <DTrackState t={w} kind="work" label={"工事 "+w.room+" "+w.work} onChange={ts=>act.trackState("work",i,ts)} />
                      {/* K18-12(0110): 区分と号室(rpc_seed の works[].kind / room)。追加のモーダルで選ぶ。
                          作ったあとで変える口は無い(区分を変えるとルートが変わる ─ K18-73)。 */}
                      <DDef label="区分"><b data-work-kind={w.kind||""} data-work-track={w.dbId||w.id} style={{ color:"var(--foreground)" }}>{w.kind||"—"}</b>
                        <span>号室 <span className="mono" data-work-room={w.room||""}>{w.room||"—"}</span></span></DDef>
                      <DDef label="内容">
                        <EdDraft why="工事の原因は調査側の原因コメントに書きます（ここからは保存できません）"><EdText value={w.cause} size="var(--text-body-sm)" onSave={val=>act.work(i,{ cause:val })} /></EdDraft>
                        <span>→</span>
                        <EdText value={w.work} size="var(--text-body-md)" bold color="var(--foreground)" onSave={val=>act.work(i,{ work:val })} />
                      </DDef>
                      <DDef label="施工者">
                        <div>
                          <DVendorPick value={w.detail && w.detail.vendor || w.vendor} dbId={w.vendorDbId} label="施工者"
                            onSave={org=>act.vendor("work",i,org)} />
                          <DVendorHistory trackDbId={w.dbId} rows={vendorChanges} />
                        </div>
                        <EdDraft why="施工者区分の編集はまだ保存できません">
                          <button onClick={()=>act.contractor(i)} title="施工者区分を切り替え（自社・確定／自社・提案／他社＝失注）。保存はまだできません"
                            style={{ all:"unset", cursor:"pointer" }}><DChip tone={ct.tone}>{ct.text}</DChip></button>
                        </EdDraft>
                      </DDef>
                      <DTrackContacts caseId={cid} trackDbId={w.dbId} ready={detailContext.status==="ready"} kind="work" orgDbId={w.vendorDbId} onToast={onToast} />
                      <DDef label="採算"><span style={{ display:"flex", alignItems:"center", gap:5 }}>請求額 <EdMoney value={w.detail.billed} label="請求額" onSave={val=>act.money("work",i,"billed",val)} /></span>
                        <span style={{ display:"flex", alignItems:"center", gap:5 }}>原価 <EdMoney value={w.detail.cost} label="原価" onSave={val=>act.money("work",i,"cost",val)} /></span></DDef>
                      <DDef label="精算方法">
                        <DMethodSelect value={w.detail.method} label={(w.room+" "+w.work)+"の精算方法"}
                          onChange={val=>act.method("work",i,w.detail.method,val)} />
                      </DDef>
                      {w.plan && <DDef label="進め方"><b style={{ color:"var(--foreground)" }}>{w.plan}</b></DDef>}
                      {/* K18-16(0111): 住めない部屋がある案件の保険精算の復旧工事に「進め方は P1 が目安」。 */}
                      <DApproachHint c={d} w={w} />
                      {w.stall && <div style={{ display:"flex", alignItems:"center", gap:6, fontSize:"var(--text-body-sm)", fontWeight:600, color:"var(--warning)", marginTop:4 }}><DMark mk="delay" />{w.stallWhy}</div>}
                      <DVisits visits={w.detail.visits} onAdd={()=>act.visitAdd("work",i)}
                        onEdit={(j,patch)=>act.visit("work",i,j,patch)} onRemove={j=>act.visitRemove("work",i,j)} />
                      <DExpand t={w} onStep={i2=>act.step("work",i,i2)} />
                      {insList.length>0 && w===insWork && (
                        <button onClick={()=>{ if(!isOpen("ins")) toggle("ins"); }}
                          style={{ all:"unset", fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)", cursor:"pointer", marginTop:8, display:"inline-block" }}>
                          精算：保険充当 <b style={{ color:"var(--foreground)" }}>{yen(insAmt)}</b>（{insList.map(i=>i.holder).join("・")}）<b style={{ color:"var(--warning)" }}>{dStatusText(insList[0])}</b> → 保険行へ ▸
                        </button>
                      )}
                    </DTreeBody>
                  )}
                </React.Fragment>
              );
            })}
            <button onClick={()=>act.add("work")} style={{ all:"unset", fontFamily:"var(--font-sans)", fontSize:"var(--text-caption)", fontWeight:600, color:"var(--brand)", cursor:"pointer", padding:"7px 0 0 25px", display:"inline-block" }}>＋ 工事を追加</button>
            {/* 案件詳細-25: app.jsx は onDispatch→DispatchModal を渡しているが、
                ここで呼ぶボタンが無くモーダルに到達不能だった。保存RPC(0035)は済。 */}
            {onDispatch && (
              <div style={{ padding:"8px 0 0 25px" }}>
                <AfButton size="sm" onClick={()=>onDispatch()}>下請を手配</AfButton>
              </div>
            )}

            {/* 保険（フェーズ同格の併走行・保険が付く案件のみ） */}
            {/* K18-06: 保険の見出しの補足は、新しい状態(保留・終了)があればそれを優先し、
                無ければ従来どおり SF フェーズ(legacy_phase=ins.status)を出す。 */}
            {insList.length>0 && <DPhaseRow state={insList.every(dAllDone)?"done":"warn"} label="保険"
              note={insList.length>1 ? insList.length+"件" : dStatusText(insList[0])}
              noteTone={insList.length===1 && dTstate(insList[0]) ? "var(--muted-foreground)" : "var(--warning)"} />}
            {insList.map((ins,ii)=>(
              <React.Fragment key={ins.id||ii}>
                <DTreeRow open={isOpen("ins"+ii)} name={ins.holder+" の保険"} sub={ins.name} mk={ins.mk}
                  ops={insOps[ii]} pay={{ text:(insWork?insWork.room+"工事へ ":"")+yen(insOne(ins)), tone:"var(--warning)" }}
                  method={ins.name} ts={dTstate(ins)}
                  onToggle={()=>toggle("ins"+ii)} jump={onJump?()=>onJump("ins"):null} />
                {isOpen("ins"+ii) && (
                  <DTreeBody>
                    {/* 保険トラックにも状態の操作を出す(K18-06)。「適用外」はここだけで選べる。 */}
                    <DTrackState t={ins} kind="ins" label={"保険 "+(ins.name||"")} onChange={ts=>act.trackState("ins",ii,ts)} />
                    <DDef label="契約者"><b style={{ color:"var(--foreground)" }}>{ins.holder}</b><DChip>{ins.policy}</DChip></DDef>
                    {/* 書き込み経路-5(0062): 保険会社は組織マスタから選び直せる(client/ownerと同型)。
                        証券番号(policyNo)は自由入力の新設欄 ─ 既存の「保険種類」(ins.policy。上のDChip)とは別物。 */}
                    <DDef label="保険会社"><DInsurerPick value={ins.name} dbId={ins.insurerDbId} onSave={org=>act.insurer(ii,org)} /></DDef>
                    <DTrackContacts caseId={cid} trackDbId={ins.dbId} ready={detailContext.status==="ready"} kind="ins" orgDbId={ins.insurerDbId} orgName={ins.name} onToast={onToast} />
                    <DDef label="証券番号"><EdText value={ins.policyNo} placeholder="未入力" size="var(--text-body-sm)" onSave={val=>act.text("ins",ii,"policyNo",val)} /></DDef>
                    <DDef label="対象">{ins.target}</DDef>
                    <DDef label="認定額"><EdMoney value={ins.detail.approved} label="認定額" onSave={val=>act.money("ins",ii,"approved",val)} /></DDef>
                    <DVisits visits={ins.detail.visits} onAdd={()=>act.visitAdd("ins",ii)}
                      onEdit={(j,patch)=>act.visit("ins",ii,j,patch)} onRemove={j=>act.visitRemove("ins",ii,j)} />
                    <DExpand t={ins} onStep={i2=>act.step("ins",ii,i2)} />
                  </DTreeBody>
                )}
              </React.Fragment>
            ))}
            <button onClick={()=>act.add("ins")} style={{ all:"unset", fontFamily:"var(--font-sans)", fontSize:10, fontWeight:600, color:"var(--brand)", cursor:"pointer", padding:"7px 0 0 25px", display:"inline-block" }}>＋ 保険を追加</button>

            {/* 完了 */}
            <DPhaseRow state={closed?"done":"todo"} label="完了" note={closed?"クローズ済":"未到達"} noteTone={closed?"var(--success)":"var(--foreground-subtle)"}
              open={isOpen("done")} onClick={()=>toggle("done")} />
            {isOpen("done") && <DTreeBody><P1Complete d={d} closed={closed} onToast={onToast}
              onClose={()=>{ upd(n=>{ n.closed = true; }); onToast&&onToast("案件を完了にしました"); }} /></DTreeBody>}
            </div>
          </div>
        </AfCard>
        {/* K18-21: 案件カード(左 2)｜タイムライン(右 1)。390px では縦に積む(mr-stack-1)。 */}
        <div className="mr-stack-1" style={{ display:"grid", gridTemplateColumns:"minmax(0,2fr) minmax(0,1fr)", gap:14, alignItems:"start" }}>
          {caseCard}
          {timelineCard}
        </div>
        {/* K18-22: 原因／被害の表。main の列(種別・入館・鍵・備考・削除)を足して池田さん版より列が多いので、
            左列ではなく幅いっぱいに置く。 */}
        {spotsCard}
        </div>
      )}

      {tab==="todo" && (
        <AfCard pad={0}>
          <div style={{ padding:"10px 14px 8px" }}>
            {todos.map(t=>{
              const done = t.done, active = activeTodo===t.id;
              return (
                <div key={t.id} style={{ display:"flex", alignItems:"center", gap:8, padding:"6px 6px 6px 4px", margin:"0 -4px",
                  borderBottom:"1px solid var(--border)", opacity:done?0.5:1, borderRadius:active?"var(--radius-sm)":0,
                  background:active?"var(--brand-muted)":"transparent", transition:"background var(--duration-fast) var(--easing)" }}>
                  <button onClick={()=>window.MStore&&window.MStore.toggleTodo(cid, t.id)} title={done?"完了を戻す":"完了にする"}
                    style={{ all:"unset", boxSizing:"border-box", width:field?44:16, height:field?44:16, flexShrink:0, cursor:"pointer",
                      display:"flex", alignItems:"center", justifyContent:"center" }}>
                    <span style={{ boxSizing:"border-box", width:field?22:15, height:field?22:15, borderRadius:3,
                      border:"1.5px solid "+(done?"var(--success)":"var(--border-strong)"), background:done?"var(--success)":"transparent",
                      display:"flex", alignItems:"center", justifyContent:"center" }}>
                      {done && <span style={{ fontSize:field?13:9, color:"#fff", fontWeight:700, lineHeight:1 }}>✓</span>}
                    </span>
                  </button>
                  <button onClick={()=>{ setActiveTodo(t.id); if(t.key){ setTab("overview"); if(!isOpen(t.key)) toggle(t.key); } }}
                    style={{ all:"unset", boxSizing:"border-box", flex:1, display:"flex", alignItems:"center", gap:8, cursor:"pointer",
                      fontFamily:"var(--font-sans)", minWidth:0 }}>
                    <span style={{ fontSize:"var(--text-caption)", fontWeight:700, color:dDueColor(t.dueTone), minWidth:44, flexShrink:0 }}>{t.due}</span>
                    <span style={{ fontSize:"var(--text-body-md)", textDecoration:done?"line-through":"none", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{t.label}</span>
                    <span style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)", flexShrink:0 }}>（{t.to}）</span>
                    <span style={{ marginLeft:"auto", display:"flex", alignItems:"center", gap:6, flexShrink:0 }}>
                      <DMark mk={t.mk} />
                      <span style={{ fontSize:"var(--text-caption)", color:t.mk==="delay"?"var(--warning)":"var(--foreground-subtle)", whiteSpace:"nowrap" }}>{t.note}</span>
                    </span>
                  </button>
                  <button onClick={()=>contact({ name:t.to, ch:"電話" })} title={t.to+"へ電話"}
                    style={{ all:"unset", fontFamily:"var(--font-sans)", fontSize:"var(--text-caption)", fontWeight:600, color:"var(--muted-foreground)",
                      border:"1px solid var(--border)", borderRadius:"var(--radius-sm)", padding:field?"0 18px":"3px 9px", height:field?44:undefined,
                      whiteSpace:"nowrap", display:"inline-flex", alignItems:"center", justifyContent:"center", boxSizing:"border-box", cursor:"pointer", flexShrink:0,
                      transition:"all var(--duration-fast) var(--easing)" }}
                    onMouseEnter={e=>{ e.currentTarget.style.borderColor="var(--border-strong)"; e.currentTarget.style.background="var(--muted)"; }}
                    onMouseLeave={e=>{ e.currentTarget.style.borderColor="var(--border)"; e.currentTarget.style.background="transparent"; }}>電話</button>
                </div>
              );
            })}
            <button onClick={()=>onAddTask&&onAddTask()} style={{ all:"unset", fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)", fontWeight:600, color:"var(--brand)", cursor:"pointer", padding:"7px 2px 0", display:"inline-block" }}>＋ TODO追加</button>
          </div>
        </AfCard>
      )}

      {tab==="files" && (
        <AfCard pad={0}>
          <div style={{ padding:"12px 14px 14px" }}>
            <div style={{ display:"flex", gap:6, flexWrap:"wrap", marginBottom:8 }}>
              {["報告書","見積書","請求書"].map(t=>(<AfButton key={t} size="sm" onClick={()=>act.doc(t)}>＋ {t}を作成</AfButton>))}
            </div>
            <FileDrop onFiles={addFiles} />
            {(()=>{
              const STAGE = { received:["受領","survey"], draft:["下書き","warn"], issued:["確定","success"] };
              const groups = [
                ...d.visits.map(v=>({ id:v.id, label:"調査 "+v.vendor })),
                ...d.works.map(w=>({ id:w.id, label:"工事 "+w.room+" "+w.work })),
                ...dInsList(d).map(i=>({ id:i.id, label:"保険 "+i.name })),
                { id:null, label:"案件全体（進行に紐づかない）" },
              ];
              // ファイル・帳票-8: 工程消込モーダル(stepReceive)からの受領物は
              // files[].stepId(track_steps.id)を持つ。どの工程の受領物かを
              // 一覧で小さく分かるよう、全トラックの工程レールから名前を引く。
              const allTracksForSteps = [...d.visits, ...d.works, ...dInsList(d)];
              const stepNameById = (stepId) => {
                if (!stepId) return null;
                for (const t of allTracksForSteps) {
                  const s = (t.steps||[]).find(x=>x.stepId===stepId);
                  if (s) return s.n;
                }
                return null;
              };
              const Row = ({ f:file }) => (
                <div style={{ display:"flex", alignItems:"center" }}>
                  <button onClick={()=>openFilePreview(file)} style={{ all:"unset", boxSizing:"border-box", flex:1, minWidth:0,
                    display:"flex", alignItems:"center", gap:8, padding:"7px 6px 7px 2px", borderBottom:"1px solid var(--border)",
                    cursor:"pointer", fontFamily:"var(--font-sans)", transition:"background var(--duration-fast) var(--easing)" }}
                    onMouseEnter={e=>e.currentTarget.style.background="var(--accent)"}
                    onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
                    {file.stage && <DChip tone={(STAGE[file.stage]||["","neutral"])[1]}>{(STAGE[file.stage]||["—"])[0]}</DChip>}
                    {/* ⑭: 画像は一覧でも小さなサムネ(url があるときだけ・署名取得前は出さない) */}
                    {file.url && file.mime && String(file.mime).startsWith("image") && (
                      <img src={file.url} alt="" style={{ width:28, height:28, objectFit:"cover", borderRadius:"var(--radius-sm)", border:"1px solid var(--border)", flexShrink:0 }} />
                    )}
                    <span style={{ fontSize:"var(--text-body-md)", minWidth:0, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{file.name}{file.count?"（"+file.count+"枚）":""}</span>
                    <DChip>{file.type}</DChip>
                    {file.stepId && (()=>{ const sn = stepNameById(file.stepId); return sn ? <span style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)", whiteSpace:"nowrap" }}>工程：{sn}</span> : null; })()}
                    {file.todoId && (()=>{ const td = (d.todos||[]).find(t=>t.id===file.todoId); return td ? <span style={{ fontSize:"var(--text-caption)", color:"var(--brand)", whiteSpace:"nowrap" }}>TODO：{td.step}</span> : null; })()}
                    {(file.sharedTo||[]).length>0 && <span style={{ fontSize:"var(--text-caption)", color:"var(--success)", whiteSpace:"nowrap" }}>共有 {file.sharedTo.join("・")}</span>}
                    <span className="mono" style={{ marginLeft:"auto", fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)", flexShrink:0 }}>{file.date}</span>
                  </button>
                  {/* ファイル・帳票-2(0058): 添付の削除(論理削除。dbIdが無い行はローカルから消すだけ) */}
                  <button onClick={()=>removeFile(file)} title="削除" style={{ all:"unset", boxSizing:"border-box", flexShrink:0,
                    cursor:"pointer", padding:"7px 6px", marginLeft:2, borderBottom:"1px solid var(--border)",
                    fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)", fontFamily:"var(--font-sans)" }}
                    onMouseEnter={e=>e.currentTarget.style.color="var(--destructive)"}
                    onMouseLeave={e=>e.currentTarget.style.color="var(--muted-foreground)"}>✕</button>
                </div>
              );
              return groups.map(g=>{
                const list = d.files.filter(x=>g.id ? x.trkId===g.id : !x.trkId);
                if(!list.length) return null;
                return (
                  <div key={g.label} style={{ marginBottom:11 }}>
                    <div style={{ display:"flex", alignItems:"center", gap:7, marginBottom:4 }}>
                      <span style={{ fontSize:"var(--text-caption)", fontWeight:700, letterSpacing:"0.04em", color:"var(--muted-foreground)" }}>{g.label}</span>
                      <span className="mono" style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>{list.length}</span>
                      {g.id && <span className="code" style={{ fontSize:9, color:"var(--foreground-subtle)" }}>{g.id}</span>}
                    </div>
                    {list.map(x=><Row key={x.id} f={x} />)}
                  </div>
                );
              });
            })()}
            <div style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)", lineHeight:1.7 }}>進行（調査・工事・保険）ごとに束ね、状態（受領／下書き／確定）と起票したTODO・共有先を併記。行タップでプレビュー・編集。</div>
          </div>
        </AfCard>
      )}

      {tab==="parties" && (
        <AfCard pad={0}>
          <div style={{ padding:"12px 14px 14px" }}>
            {/* 対象者（役割つき）＋連絡 */}
            <div style={{ display:"flex", gap:7, flexWrap:"wrap", alignItems:"center", paddingBottom:11, marginBottom:11, borderBottom:"1px solid var(--border)" }}>
              {/* 通知・連携-2(決-33 A・0078): 電話番号を持つ関係者はその場で発信できる。
                  番号は rpc_case_detail_context が parties に載せて返す(0078)。
                  リンク(a)を履歴切替の button の**中**には入れない ─ 入れ子にすると
                  押し分けができないので、チップと横並びの兄弟にする。 */}
              {d.parties.map((p,i)=>(
                <span key={i} style={{ display:"inline-flex", alignItems:"center", gap:5 }}>
                  <button onClick={()=>{ setPtab(pName(p)); setPsel(null); }} title={p.name+" の履歴を見る"}
                    style={{ all:"unset", cursor:"pointer", display:"flex", alignItems:"center", gap:5, padding:"4px 10px", borderRadius:"var(--radius-full)",
                      border:"1px solid "+(ptab===pName(p)?"var(--brand)":"var(--border)"), background:ptab===pName(p)?"var(--brand-muted)":"var(--card)",
                      fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)", fontWeight:600, whiteSpace:"nowrap" }}>
                    <span style={{ color:ptab===pName(p)?"var(--brand)":"var(--foreground)" }}>{p.name}</span>
                    <span style={{ fontSize:"var(--text-caption)", fontWeight:500, color:"var(--foreground-subtle)" }}>{p.role}</span>
                    {p.wait && <DChip tone="warn" dot>返事待ち {p.wait}日</DChip>}
                  </button>
                  {p.phone && <PhoneLink phone={p.phone} name={pName(p)} onCall={()=>onPartyCall(p)}
                    className="mono" style={{ fontSize:"var(--text-caption)", whiteSpace:"nowrap" }} />}
                </span>
              ))}
              <span style={{ marginLeft:"auto", display:"flex", gap:6, flexWrap:"wrap", alignItems:"center" }}>
                <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>{ptab==="すべて"?"相手を選ぶと連絡できます":ptab+" へ"}</span>
                {["電話","メール","SMS","LINE","Slack"].map(a=>(
                  <button key={a} disabled={ptab==="すべて" || detailContext.status!=="ready"} onClick={()=>contact({ name:ptab, ch:a })} style={{ all:"unset", fontFamily:"var(--font-sans)",
                    fontSize:"var(--text-body-sm)", fontWeight:500, color:ptab==="すべて"?"var(--foreground-subtle)":"var(--muted-foreground)",
                    border:"1px solid var(--border)", borderRadius:"var(--radius-sm)", padding:"3px 10px", cursor:ptab==="すべて"?"default":"pointer",
                    opacity:ptab==="すべて"?0.5:1, transition:"all var(--duration-fast) var(--easing)" }}>{a}</button>
                ))}
                {/* E-2'(決-37・0079): 最初の連絡(電話/メール/SMS)に貼る「LINE 登録の案内文」を
                    クリップボードへ。友だち追加リンクにはパラメータを載せられないので、
                    案内文の中の「受付番号」(cases.line_bind_token)を居住者が最初のメッセージで
                    送り返すことで案件に紐づく(rpc_line_invite_text / private._line_ingest)。
                    ここは仮置き ─ 本来は連絡モーダル(Screens.jsx の ContactModal)の中に
                    置きたいが、あちらは Cursor の持ち場なので、まずは連絡ボタンの並びに1つだけ。 */}
                <button type="button" disabled={!dbId} title="最初の連絡に貼る文面をコピーします"
                  onClick={async()=>{
                    const cloud = window.MarurouCloud;
                    if (!cloud || !cloud.lineInviteText) { onToast && onToast("この環境ではLINEの案内文を作れません。"); return; }
                    try {
                      const r = await cloud.lineInviteText(dbId);
                      const text = r && r.text;
                      if (!text) { onToast && onToast("LINEの案内文を作れませんでした。"); return; }
                      let copied = false;
                      try {
                        if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); copied = true; }
                      } catch (_) { copied = false; }
                      if (!copied) {
                        // クリップボードAPIが使えない環境の逃げ道(古いSafari・非セキュアな出先)。
                        const ta = document.createElement("textarea");
                        ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
                        document.body.appendChild(ta); ta.select();
                        try { copied = document.execCommand("copy"); } catch (_) { copied = false; }
                        document.body.removeChild(ta);
                      }
                      onToast && onToast(!copied ? "コピーできませんでした。もう一度お試しください。"
                        : r.configured ? "LINE登録の案内文をコピーしました。最初の連絡に貼ってください。"
                        : "案内文をコピーしました（友だち追加URLが未設定です。管理者に設定を頼んでください）");
                    } catch (e) { onToast && onToast("LINEの案内文を作れませんでした。"); }
                  }}
                  style={{ all:"unset", fontFamily:"var(--font-sans)", fontSize:"var(--text-caption)", fontWeight:600,
                    color:!dbId?"var(--muted-foreground)":"var(--brand)", cursor:!dbId?"default":"pointer" }}>LINE登録の案内文</button>
                <button disabled={detailContext.status!=="ready"} onClick={()=>act.add("party")} style={{ all:"unset", fontFamily:"var(--font-sans)", fontSize:"var(--text-caption)", fontWeight:600, color:detailContext.status!=="ready"?"var(--muted-foreground)":"var(--brand)", cursor:detailContext.status!=="ready"?"default":"pointer" }}>＋関係者</button>
              </span>
            </div>
            {/* ⑲: 付け替え・外す。actorId がある行だけ(現場担当・営業担当は cases 列由来で不可)。 */}
            {detailContext.status==="ready" && d.parties.some(p => p && p.actorId) && (
              <div style={{ marginBottom:12, paddingBottom:12, borderBottom:"1px solid var(--border)" }}>
                <div style={{ fontSize:"var(--text-caption)", fontWeight:600, color:"var(--muted-foreground)", marginBottom:6 }}>関係者の付け替え・終了</div>
                <div style={{ display:"flex", flexDirection:"column", gap:6 }}>
                  {d.parties.map((p, i) => {
                    if (!p || !p.actorId) return null;
                    return (
                      <div key={p.actorId || i} style={{ display:"flex", alignItems:"center", gap:10, flexWrap:"wrap",
                        padding:"6px 8px", borderRadius:"var(--radius-sm)", border:"1px solid var(--border)", background:"var(--accent)" }}>
                        <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)", minWidth:56 }}>{p.role || "—"}</span>
                        <DPartyOrgPick value={p.name} dbId={p.orgDbId || null} role={p.role}
                          label={(p.role || "関係者") + "の会社"}
                          onSave={org => { act.partyReplace(i, org); onToast && onToast((p.role || "関係者") + "を「" + org.name + "」に差し替えました"); }} />
                        <button type="button" onClick={() => {
                          const label = p.name || p.role || "関係者";
                          act.partyRemove(i);
                          if (ptab === pName(p)) { setPtab("すべて"); setPsel(null); }
                          onToast && onToast(label + " を外しました");
                        }}
                          style={{ all:"unset", cursor:"pointer", marginLeft:"auto", fontFamily:"var(--font-sans)",
                            fontSize:"var(--text-caption)", fontWeight:600, color:"var(--muted-foreground)" }}>外す</button>
                      </div>
                    );
                  })}
                </div>
                <div style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)", marginTop:6, lineHeight:1.5 }}>
                  名簿から会社を選び直すと差し替え、外すとその関係を終了します。現場担当・営業担当はこの一覧では変えられません。
                </div>
              </div>
            )}
            {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 && window.ScActivity && (
              <React.Fragment>
                <window.ScActivity targets={d.parties.map(p=>({ name:pName(p), role:p.role, contactDbId:p.contactDbId||null, orgDbId:p.orgDbId||null }))}
                  entries={partyEntries} tab={ptab} setTab={setPtab} selId={psel} setSelId={setPsel} canReport
                  onRecord={r=>{ if (detailContext.status!=="ready") throw new Error("関係者を読み込み中のため、記録できません。"); logContact(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>
            )}
          </div>
        </AfCard>
      )}

      {tab==="money" && (
        <AfCard pad={0}>
          <div style={{ padding:"12px 14px 14px" }}>
            {/* 精算画面への導線。app.jsx は onPayment を渡していたが、ここで受け取ったまま
                使っていなかったため、**案件から精算画面に入る道が1本も無かった**。
                入金の消し込みは精算画面でしか行えないので、これが無いと
                docs/03-screens.md §4.2 の P0「入金/支払の記録」が始められない。 */}
            {onPayment && (
              <div style={{ display:"flex", justifyContent:"flex-end", marginBottom:10 }}>
                <AfButton size="sm" onClick={()=>onPayment()}>精算画面へ →</AfButton>
              </div>
            )}
            <div style={{ overflowX:"auto" }}>
              <div style={{ minWidth:560 }} className="mr-case-tree-min">
                <div style={{ display:"grid", gridTemplateColumns:D_PL_COLS, gap:6, padding:"6px 2px", borderBottom:"1px solid var(--border-strong)", alignItems:"center" }}>
                  {["No.","トラック","請求額","原価","粗利","入金","支払"].map((h,i)=>(
                    <span key={h} style={{ fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.03em", color:"var(--muted-foreground)", textAlign:i>=2&&i<=4?"right":"left" }}>{h}</span>
                  ))}
                </div>
                {pl.map((r,i)=>{
                  const profit = r.billed!=null? r.billed-(r.cost||0) : null;
                  const expandable = !!(r.items && r.items.length);
                  return (
                    <React.Fragment key={i}>
                      <div onClick={expandable?()=>setPlOpen(!plOpen):undefined}
                        style={{ display:"grid", gridTemplateColumns:D_PL_COLS, gap:6, padding:"8px 2px", cursor:expandable?"pointer":"default",
                          borderBottom:"1px solid var(--border)", alignItems:"center", background:r.isIns?"var(--warning-muted)":"transparent" }}>
                        <span className="mono" style={{ fontSize:"var(--text-body-sm)", color:"var(--foreground-subtle)" }}>{i+1}</span>
                        <span style={{ fontSize:"var(--text-body-sm)", fontWeight:600, display:"flex", alignItems:"center", gap:5 }}>
                          {r.isIns && <span style={{ width:7, height:7, borderRadius:999, background:"var(--warning)", flexShrink:0 }} />}{r.name}
                          {expandable && <span style={{ fontSize:9, color:"var(--brand)" }}>{plOpen?"▲ 明細":"▼ 明細"}</span>}
                        </span>
                        <span className="mono" style={{ fontSize:"var(--text-body-sm)", textAlign:"right" }}>{r.billed!=null? yen(r.billed) : <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>{r.refAmt}</span>}</span>
                        <span className="mono" style={{ fontSize:"var(--text-body-sm)", textAlign:"right", color:"var(--muted-foreground)" }}>{r.cost!=null? "−"+r.cost.toLocaleString() : "—"}</span>
                        <span className="mono" style={{ fontSize:"var(--text-body-sm)", textAlign:"right", fontWeight:700, color:profit!=null?"var(--success)":"var(--foreground-subtle)" }}>{profit!=null? yen(profit) : "—"}</span>
                        <span style={{ fontSize:"var(--text-caption)", color:r.inWarn?"var(--warning)":"var(--foreground-subtle)", display:"flex", alignItems:"center", gap:4 }}>
                          {r.isIns && <span style={{ fontSize:7 }}>●</span>}{r.in}
                        </span>
                        <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>{r.out}</span>
                      </div>
                      {expandable && plOpen && r.items.map((x,j)=>(
                        <div key={x.id} style={{ display:"grid", gridTemplateColumns:D_PL_COLS, gap:6, padding:"6px 2px",
                          borderBottom:"1px solid var(--border)", alignItems:"center", background:"var(--accent)" }}>
                          <span></span>
                          <span style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)", display:"flex", alignItems:"center", gap:5, paddingLeft:10 }}>
                            {j===r.items.length-1?"└":"├"} {x.name}<span style={{ color:"var(--foreground-subtle)" }}>（{x.vendor}）</span>
                          </span>
                          <span className="mono" style={{ fontSize:"var(--text-caption)", textAlign:"right", color:"var(--muted-foreground)" }}>{yen(x.amt)}</span>
                          <span></span><span></span>
                          <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>{x.ins?"保険へ":"保険外・自費"}</span>
                          <span style={{ fontSize:"var(--text-caption)", color:x.tone==="green"?"var(--success)":"var(--foreground-subtle)" }}>{x.tone==="green"?"支払済":"未"}</span>
                        </div>
                      ))}
                    </React.Fragment>
                  );
                })}
                <div style={{ display:"grid", gridTemplateColumns:D_PL_COLS, gap:6, padding:"9px 2px", alignItems:"center" }}>
                  <span></span>
                  <span style={{ fontSize:"var(--text-body-md)", fontWeight:700 }}>合計</span>
                  <span className="mono" style={{ fontSize:"var(--text-body-md)", textAlign:"right", fontWeight:700 }}>{yen(totalBilled)}</span>
                  <span className="mono" style={{ fontSize:"var(--text-body-md)", textAlign:"right", fontWeight:600, color:"var(--muted-foreground)" }}>−{totalCost.toLocaleString()}</span>
                  <span className="mono" style={{ fontSize:"var(--text-body-md)", textAlign:"right", fontWeight:700, color:"var(--success)" }}>{yen(totalBilled-totalCost)}</span>
                  <span></span><span></span>
                </div>
              </div>
            </div>
            <div style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)", marginTop:7, lineHeight:1.7 }}>
              保険充当分は保険から回収し、残りは自費。保険行は経路と状態のみで、金額は302行（明細）に計上（二重計上なし）。請求→入金の工程は各トラックのレールに内包。
            </div>
          </div>
        </AfCard>
      )}

      {/* 案件詳細-22: 過去のやり取り。Salesforceの活動履歴に相当するものが丸ごと見られる場所が
          無かった。communications(0027)を案件単位・新しい順・全チャネル横断で出す。
          関係者タブと違って対象者では絞らない(横断して探す場所)。 */}
      {tab==="comms" && (
        <AfCard pad={0}>
          <div style={{ padding:"12px 14px 14px" }}>
            <div style={{ fontSize:10, color:"var(--foreground-subtle)", marginBottom:10 }}>
              電話・メール・LINE等、この案件のすべてのやり取りを新しい順にまとめて表示します。
            </div>
            {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 && comms.rows.length===0 && !comms.err &&
              <div style={{ fontSize:"var(--text-body-md)", color:"var(--foreground-subtle)" }}>連絡記録はまだありません。</div>}
            {comms.rows!==null && comms.rows.length>0 && (
              <div>
                {comms.rows.map((r,i)=>{
                  const dir = r.direction==="発信"?"out":r.direction==="受信"?"in":"memo";
                  const who = r.counterpartyContactName || r.counterpartyOrgName || (r.direction==="社内メモ"?"社内":"相手");
                  return (
                    <div key={r.id} style={{ display:"flex", gap:10, alignItems:"flex-start", padding:"10px 2px",
                      borderBottom:i<comms.rows.length-1?"1px solid var(--border)":"none" }}>
                      <span style={{ color:dir==="out"?"var(--brand)":dir==="in"?"var(--success)":"var(--muted-foreground)", width:12, flexShrink:0, marginTop:2 }}>
                        {dir==="out"?"↑":dir==="in"?"↓":"・"}
                      </span>
                      <div style={{ minWidth:0, flex:1 }}>
                        <div style={{ display:"flex", gap:8, alignItems:"baseline", flexWrap:"wrap" }}>
                          <span style={{ fontSize:"var(--text-body-sm)", fontWeight:600 }}>{r.channel}</span>
                          <span style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)" }}>{who}</span>
                          <span className="mono" style={{ fontSize:10, color:"var(--foreground-subtle)", marginLeft:"auto" }}>{(r.occurredAt||"").replace("T"," ").slice(0,16)}</span>
                        </div>
                        {r.subject && <div style={{ fontSize:"var(--text-body-sm)", fontWeight:600, marginTop:3 }}>{r.subject}</div>}
                        <div style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)", marginTop:2, whiteSpace:"pre-wrap",
                          overflow:"hidden", textOverflow:"ellipsis", display:"-webkit-box", WebkitLineClamp:2, WebkitBoxOrient:"vertical" }}>{r.body}</div>
                        {r.byName && <div style={{ fontSize:10, color:"var(--foreground-subtle)", marginTop:3 }}>担当 {r.byName}</div>}
                      </div>
                    </div>
                  );
                })}
              </div>
            )}
            {comms.hasMore && <div style={{ marginTop:10 }}>
              <AfButton size="sm" disabled={comms.loadingMore} onClick={comms.loadMore}>{comms.loadingMore?"読み込み中…":"続きを読む"}</AfButton>
            </div>}
          </div>
        </AfCard>
      )}

      {ctxStep && <StepModal ctx={{ step:ctxStep, trackLabel, prevName: stepCtx.i>0 ? (dSteps(ctxTrack)[stepCtx.i-1] || {}).n || null : null,
          docType: D_DOC_BY_FACT[ctxStep.factKey] || null,
          receiveLabel: D_RECEIVE_BY_FACT[ctxStep.factKey] || null }}
        staff={(window.MStore && window.MStore.masters ? window.MStore.masters().users : null) || []}
        onClose={closeStep} onDone={stepDone} onUndo={stepUndo} onWait={stepWait} onResume={stepResume} onReject={stepReject}
        onPlan={stepPlan} onReceive={stepReceive} onUnskip={stepUnskip} onSkip={stepSkip} onAssign={stepAssign}
        onDoc={type=>{ closeStep(); setDocType(type); }} />}
      {viewFileLoading && <div style={{ position:"fixed", inset:0, zIndex:40, display:"flex", alignItems:"center", justifyContent:"center", background:"rgba(0,0,0,0.18)", fontSize:"var(--text-body-md)", color:"var(--foreground)" }}>プレビューを準備しています…</div>}
      {viewFile && <FileViewer file={viewFile} onClose={()=>setViewFile(null)} />}
      {addKind && <AddRecordModal kind={addKind} rooms={d.spots.map(s=>s.room)} spots={d.spots}
        workDefaults={window.requesterWorkDefaults ? window.requesterWorkDefaults(d, orgs) : null}
        onClose={()=>setAddKind(null)} onSave={addRecord} />}
      {docType && <DocModal type={docType} d={d} onClose={()=>setDocType(null)} onIssue={issueDoc} />}
    </div>
    </DFieldCtx.Provider>
  );
}

Object.assign(window, { CaseDetail });
