// BoardV4 — 主画面「今日のボード」: board-proto-v3 (v4.0) 確定構造をアクアフロー Light で実装.
// 案件一覧(受付順・テーブル) ⇄ TODO一覧(期限順・TODO並び/案件並び).
// 状態マーカーはDSネイティブ: 緊急=赤塗り / 滞り=SeverityBadge / 予兆=●ドット / 要注意=warningチップ.

const BV_TABS = ["すべて","報告","承認","連絡","方針確認","見積","入金精算","その他"];
/* ダッシュボードは社内/社外を分ける。盤面の「承認」は両方をまとめる(ヘッダ遷移の先)。 */
const bvTypeMatch = (btype, tab) => {
  if (tab === "すべて") return true;
  if (tab === "承認") return btype === "承認" || btype === "社内承認" || btype === "社外承認";
  if (tab === "入金精算") return btype === "入金精算" || btype === "精算";
  return btype === tab;
};
/* 行データは store（MStore.boardRows）が単一の真実 */

const bvRank = r => r.urgent ? 1 : (r.marker==="delay"&&r.sev==="critical") ? 2 : r.marker==="delay" ? 3 : r.marker==="warn" ? 4 : 5;
const bvHasFlag = r => r.urgent || r.marker==="delay" || r.watch;
const bvIsActive = r => bvHasFlag(r) || r.category==="自社";

/* ── K18-23: TODO一覧の切替「要対応（自社）/相手待ち/すべて」 ─────────────────
   叩き台(reference/ikeda-2026-09-16/screens/todo-step-deadline-mock.html:41)のセグメント。
   分け方は案件並びの「要対応／待ち」(bvIsActive)と同じ 1 つの規則にする:
     要対応（自社）= 自社ボール(category 自社 = ball 自社)＋ 滞り・緊急・要注意(相手ボールでも督促が要る)
     相手待ち      = それ以外(相手ボールで印の無いもの＝通常の待ち)
   仕様 03 §8.1 の第1層 3 段(①滞り＋優先対応 ②自社ボール ③他者ボール)と食い違わないよう、
   ①は相手ボールでも「要対応」に入れる(既定の画面から滞りが消えない)。予兆🟡は①に入れない
   (bvHasFlag に warn が無い ─ 仕様 03 と同じ)。
   既定は叩き台どおり「要対応（自社）」。仕様 03 の 3 段と叩き台の切替のどちらを既定にするかは
   池田さんの答え待ち(docs/21 K18-23・仮置き)。 */
const BV_TODO_VIEWS = [["mine","要対応（自社）"],["wait","相手待ち"],["all","すべて"]];
const bvTodoViewLabel = v => (BV_TODO_VIEWS.find(([k])=>k===v) || BV_TODO_VIEWS[2])[1];
const bvTodoViewMatch = (r, view) => view==="mine" ? bvIsActive(r) : view==="wait" ? !bvIsActive(r) : true;
/* 画面を移って戻っても(TODO詳細 → 一覧)選んだ切替を覚えておく。再読込では既定に戻る。 */
let bvTodoViewLast = null;
/* ダッシュボードの見出し(種類・印)から来たときは「すべて」で開く ─ カードの件数は
   ボールで分けていないので、要対応だけにすると件数が合わない。 */
const bvInitialTodoView = (tab, filter) =>
  ((tab && tab!=="すべて") || filter) ? "all" : (bvTodoViewLast || "mine");

/* K18-23: 行の鍵(todoId は案件の中でしか一意でない ─ rpc_seed の 'TD'||right(id,6))。 */
const bvRowKey = r => r.caseId+"|"+(r.todoId||r.trk+"|"+r.step);
/* トラックの今の工程 = 未完了・未見送りの最初(v_track_current_step と同じ規則・rpc_seed の cur_no)。 */
const bvCurStepIndex = t => (Array.isArray(t && t.steps) ? t.steps : []).findIndex(s => s && s.s!=="done" && s.s!=="skip");
const BV_KIND_LABEL = { survey:"調査", work:"工事", ins:"保険" };
/* 行から完了にできるか。**各トラックの今の工程だけ** ─ 先の工程を完了にするとレールの順番が崩れる
   (docs/21 K18-23)。stepId が届いていれば stepId(= row.dbId・track_steps.id)で、まだなら工程名で
   今の工程と突き合わせる(保存の直前に bvCompleteRow が stepId でもう一度確かめる)。
   手動の TODO(railStep なし)はレールの外なので、DB の行(dbId)があれば完了にできる。
   dbId の無い行(送信前・受付の仮行)は押しても保存先が無いので出さない。 */
function bvCanCompleteRow(c, r) {
  if (!c || !r || !r.todoId) return false;
  const row = (c.todos||[]).find(t => t.id===r.todoId);
  if (!row || row.done || !row.dbId) return false;
  if (!row.railStep) return true;
  if (!row.trkId || typeof window.msTracks!=="function") return false;
  const hit = window.msTracks(c).find(x => x && x.t && x.t.id===row.trkId);
  if (!hit) return false;
  const i = bvCurStepIndex(hit.t);
  if (i < 0) return false;
  const s = hit.t.steps[i];
  return s.stepId ? s.stepId===row.dbId : s.n===row.railStep;
}
/* 行ごとの付随情報(完了できるか・トラックの種類)を 1 回で引く。行 2,000 件で毎回
   案件を線形に探さないよう、案件は Map にしてから引く。 */
function bvRowInfo(rows) {
  const out = new Map();
  const S = window.MStore;
  const cs = new Map(((S && S.get().cases) || []).map(c => [c.id, c]));
  rows.forEach(r => {
    const c = cs.get(r.caseId);
    const hit = (c && r.trkId && typeof window.msTracks==="function")
      ? window.msTracks(c).find(x => x && x.t && x.t.id===r.trkId) : null;
    out.set(bvRowKey(r), { canComplete: bvCanCompleteRow(c, r), kind: hit ? hit.kind : null });
  });
  return out;
}

/* K18-23: 行から完了。TODO詳細の finish(Screens.jsx・K18-07)と同じ手順で、対応メモだけを持たない:
     ・レール上の工程 → トラックの steps を done にする(書き込み翻訳 diffSteps が step.complete に直す)
     ・手動の TODO   → todos[].done(= toggleTodo と同じ変更 → todo.toggle)
   道具(scFindRailStep ほか)は Screens.jsx のものを window 経由で使う(完了の規則を 2 か所に持たない)。
   保存を見届けて(scAwaitWrite)から戻り、サーバに残らなかったら画面の変更も戻す(保存したふりをしない)。
   保存の直前に、stepId で「今の工程か」をもう一度確かめる ─ 先の工程は書かずに止まる。 */
const bvTodayMd = () => { const d = new Date(); return (d.getMonth()+1)+"/"+d.getDate(); };
async function bvAwaitStepIds(caseId, dbId) {
  const S = window.MStore;
  if (!S || !S.ensureCaseSteps) return;
  try {
    const waiting = Promise.race([S.ensureCaseSteps(caseId, dbId), new Promise(r => setTimeout(r, 8000))]);
    await (window.MarurouWrite && window.MarurouWrite.prepare ? window.MarurouWrite.prepare(waiting) : waiting);
  } catch (err) {
    console.warn("[TODO一覧] 工程の追加情報(stepId等)を取得できませんでした。", err);
  }
}
async function bvCompleteRow(r) {
  const S = window.MStore;
  const tools = ["scFindRailStep","scRecalcTrack","scWriteSnap","scAwaitWrite","msTracks"];
  if (!S || tools.some(k => typeof window[k]!=="function"))
    throw new Error("完了の保存に使う道具が読み込まれていません。画面を読み込み直してください。");
  const rowId = r.todoId;
  const c0 = S.case(r.caseId);
  const row0 = c0 ? (c0.todos||[]).find(t => t.id===rowId) : null;
  if (!c0 || !row0 || row0.done)
    throw new Error("このTODOを案件の中に見つけられないため、完了を保存できません。TODO一覧を開き直してください。");
  const isRail = !!row0.railStep;
  if (isRail) await bvAwaitStepIds(r.caseId, c0.dbId);
  const c1 = S.case(r.caseId) || c0;
  const ref = isRail ? window.scFindRailStep(c1, row0) : null;
  if (isRail && !ref) throw new Error("工程「"+row0.railStep+"」を進行の中に見つけられないため、完了を保存できません。案件を開き直してからもう一度お試しください。");
  if (isRail && !ref.step.stepId) throw new Error("工程「"+row0.railStep+"」はDBの工程行と結び付いていないため、完了を保存できません。少し待ってからもう一度お試しください。");
  if (isRail && bvCurStepIndex(ref.track)!==ref.index)
    throw new Error("工程「"+row0.railStep+"」はこのトラックの今の工程ではないため、一覧からは完了にできません(先の工程を完了にすると工程の順番が崩れます)。");
  if (!isRail && !row0.dbId) throw new Error("このTODOはまだサーバに登録されていないため、完了を保存できません。少し待ってからもう一度お試しください。");
  const localOnly = !window.MarurouWrite;
  const when = bvTodayMd();
  const keep = JSON.parse(JSON.stringify({
    track: ref ? { id:ref.track.id, steps:ref.track.steps, state:ref.track.state, status:ref.track.status, stall:ref.track.stall, mk:ref.track.mk } : null,
    row: row0, rowIndex: (c1.todos||[]).findIndex(t => t.id===rowId),
  }));
  const before = window.scWriteSnap();
  S.updateCase(r.caseId, c => {
    const row = (c.todos||[]).find(t => t.id===rowId);
    if (isRail) {
      const x = window.scFindRailStep(c, row || row0);
      if (!x) return;
      const t = x.track, s = x.step;
      s.s = "done"; s.date = when;
      const nx = (t.steps||[]).find(y => y && y.s!=="done" && y.s!=="skip");
      if (nx && nx.s==="todo") nx.s = "now";
      window.scRecalcTrack(t);
      /* TODO行は「トラックの今の工程」を 1 行で返すもの(rpc_seed todo_track)。次の工程へ進め、
         工程が尽きたら行を外す(TODO詳細の finish・再読込後と同じ形。ここでは op は増えない)。 */
      if (row) {
        if (nx) {
          row.railStep = nx.n; row.dbId = nx.stepId || null; row.due = nx.plan || "—";
          row.marker = "on"; row.sev = null; row.raised = when;
          if (row.stepLabel!==undefined) row.stepLabel = nx.n;
          if (x.kind==="ins") row.step = "保険 " + nx.n;
        } else {
          c.todos = (c.todos||[]).filter(y => y!==row);
        }
      }
    } else if (row) {
      row.done = true;
    }
  });
  const res = await window.scAwaitWrite(before);
  if (!res.ok) {
    if (res.lost) {
      /* 書き戻しは利用者の編集ではない(TODO詳細の finish と同じ守り)。 */
      const prev = window.__MARUROU_APPLYING_REMOTE__;
      window.__MARUROU_APPLYING_REMOTE__ = true;
      try {
        S.updateCase(r.caseId, c => {
          if (keep.track) {
            const hit = window.msTracks(c).find(x => x && x.t && x.t.id===keep.track.id);
            if (hit) { hit.t.steps = keep.track.steps; hit.t.state = keep.track.state; hit.t.status = keep.track.status; hit.t.stall = keep.track.stall; hit.t.mk = keep.track.mk; }
          }
          const todos = c.todos || [];
          const i = todos.findIndex(t => t.id===rowId);
          if (i >= 0) todos[i] = keep.row;
          else todos.splice(Math.max(0, Math.min(keep.rowIndex, todos.length)), 0, keep.row);
          c.todos = todos;
        });
      } finally { window.__MARUROU_APPLYING_REMOTE__ = prev; }
    }
    throw new Error(res.reason);
  }
  return { localOnly, isRail, railStep: row0.railStep || null };
}

const bvDueNum = s => { const [m,d]=s.split("/").map(Number); return m*100+d; };
// 本日は実際の日付から取る。モックでは 6/16 を焼き込んでいたが、それだと
// 7月・8月に期限が切れた案件が「あと〇日」と余裕があるように見えてしまい、
// 盤面の目的(滞りが見えること)と逆向きに狂う。
const bvToday = () => { const t = new Date(); t.setHours(0,0,0,0); return t; };
// 期限は "MM/DD" と "YYYY/MM/DD" の両方を受ける。年が無い表記は今年とみなすが、
// 半年以上先に見えるなら去年のもの(過ぎた期限)と解釈する。
const bvParseDue = s => {
  if (!s) return null;
  const p = String(s).split("/").map(Number);
  if (p.some(isNaN)) return null;
  const t = bvToday();
  if (p.length >= 3) return new Date(p[0], p[1]-1, p[2]);
  const [m,d] = p;
  let y = t.getFullYear();
  if (new Date(y, m-1, d) - t > 183*86400000) y -= 1;
  return new Date(y, m-1, d);
};
const bvTodayLabel = () => { const t = bvToday();
  return t.getFullYear()+"/"+String(t.getMonth()+1).padStart(2,"0")+"/"+String(t.getDate()).padStart(2,"0")
    +"（"+["日","月","火","水","木","金","土"][t.getDay()]+"）"; };
const bvDaysLeft = s => { const dt = bvParseDue(s); return dt===null ? 0 : Math.round((dt - bvToday())/86400000); };
const bvDueState = s => { const n=bvDaysLeft(s); return n<0?"over":n===0?"today":n<=2?"soon":"future"; };
const bvDueLabel = s => { const n=bvDaysLeft(s); return n<0?("期限超過 "+(-n)+"日"):n===0?"本日":n===1?"明日":("あと"+n+"日"); };
const bvDueColor = st => st==="over"?"var(--destructive)":st==="today"?"var(--warning)":st==="soon"?"var(--status-survey)":"var(--muted-foreground)";
const bvChip = { display:"inline-flex", alignItems:"center", gap:3, height:16, padding:"0 6px", borderRadius:"var(--radius-sm)", fontSize:"var(--text-caption)", fontWeight:700, whiteSpace:"nowrap", flexShrink:0, lineHeight:1 };

function BvMarks({ r }) {
  return (
    <span style={{ display:"inline-flex", gap:4, alignItems:"center", flexShrink:0 }}>
      {r.urgent && <span style={{ ...bvChip, background:"var(--urgency-emergency)", color:"var(--urgency-emergency-foreground)" }}>緊急</span>}
      {r.marker==="delay" && <SeverityBadge level={r.sev==="critical" ? "critical" : "normal"} />}
      {r.marker==="warn" && <span style={{ ...bvChip, color:"var(--status-survey)", background:"var(--status-survey-muted)", border:"1px solid var(--status-survey-border)" }}><span style={{ fontSize:7 }}>●</span>予兆</span>}
      {r.watch && <span style={{ ...bvChip, color:"var(--warning)", background:"var(--warning-muted)", border:"1px solid var(--warning-border)" }}>要注意</span>}
    </span>
  );
}

/* K18-05(0098): 全トラックが終了しているのに閉じていない案件の軽い印。導出は rpc_seed の
   derivedEnded(失注・適用外を含む「全部終わった」)。閉じる操作は案件詳細の既存の完了ボタン
   (closedFlag)のまま ─ ここは印だけで、閉じない。 */
function BvCloseHint({ c }) {
  if (!c || !c.closeHint) return null;
  return <span title="全トラックが終了しています。案件を閉じますか(案件詳細の「完了」から)" style={{ ...bvChip, color:"var(--success)", background:"var(--success-muted)", border:"1px solid var(--success-border)" }}>全部終了・閉じますか</span>;
}

/* K18-13(0105): 漏水が続いている案件(漏水状況が 常時/時々)の印「継続中」。規則は store.jsx の
   isLeakContinuing の 1 か所(boardRows / caseList の leakContinuing)。案件カードの「漏水状況」と同じ語。
   緊急(urgent)とは別 ─ 緊急は受付の判断、これは漏水状況の事実。 */
function BvLeakChip({ c }) {
  if (!c || !c.leakContinuing) return null;
  return <span data-leak-continuing title="漏水状況が 常時/時々(まだ漏れています)" style={{ ...bvChip, color:"var(--destructive)", background:"var(--destructive-muted)", border:"1px solid var(--destructive-border)" }}><span style={{ fontSize:7 }}>●</span>継続中</span>;
}

function BvBall({ who }) {
  if (window.BALL && window.BALL[who]) return <BallChip who={who} />;
  return <span style={{ fontSize:"var(--text-caption)", fontWeight:600, color:"var(--muted-foreground)", background:"var(--muted)", border:"1px solid var(--border)", borderRadius:"var(--radius-sm)", padding:"1px 6px", whiteSpace:"nowrap" }}>{who}</span>;
}

/* K18-23: 期限順の行。所属(案件／トラック／工程)を 1 行で添え(叩き台 mock の flat 表示)、
   完了できる行(トラックの今の工程・手動の TODO)には完了ボタンを付ける。
   所属の語: トラックは種類(調査/工事/保険)と進行コード、工程は rpc_seed の railStep(トラックの今の工程)。
   手動の TODO はトラックに結び付いていればそのトラック、無ければ「トラック外」、工程の代わりに「手動のTODO」。
   行の見出し(r.step)は今までどおり ─ TODO詳細の見出しと同じ語のままにする。 */
function BvTask({ r, dim, showProp, onOpenTask, info, onComplete, busy }) {
  const st = bvDueState(r.due), col = bvDueColor(st), bold = st==="over"||st==="today";
  const [hov, setHov] = React.useState(false);
  const kindLabel = info && info.kind ? BV_KIND_LABEL[info.kind] : null;
  const hasTrack = !!r.trk && r.trk!=="—";
  const stepText = r.railStep ? "工程："+r.railStep : "手動のTODO";
  const belong = [showProp ? r.prop : null, hasTrack ? [kindLabel, r.trk].filter(Boolean).join(" ") : "トラック外", stepText].filter(Boolean).join(" ／ ");
  const tier = bvIsActive(r) ? "mine" : "wait";
  /* 相手ボールだが滞り・緊急・要注意で「要対応」に入っている行(仕様 03 の 1 段目)。督促先(dunning)が
     あればそれを出す既存の行があるので、無いときだけ印を出す。 */
  const nudge = tier==="mine" && r.category!=="自社" && !r.dunning;
  const sep = <span aria-hidden="true" style={{ color:"var(--foreground-subtle)", flexShrink:0 }}>／</span>;
  const canComplete = !!(onComplete && info && info.canComplete);
  return (
    <div data-todo-row="" data-todo-id={r.todoId||""} data-case-id={r.caseId} data-todo-tier={tier}
      onClick={()=>onOpenTask&&onOpenTask(r)} onMouseEnter={()=>setHov(true)} onMouseLeave={()=>setHov(false)}
      style={{ display:"flex", flexWrap:"wrap", gap:"6px 10px", padding:"8px 14px", borderTop:"1px solid var(--border)", alignItems:"center", opacity:dim?0.66:1,
        cursor:onOpenTask?"pointer":"default", background:hov&&onOpenTask?"var(--accent)":"transparent", transition:"background var(--duration-fast) var(--easing)" }}>
      <BvMarks r={r} />
      {/* 狭い画面(スマホ)では右側(ボール・期限・完了)が次の行へ回り、見出しと所属の幅を残す。 */}
      <div style={{ flex:"1 1 220px", minWidth:0 }}>
        <div style={{ fontSize:"var(--text-body-md)", fontWeight:600, color:dim?"var(--muted-foreground)":"var(--foreground)" }}>{r.step}</div>
        <div data-todo-belong="" title={belong+"・"+r.btype} style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)", marginTop:2, display:"flex", gap:5, alignItems:"center", whiteSpace:"nowrap", overflow:"hidden", minWidth:0 }}>
          {showProp && <React.Fragment><span style={{ color:"var(--foreground)", fontWeight:600, overflow:"hidden", textOverflow:"ellipsis", minWidth:0, flexShrink:1 }}>{r.prop}</span>{sep}</React.Fragment>}
          {hasTrack
            ? <span style={{ overflow:"hidden", textOverflow:"ellipsis", minWidth:0, flexShrink:1 }}>{kindLabel && <span style={{ marginRight:4 }}>{kindLabel}</span>}<span className="code" style={{ color:"var(--brand)" }}>{r.trk}</span></span>
            : <span style={{ flexShrink:0 }}>トラック外</span>}
          {sep}
          <span style={{ overflow:"hidden", textOverflow:"ellipsis", minWidth:0, flexShrink:1 }}>{stepText}</span>
          <span style={{ color:"var(--foreground-subtle)", flexShrink:0, marginLeft:4 }}>{r.btype}</span>
        </div>
        {r.waitLabel && <div style={{ fontSize:"var(--text-caption)", color:"var(--warning)", fontWeight:600, marginTop:2 }}>{r.waitLabel}</div>}
      </div>
      <div style={{ display:"flex", alignItems:"center", gap:10, marginLeft:"auto", flexShrink:0 }}>
        <div style={{ textAlign:"right", flexShrink:0, minWidth:124 }}>
          <div style={{ display:"flex", justifyContent:"flex-end" }}><BvBall who={r.ball} /></div>
          <div style={{ marginTop:3 }}>
            <span className="mono" style={{ fontSize:"var(--text-body-sm)", fontWeight:bold?700:500, color:col }}>{r.due}</span>
            <span style={{ fontSize:"var(--text-caption)", color:col, marginLeft:5 }}>{bvDueLabel(r.due)}</span>
          </div>
          {r.dunning && <div style={{ fontSize:"var(--text-caption)", color:r.marker==="delay"?"var(--warning)":"var(--foreground-subtle)", marginTop:1 }}>→ {r.dunning}</div>}
          {nudge && <div title="相手ボールですが、滞り・緊急・要注意のため要対応に入れています(督促・確認)" style={{ fontSize:"var(--text-caption)", color:"var(--warning)", fontWeight:600, marginTop:1 }}>要督促</div>}
        </div>
        {onComplete && (
          <div style={{ width:64, flexShrink:0, display:"flex", justifyContent:"flex-end" }}>
            {canComplete && (
              <button type="button" data-todo-complete="" disabled={!!busy}
                aria-label={"「"+r.step+"」"+(r.railStep ? "の工程「"+r.railStep+"」" : "")+"を完了にする"}
                title={r.railStep ? "このトラックの今の工程を完了にします" : "このTODOを完了にします"}
                onClick={e=>{ e.stopPropagation(); if(!busy) onComplete(r); }}
                style={{ all:"unset", boxSizing:"border-box", cursor:busy?"default":"pointer", fontFamily:"var(--font-sans)", fontSize:"var(--text-caption)", fontWeight:700,
                  padding:"4px 9px", borderRadius:"var(--radius-full)", whiteSpace:"nowrap", opacity:busy?0.6:1,
                  border:"1px solid var(--brand-border, var(--border))", background:"var(--card)", color:"var(--brand)" }}>
                {busy ? "保存中…" : "✓ 完了"}
              </button>
            )}
          </div>
        )}
      </div>
    </div>
  );
}

function bvAgg(tasks) {
  return {
    urgent: tasks.some(t=>t.urgent),
    marker: tasks.some(t=>t.marker==="delay") ? "delay" : tasks.some(t=>t.marker==="warn") ? "warn" : "on",
    sev: tasks.some(t=>t.marker==="delay"&&t.sev==="critical") ? "critical" : "normal",
    watch: tasks.some(t=>t.watch),
  };
}

function BvCaseHead({ c, onOpen, compact }) {
  const st = bvDueState(c.nearDue);
  const active = c.tasks.filter(bvIsActive), waiting = c.tasks.filter(r=>!bvIsActive(r));
  return (
    <div onClick={()=>onOpen&&onOpen(c)} style={{ display:"flex", gap:10, padding:compact?"9px 14px":"11px 14px", alignItems:"center", cursor:onOpen?"pointer":"default", transition:"background var(--duration-fast) var(--easing)" }}
      onMouseEnter={e=>{ if(onOpen) e.currentTarget.style.background="var(--accent)"; }}
      onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
      <div style={{ flex:1, minWidth:0 }}>
        <div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap" }}>
          <span style={{ fontSize:"var(--text-body-lg)", fontWeight:700 }}>{c.prop}</span>
          {c.surveyLabel && <span style={{ fontSize:"var(--text-caption)", fontWeight:700, color:"var(--status-survey)", whiteSpace:"nowrap" }}>{c.surveyLabel}</span>}
          <BvMarks r={c.agg} />
          <BvCloseHint c={c} />
          <BvLeakChip c={c} />
        </div>
        <div className="code" style={{ fontSize:"var(--text-caption)", color:"var(--brand)", marginTop:2 }}>{c.aqId}</div>
      </div>
      <div style={{ textAlign:"right", flexShrink:0 }}>
        {!compact && <div style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)" }}>要対応 <b className="mono" style={{ color:"var(--foreground)" }}>{active.length}</b>{waiting.length>0 && <span style={{ color:"var(--foreground-subtle)" }}> ／ 待ち {waiting.length}</span>}</div>}
        <div className="mono" style={{ fontSize:"var(--text-caption)", color:bvDueColor(st), marginTop:2 }}>最短 {c.nearDue}・{bvDueLabel(c.nearDue)}</div>
      </div>
    </div>
  );
}

function BvCaseCard({ c, onOpen, onOpenTask }) {
  const [open, setOpen] = React.useState(false);
  const active = c.tasks.filter(bvIsActive), waiting = c.tasks.filter(r=>!bvIsActive(r));
  return (
    <AfCard pad={0} style={{ marginTop:12, overflow:"hidden" }}>
      <BvCaseHead c={c} onOpen={onOpen} />
      {active.length===0
        ? <div style={{ padding:"8px 14px", borderTop:"1px solid var(--border)", fontSize:"var(--text-body-sm)", color:"var(--foreground-subtle)" }}>今すぐの対応はなし（相手待ち {waiting.length}件）</div>
        : active.map((r,i)=><BvTask key={r.trk+i} r={r} onOpenTask={onOpenTask} />)}
      {waiting.length>0 && (
        <React.Fragment>
          <button onClick={()=>setOpen(!open)} style={{ all:"unset", boxSizing:"border-box", width:"100%", padding:"8px 14px", borderTop:"1px solid var(--border)", cursor:"pointer", color:"var(--muted-foreground)", fontSize:"var(--text-body-sm)", fontWeight:500, background:"var(--accent)" }}>
            {open?"▾":"▸"} 相手待ち・督促予定 {waiting.length}件
          </button>
          {open && waiting.map((r,i)=><BvTask key={"w"+r.trk+i} r={r} dim onOpenTask={onOpenTask} />)}
        </React.Fragment>
      )}
    </AfCard>
  );
}

/* 案件メタは store（MStore.caseMeta）から */
const BV_STAGE_TONE = {
  "受付":["var(--status-intake)","var(--status-intake-muted)","var(--status-intake-border)"],
  "調査":["var(--status-survey)","var(--status-survey-muted)","var(--status-survey-border)"],
  "工事":["var(--status-progress)","var(--status-progress-muted)","var(--status-progress-border)"],
  "完了":["var(--success)","var(--success-muted)","var(--success-border)"],
};
const BV_CASE_COLS = "18px 26px 46px minmax(140px,2fr) 46px minmax(74px,1fr) 38px 40px 50px 60px";
const BV_TR_TONE = { survey:["調査","var(--status-survey)"], work:["工事","var(--status-progress)"], ins:["保険","var(--warning)"], settle:["精算","var(--success)"] };
/* 案件に付随する稼働トラック（横断の進行データから導出・無ければTODOのtrk束から） */
/* トラック行の着地先（案件詳細のどの段階・どの左ペイン行を開くか） */
function bvTrackTarget(t, list){
  if(t.kind==="survey") return { stage:1, panelSel:"v"+Math.max(0, list.filter(x=>x.kind==="survey").indexOf(t)) };
  if(t.kind==="ins") return { stage:2, panelSel:"ins" };
  if(t.kind==="settle") return { stage:2, panelSel:"w0" };
  return { stage:2, panelSel:"w"+Math.max(0, list.filter(x=>x.kind==="work").indexOf(t)) };
}
function bvTracks(c){
  const tr = (window.TR_ROWS||[]).filter(r=>r.caseId===c.caseId && r.kind!=="settle");
  if(tr.length) return tr.map(r=>({
    kind:r.kind, name: r.kind==="work" ? r.target+" "+r.work : r.kind==="ins" ? r.vendor : r.kind==="settle" ? r.target : r.vendor,
    sub: r.kind==="work" ? r.vendor : r.kind==="ins" ? r.holder : r.kind==="settle" ? r.settleWay : r.target,
    step:r.step, status:r.status, mk:r.mk, due:r.due, state:r.state }));
  const map = {};
  c.tasks.forEach(t=>{ (map[t.trk] = map[t.trk] || []).push(t); });
  return Object.entries(map).map(([trk,list])=>{
    const head = [...list].sort((a,b)=>bvDueNum(a.due)-bvDueNum(b.due))[0];
    const kind = head.btype==="入金精算"?"settle" : /保険/.test(head.ball)?"ins" : /調査|報告/.test(head.step)?"survey" : "work";
    return { kind, name:{ survey:"原因調査", work:"復旧工事", ins:"保険申請", settle:"精算" }[kind]+"（"+trk+"）", sub:head.ball, step:head.step,
      status: head.marker==="delay"?"滞留" : head.category==="他者" ? head.ball+"待ち" : "進行中",
      mk:head.marker==="delay"?"delay":head.marker==="warn"?"warn":"", due:head.due, state:"now" };
  });
}

function BvCaseTable({ cases, sort, setSort, onOpen }) {
  const [open, setOpen] = React.useState({});
  const rows = React.useMemo(()=>{
    const meta = window.MStore ? window.MStore.caseMeta() : {};
    const list = cases.map(c=>({ ...c, meta:meta[c.caseId]||{} }));
    list.sort((a,b)=>{
      if(sort==="accepted") return (a.meta.accepted||"")>(b.meta.accepted||"") ? -1 : 1;
      if(sort==="dueDone"){ const x=(a.meta.dueDone==="—"?"12/NaN":a.meta.dueDone)||"12/NaN", y=(b.meta.dueDone==="—"?"12/NaN":b.meta.dueDone)||"12/NaN"; return x<y?-1:1; }
      if(sort==="todo") return b.tasks.length-a.tasks.length;
      if(sort==="due") return bvDueNum(a.nearDue)-bvDueNum(b.nearDue);
      return a.intake-b.intake;
    });
    return list;
  }, [cases, sort]);
  const Th = ({ label, k, right }) => (
    <button onClick={k?()=>setSort(k):undefined} style={{ all:"unset", fontFamily:"var(--font-sans)", fontSize:"var(--text-caption)", fontWeight:600, letterSpacing:"0.03em",
      color:sort===k?"var(--brand)":"var(--muted-foreground)", cursor:k?"pointer":"default", textAlign:right?"right":"left", whiteSpace:"nowrap" }}>
      {label}{k&&sort===k?" ↓":""}
    </button>
  );
  return (
    <div style={{ marginTop:12 }}>
      <div className="mr-board-case-desk" style={{ overflowX:"auto" }}>
      <div style={{ minWidth:640 }}>
        <AfCard pad={0} style={{ overflow:"hidden" }}>
          <div style={{ display:"grid", gridTemplateColumns:BV_CASE_COLS, gap:6, padding:"9px 12px", background:"var(--muted)", borderBottom:"1px solid var(--border-strong)", alignItems:"center", position:"sticky", top:0, zIndex:1 }}>
            <span></span><Th label="No." /><Th label="受付日" k="accepted" /><Th label="物件・案件名" />
            <Th label="段階" /><Th label="依頼先" /><Th label="担当" />
            <Th label="TODO" k="todo" /><Th label="完了予定" k="dueDone" /><Th label="対応期限" k="due" right />
          </div>
          {rows.length===0 && <div style={{ padding:18, fontSize:"var(--text-body-md)", color:"var(--foreground-subtle)" }}>該当なし</div>}
          {rows.map((c,i)=>{
            const m = c.meta, tone = BV_STAGE_TONE[m.stage]||BV_STAGE_TONE["受付"];
            const st = bvDueState(c.nearDue);
            const isOpen = !!open[c.caseId];
            const tracks = isOpen ? bvTracks(c) : [];
            return (
              <React.Fragment key={c.caseId}>
              <div onClick={()=>onOpen&&onOpen(c)} style={{ boxSizing:"border-box", width:"100%",
                display:"grid", gridTemplateColumns:BV_CASE_COLS, gap:6, padding:"10px 12px", fontFamily:"var(--font-sans)",
                cursor:"pointer", alignItems:"center", borderBottom:(isOpen||i<rows.length-1)?"1px solid var(--border)":"none",
                background:isOpen?"var(--accent)":"transparent",
                transition:"background var(--duration-fast) var(--easing)" }}
                onMouseEnter={e=>{ if(!isOpen) e.currentTarget.style.background="var(--accent)"; }}
                onMouseLeave={e=>{ if(!isOpen) e.currentTarget.style.background="transparent"; }}>
                <button onClick={e=>{ e.stopPropagation(); setOpen(o=>({ ...o, [c.caseId]:!o[c.caseId] })); }}
                  title={isOpen?"稼働トラックを閉じる":"稼働トラックを見る"}
                  style={{ all:"unset", cursor:"pointer", width:18, height:18, display:"flex", alignItems:"center", justifyContent:"center",
                    borderRadius:"var(--radius-sm)", color:isOpen?"var(--brand)":"var(--foreground-subtle)", fontSize:9,
                    transform:isOpen?"rotate(90deg)":"none", transition:"transform var(--duration-fast) var(--easing)" }}>▶</button>
                <span className="mono" style={{ fontSize:"var(--text-body-sm)", color:"var(--foreground-subtle)" }}>{i+1}</span>
                <span className="mono" style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)" }}>{m.accepted||"—"}</span>
                <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", minWidth:0 }}>{c.prop}</span>
                  {c.surveyLabel && <span style={{ fontSize:"var(--text-caption)", fontWeight:700, color:"var(--status-survey)", flexShrink:0, whiteSpace:"nowrap" }}>{c.surveyLabel}</span>}
                  <BvMarks r={c.agg} />
                  <BvCloseHint c={c} />
                  <BvLeakChip c={c} />
                </span>
                <span style={{ ...bvChip, color:tone[0], background:tone[1], border:"1px solid "+tone[2], justifyContent:"center" }}>{m.stage||"受付"}</span>
                <span style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{m.client||"—"}</span>
                <span style={{ display:"flex", flexDirection:"column", minWidth:0, lineHeight:1.2 }}>
                  <span style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{m.owner||"—"}</span>
                  {/* 決-21(追-4-3): 副担当・営業担当の補足表示(列は増やさない)。 */}
                  {(m.sub || m.sales) && (
                    <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>
                      {m.sub && ("副:"+m.sub)}{m.sub && m.sales ? "・" : ""}{m.sales && ("営:"+m.sales)}
                    </span>
                  )}
                </span>
                <span className="mono" style={{ fontSize:"var(--text-body-sm)", fontWeight:700, color:c.tasks.length?"var(--foreground)":"var(--foreground-subtle)" }}>{c.tasks.length}</span>
                <span className="mono" style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)" }}>{m.dueDone||"—"}</span>
                <span className="mono" style={{ fontSize:"var(--text-body-sm)", fontWeight:700, color:bvDueColor(st), textAlign:"right" }}>{bvDueLabel(c.nearDue)}</span>
              </div>
              {isOpen && (
                <div style={{ background:"var(--muted)", borderBottom:i<rows.length-1?"1px solid var(--border)":"none", padding:"7px 12px 9px 40px" }}>
                  <div style={{ fontSize:"var(--text-caption)", fontWeight:700, letterSpacing:"0.04em", color:"var(--muted-foreground)", marginBottom:5 }}>
                    稼働トラック <span className="mono">{tracks.length}</span> <span style={{ fontWeight:500, color:"var(--foreground-subtle)" }}>閲覧のみ・行タップで案件詳細のその進行へ</span>
                  </div>
                  {tracks.length===0 && <div style={{ fontSize:"var(--text-body-sm)", color:"var(--foreground-subtle)" }}>進行なし（受付のみ）</div>}
                  <div style={{ display:"flex", flexDirection:"column", gap:1 }}>
                    {tracks.map((t,j)=>{
                      const kt = BV_TR_TONE[t.kind]||BV_TR_TONE.work;
                      return (
                        <button key={j} onClick={()=>onOpen&&onOpen(c, bvTrackTarget(t, tracks))} style={{ all:"unset", boxSizing:"border-box", width:"100%", cursor:"pointer",
                          display:"grid", gridTemplateColumns:"38px minmax(100px,1.2fr) minmax(70px,0.8fr) 100px 82px 50px", gap:7, alignItems:"center",
                          padding:"6px 8px", borderRadius:"var(--radius-sm)", fontFamily:"var(--font-sans)",
                          transition:"background var(--duration-fast) var(--easing)" }}
                          onMouseEnter={e=>e.currentTarget.style.background="rgba(255,255,255,0.7)"}
                          onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
                          <span style={{ fontSize:"var(--text-caption)", fontWeight:700, color:kt[1], display:"flex", alignItems:"center", gap:3 }}><span style={{ fontSize:6 }}>●</span>{kt[0]}</span>
                          <span style={{ fontSize:"var(--text-body-sm)", fontWeight:600, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{t.name}</span>
                          <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{t.sub}</span>
                          <span style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>現在地 {t.step}</span>
                          <span style={{ fontSize:"var(--text-body-sm)", fontWeight:600, display:"flex", alignItems:"center", gap:5,
                            color:t.mk==="delay"?"var(--warning)":t.state==="done"?"var(--success)":"var(--brand)" }}>
                            {t.status}{t.mk==="delay" && <span style={{ fontSize:6 }}>●</span>}
                          </span>
                          <span className="mono" style={{ fontSize:"var(--text-caption)", color:bvDueColor(bvDueState(t.due)), textAlign:"right" }}>{t.due}</span>
                        </button>
                      );
                    })}
                  </div>
                </div>
              )}
              </React.Fragment>
            );
          })}
        </AfCard>
      </div>
      <div style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)", marginTop:8 }}>▶ で稼働トラック（調査・工事・保険・精算）を展開。行タップで案件詳細。列見出しタップでソート（標準＝受付日 新しい順）。段階は進行からの導出値。</div>
      </div>

      {/* ㉜ スマホ: 1行に案件名・遅れ・担当だけ。残りは畳む。 */}
      <div className="mr-board-case-mob">
        <AfCard pad={0} style={{ overflow:"hidden" }}>
          <div style={{ display:"flex", gap:8, padding:"8px 12px", background:"var(--muted)", borderBottom:"1px solid var(--border-strong)",
            fontSize:"var(--text-caption)", fontWeight:600, color:"var(--muted-foreground)" }}>
            <span style={{ flex:1 }}>案件名</span>
            <span style={{ width:72, textAlign:"right" }}>遅れ</span>
            <span style={{ width:56, textAlign:"right" }}>担当</span>
          </div>
          {rows.length===0 && <div style={{ padding:18, fontSize:"var(--text-body-md)", color:"var(--foreground-subtle)" }}>該当なし</div>}
          {rows.map((c,i)=>{
            const m = c.meta;
            const st = bvDueState(c.nearDue);
            const hasLag = c.agg && (c.agg.urgent || c.agg.marker==="delay" || c.agg.marker==="warn" || c.agg.watch);
            const lagText = (hasLag || st==="over" || st==="today") ? bvDueLabel(c.nearDue) : "—";
            const ownerShort = String(m.owner || "—").replace(/（.*）/, "").trim() || "—";
            return (
              <div key={"m-"+c.caseId} role="button" tabIndex={0} onClick={()=>onOpen&&onOpen(c)}
                onKeyDown={e=>{ if(e.key==="Enter"||e.key===" "){ e.preventDefault(); onOpen&&onOpen(c); } }}
                style={{ display:"flex", gap:8, alignItems:"center", padding:"10px 12px", cursor:"pointer",
                  borderBottom:i<rows.length-1?"1px solid var(--border)":"none", fontFamily:"var(--font-sans)" }}>
                <div style={{ flex:1, minWidth:0 }}>
                  <div style={{ fontSize:"var(--text-body-md)", fontWeight:600, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{c.prop}</div>
                  <div style={{ marginTop:3, display:"flex", gap:4, alignItems:"center", flexWrap:"wrap" }}>
                    <BvMarks r={c.agg} />
                    <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>{m.stage||"受付"}</span>
                  </div>
                </div>
                <span className="mono" style={{ width:72, textAlign:"right", flexShrink:0, fontSize:"var(--text-caption)", fontWeight:700, color:bvDueColor(st) }}>{lagText}</span>
                <span style={{ width:56, textAlign:"right", flexShrink:0, fontSize:"var(--text-caption)", color:"var(--muted-foreground)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{ownerShort}</span>
              </div>
            );
          })}
        </AfCard>
        <div style={{ fontSize:"var(--text-caption)", color:"var(--muted-foreground)", marginTop:8 }}>行タップで案件詳細。狭い画面では案件名・遅れ・担当だけを出しています。</div>
      </div>
    </div>
  );
}

function BvSeg({ items, value, onChange, small }) {
  return (
    <div style={{ display:"flex", gap:2, background:"var(--muted)", border:"1px solid var(--border)", padding:2, borderRadius:"var(--radius-md)", width:"fit-content" }}>
      {items.map(([k,l,tip])=>(
        <button key={k} type="button" data-seg-key={k} aria-pressed={value===k} title={tip||undefined} onClick={()=>onChange(k)} style={{
          all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)", fontSize:small?"var(--text-body-sm)":"var(--text-body-md)", fontWeight:600, whiteSpace:"nowrap",
          padding:small?"4px 11px":"5px 14px", borderRadius:"var(--radius-sm)", transition:"all var(--duration-fast) var(--easing)",
          background:value===k?"var(--card)":"transparent", color:value===k?"var(--brand)":"var(--muted-foreground)",
          boxShadow:value===k?"var(--shadow-sm)":"none", border:"1px solid "+(value===k?"var(--border-strong)":"transparent"),
        }}>{l}</button>
      ))}
    </div>
  );
}

function BvEmpty({ label }) {
  return <AfCard style={{ marginTop:14, padding:"24px 14px", textAlign:"center", fontSize:"var(--text-body-md)", color:"var(--foreground-subtle)" }}>{label}</AfCard>;
}

/* 決-10: needsAssignee を持つ工程がある案件か。assigneeUserId==null では数えない
   (移行データが空でも needsAssignee はサーバ生成列。Board はストア上の印だけ見る)。 */
function bvCaseHasNeedsAssignee(caseId) {
  if (!caseId || !window.MStore || !window.MStore.caseList) return false;
  const c = window.MStore.caseList().find(x => x.id === caseId || x.caseId === caseId);
  if (!c) return false;
  const ins = !c.ins ? [] : (Array.isArray(c.ins) ? c.ins : [c.ins]);
  const tracks = [...(c.visits || []), ...(c.works || []), ...ins];
  return tracks.some(t => Array.isArray(t && t.steps) && t.steps.some(s => s && s.needsAssignee));
}

function bvNeedsAssigneeCaseIds() {
  if (!window.MStore || !window.MStore.caseList) return new Set();
  const ids = new Set();
  window.MStore.caseList().forEach(c => {
    if (bvCaseHasNeedsAssignee(c.id)) ids.add(c.id);
  });
  return ids;
}

/* ページング2段目(決-1・0033)。「もっと見る」。無限スクロールにしない
   (現場で誤操作しやすいため。docs/05-roadmap.md §1.5 決-1)。
   件数はサーバで数えた全件数(MStore.counts())と、いま読み込んでいる件数を出す。 */
function BvLoadMore() {
  const store = window.useMStore ? window.useMStore() : null;
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState("");
  const paging = window.MStore ? window.MStore.paging() : { hasMore:false };
  const counts = window.MStore ? window.MStore.counts() : null;
  const loaded = (store && store.cases && store.cases.length) || 0;
  if (!paging.hasMore) return null;
  const onClick = async () => {
    setBusy(true); setErr("");
    try { await window.MStore.loadMoreCases(); }
    catch (e) { setErr((e && e.message) || "読み込めませんでした"); }
    finally { setBusy(false); }
  };
  return (
    <div style={{ display:"flex", flexDirection:"column", alignItems:"center", gap:6, marginTop:14 }}>
      <button onClick={onClick} disabled={busy} style={{ all:"unset", cursor:busy?"default":"pointer",
        fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)", fontWeight:600, padding:"7px 16px",
        borderRadius:"var(--radius-full)", border:"1px solid var(--border)",
        background:"var(--card)", color:"var(--brand)", opacity:busy?0.6:1 }}>
        {busy ? "読み込み中…" : `もっと見る（${loaded}${counts ? " / " + counts.cases : ""}件）`}
      </button>
      {err && <span style={{ fontSize:"var(--text-caption)", color:"var(--destructive)" }}>{err}</span>}
    </div>
  );
}

function bvReadCaseQuery(){
  try {
    const q = new URLSearchParams(typeof location !== "undefined" ? location.search : "");
    return {
      person: q.get("person") || "全員",
      lag: q.get("lag") || "",
      propKind: q.get("propKind") || "",
    };
  } catch (e) {
    return { person: "全員", lag: "", propKind: "" };
  }
}
function bvWriteCaseQuery({ person, lag, propKind }){
  if(typeof history === "undefined" || typeof location === "undefined") return;
  try {
    const q = new URLSearchParams(location.search);
    if(person && person !== "全員") q.set("person", person); else q.delete("person");
    if(lag) q.set("lag", lag); else q.delete("lag");
    if(propKind) q.set("propKind", propKind); else q.delete("propKind");
    const next = q.toString();
    const url = location.pathname + (next ? ("?" + next) : "") + (location.hash || "");
    if(url !== (location.pathname + location.search + location.hash))
      history.replaceState(null, "", url);
  } catch (e) { /* URL を触れない環境では黙ってスキップ */ }
}
const BV_PROP_KINDS = ["賃貸マンション", "分譲マンション", "アパート", "戸建て", "その他"];
const BV_LAG_OPTS = [["", "遅れ:すべて"], ["delay", "遅れ:赤"], ["warn", "遅れ:黄"], ["none", "遅れ:なし"]];

function Board({ onOpen, view, initialTab, initialMode, initialFilter, initialStage, onOpenTask, extraRows, onToast }) {
  const initQ = bvReadCaseQuery();
  const [aggr, setAggr] = React.useState("person");
  const [aggrVal, setAggrVal] = React.useState("全員");
  const [tab, setTab] = React.useState(initialTab||"すべて");
  const [mode, setMode] = React.useState(initialMode||"case");
  const [flag, setFlag] = React.useState(initialFilter||null);
  const [stageFilter, setStageFilter] = React.useState(initialStage||null);
  const [taskView, setTaskView] = React.useState("flat");
  // K18-23: 要対応（自社）/相手待ち/すべて(既定は要対応・仮置き)。行から完了の保存中の行。
  const [todoView, setTodoViewState] = React.useState(()=>bvInitialTodoView(initialTab, initialFilter));
  const setTodoView = v => { bvTodoViewLast = v; setTodoViewState(v); };
  const [completing, setCompleting] = React.useState({});
  React.useEffect(()=>{ if((initialTab && initialTab!=="すべて") || initialFilter) setTodoViewState("all"); }, [initialTab, initialFilter]);
  const [caseSort, setCaseSort] = React.useState("accepted");
  // 案件一覧の絞り込み(⑩)。URL query と同期。
  const [casePerson, setCasePerson] = React.useState(initQ.person);
  const [caseLag, setCaseLag] = React.useState(initQ.lag);
  const [casePropKind, setCasePropKind] = React.useState(initQ.propKind);
  React.useEffect(()=>{ if(initialTab) setTab(initialTab); }, [initialTab]);
  React.useEffect(()=>{ setFlag(initialFilter||null); }, [initialFilter]);
  React.useEffect(()=>{ setStageFilter(initialStage||null); }, [initialStage]);
  React.useEffect(()=>{ if(initialMode) setMode(initialMode); }, [initialMode]);
  React.useEffect(()=>{
    if(mode !== "case") return;
    bvWriteCaseQuery({ person: casePerson, lag: caseLag, propKind: casePropKind });
  }, [mode, casePerson, caseLag, casePropKind]);

  const AGG = {
    person:{ label:"担当", opts:["全員"] },
    role:  { label:"チーム", opts:["初動","受電","調査","工事","保険","案件管理","その他"] },
  };
  /* K18-19(決-17): 「チーム」の集約はまだ効かない(role は上の filtered で絞り込みに
     使われておらず、dev の担当役割も全件空)。担当・チームの集約は 11 月以降と決まった
     ので、それまで軸の切替から**隠す**(消さない)。戻すときは BV_AGG_HIDDEN を [] にする。 */
  const BV_AGG_HIDDEN = ["role"];
  const aggAxes = Object.entries(AGG).filter(([k]) => BV_AGG_HIDDEN.indexOf(k) < 0);
  const store = window.useMStore ? window.useMStore() : null;
  const base = React.useMemo(()=>{
    const rows = window.MStore ? window.MStore.boardRows() : [];
    return extraRows && extraRows.length ? [...rows, ...extraRows] : rows;
  }, [extraRows, store]);
  // 担当の選択肢は名簿(masters.users)を優先。無ければ盤面行の assignee。
  // 以前は「立石」1名固定で絞り込めなかった(盤面-2)。
  const personOpts = React.useMemo(()=>{
    const masters = (window.MStore && window.MStore.masters) ? window.MStore.masters().users : [];
    const fromMasters = (masters || []).map(u => u.name || u.displayName).filter(Boolean);
    const fromRows = base.map(r => r.assignee).filter(n => n && n !== "—");
    const names = [...new Set([...(fromMasters.length ? fromMasters : fromRows)])].sort();
    return ["全員", ...names];
  }, [base, store]);
  React.useEffect(()=>{
    if (aggr === "person" && personOpts.indexOf(aggrVal) < 0) setAggrVal("全員");
  }, [aggr, aggrVal, personOpts]);
  React.useEffect(()=>{
    if (casePerson !== "全員" && personOpts.indexOf(casePerson) < 0) setCasePerson("全員");
  }, [casePerson, personOpts]);
  const aggrOpts = aggr === "person" ? personOpts : AGG[aggr].opts;
  const flagged = React.useMemo(()=>{
    if(flag==="urgent") return base.filter(r=>r.urgent);
    if(flag==="delay") return base.filter(r=>r.marker==="delay");
    if(flag==="warn") return base.filter(r=>r.marker==="warn"||r.watch);
    if(flag==="needsAssignee") {
      const ids = bvNeedsAssigneeCaseIds();
      return base.filter(r => ids.has(r.caseId));
    }
    return base;
  }, [flag, base, store]);
  const filtered = React.useMemo(()=>{
    let rows = flagged.filter(r=>bvTypeMatch(r.btype, tab));
    if (aggr === "person" && aggrVal && aggrVal !== "全員")
      rows = rows.filter(r => r.assignee === aggrVal);
    return rows;
  }, [tab, flagged, aggr, aggrVal]);
  const taskCmp = (a,b)=> bvDueNum(a.due)-bvDueNum(b.due) || bvRank(a)-bvRank(b);
  // K18-23: TODO一覧だけ、要対応（自社）/相手待ち/すべて で絞る(案件一覧の「要対応／待ち」の数は絞らない)。
  // 切替の件数は種類タブ・担当・印で絞った後の行で数える(いま見ている範囲の内訳)。
  const viewCounts = React.useMemo(()=>{
    const n = { mine:0, wait:0, all:filtered.length };
    filtered.forEach(r=>{ if(bvIsActive(r)) n.mine++; else n.wait++; });
    return n;
  }, [filtered]);
  const viewed = React.useMemo(()=> mode==="task" ? filtered.filter(r=>bvTodoViewMatch(r, todoView)) : filtered, [filtered, mode, todoView]);
  // 行ごとの「完了できるか・トラックの種類」。店が変わるたび(完了して次の工程へ進んだ等)に引き直す。
  const rowInfo = React.useMemo(()=> mode==="task" ? bvRowInfo(viewed) : new Map(), [viewed, mode, store]);
  const completeRow = async r => {
    const key = bvRowKey(r);
    if (completing[key]) return;
    setCompleting(m => ({ ...m, [key]:true }));
    try {
      const res = await bvCompleteRow(r);
      onToast && onToast(res.localOnly
        ? "完了を画面にだけ残しました（案件へ保存できる経路がありません）"
        : "「"+r.step+"」"+(res.railStep ? "の工程「"+res.railStep+"」" : "")+"を完了にしました");
    } catch (e) {
      const reason = (e && e.message) || "完了を保存できませんでした。";
      if (typeof window.scSaveFail === "function") window.scSaveFail(reason);
      else if (typeof window.showSaveFail === "function") window.showSaveFail({ reason, title:"完了を保存できませんでした" });
      else console.warn("[TODO一覧] " + reason);
    } finally {
      setCompleting(m => { const n = { ...m }; delete n[key]; return n; });
    }
  };

  // 件数: rpc_open_counts に needsAssignee があればサーバ件数、無ければ読み込み分と明示
  const openCounts = window.MStore ? window.MStore.counts() : null;
  const needsAssigneeServerN = openCounts && openCounts.needsAssignee != null
    ? Number(openCounts.needsAssignee) : null;
  const needsAssigneeLoadedN = bvNeedsAssigneeCaseIds().size;
  const needsAssigneeLabelN = needsAssigneeServerN != null ? needsAssigneeServerN : needsAssigneeLoadedN;

  // 案件一覧の絞り込み(⑩): filterCases の結果に載る案件 id だけ残す。
  const allowedCaseIds = React.useMemo(()=>{
    if(mode !== "case" || !window.MStore || !window.MStore.filterCases) return null;
    const rows = window.MStore.filterCases({
      person: casePerson,
      lag: caseLag || null,
      propKind: casePropKind || null,
    });
    return new Set(rows.map(r => r.id));
  }, [mode, casePerson, caseLag, casePropKind, store]);

  const cases = React.useMemo(()=>{
    const map = {};
    viewed.forEach(r=>{
      if(allowedCaseIds && !allowedCaseIds.has(r.caseId)) return;
      if(!map[r.caseId]) map[r.caseId] = { caseId:r.caseId, aqId:"AQ-2026-"+r.caseId.slice(5), prop:r.prop, intake:r.intake, tasks:[], surveyLabel:r.surveyLabel||null, closeHint:!!r.closeHint, leakContinuing:!!r.leakContinuing };
      map[r.caseId].tasks.push(r);
    });
    // 段階絞込時は TODO が無い案件も stage で拾う（ダッシュボード段階チップからの遷移）。
    // ⑩の絞り込み時も、TODO が無い案件を filterCases から足す。
    if(mode==="case" && window.MStore){
      const seed = (allowedCaseIds
        ? window.MStore.filterCases({ person: casePerson, lag: caseLag || null, propKind: casePropKind || null })
        : window.MStore.caseList());
      seed.forEach(c=>{
        if(stageFilter && c.stage !== stageFilter) return;
        if(map[c.id]){
          if(!map[c.id].surveyLabel && c.surveyLabel) map[c.id].surveyLabel = c.surveyLabel;
          return;
        }
        map[c.id] = { caseId:c.id, aqId:"AQ-2026-"+String(c.id).slice(5), prop:c.name||c.prop, intake:Number(String(c.id).slice(5))||0, tasks:[], surveyLabel:c.surveyLabel||null, closeHint:!!c.closeHint, leakContinuing:!!c.leakContinuing };
      });
      if(stageFilter){
        Object.keys(map).forEach(id=>{
          const meta = (window.BV_CASEMETA||{})[id] || {};
          if((meta.stage||"受付")!==stageFilter) delete map[id];
        });
      }
    }
    const list = Object.values(map);
    list.forEach(c=>{
      if(c.tasks.length){
        c.nearDueNum = Math.min(...c.tasks.map(t=>bvDueNum(t.due)));
        c.nearDue = c.tasks.reduce((a,t)=> bvDueNum(t.due)<bvDueNum(a)?t.due:a, c.tasks[0].due);
        c.tasks.sort(taskCmp);
        c.agg = bvAgg(c.tasks);
      } else {
        c.nearDueNum = 9999; c.nearDue = "12/31"; c.agg = {};
      }
    });
    return list;
  }, [viewed, mode, stageFilter, store, allowedCaseIds, casePerson, caseLag, casePropKind]);

  const casesByIntake = React.useMemo(()=> [...cases].sort((a,b)=>a.intake-b.intake), [cases]);
  const casesByDue = React.useMemo(()=> [...cases].sort((a,b)=>a.nearDueNum-b.nearDueNum), [cases]);
  const flatTasks = React.useMemo(()=> [...viewed].sort(taskCmp), [viewed]);
  const tabCount = name => base.filter(r=>bvTypeMatch(r.btype, name)).length;
  const totalActive = cases.reduce((a,c)=>a+c.tasks.filter(bvIsActive).length,0);
  const totalWaiting = cases.reduce((a,c)=>a+c.tasks.filter(r=>!bvIsActive(r)).length,0);

  const openCase = (c, opt) => { if(onOpen) onOpen({ id:c.caseId, caseId:c.caseId, property:c.prop }, opt); };

  return (
    <div style={{ padding:"20px 24px 32px 20px" }}>
      <div style={{ height:2 }}></div>

      <div style={{ display:"flex", gap:14, alignItems:"center", flexWrap:"wrap" }}>
        {mode!=="case" && (
          <React.Fragment>
            <BvSeg small items={aggAxes.map(([k,a])=>[k,a.label])} value={aggr} onChange={k=>{ setAggr(k); setAggrVal(k==="person" ? "全員" : AGG[k].opts[0]); }} />
            <div style={{ display:"flex", gap:5, flexWrap:"wrap" }}>
              {aggrOpts.map(o=>(
                <button key={o} onClick={()=>setAggrVal(o)} style={{
                  all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)", fontWeight:600, padding:"4px 10px", borderRadius:"var(--radius-full)", whiteSpace:"nowrap",
                  border:"1px solid "+(aggrVal===o?"var(--brand)":"var(--border)"),
                  background:aggrVal===o?"var(--brand-muted)":"var(--card)", color:aggrVal===o?"var(--brand)":"var(--muted-foreground)",
                }}>{o}</button>
              ))}
            </div>
          </React.Fragment>
        )}
        {mode==="case" && (
          <React.Fragment>
            <span style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)" }}>担当(主/副/営)</span>
            <select aria-label="担当者で絞り込み" value={casePerson} onChange={e=>setCasePerson(e.target.value)}
              style={{ height:28, padding:"0 8px", borderRadius:"var(--radius-md)", border:"1px solid var(--input)",
                fontSize:"var(--text-body-sm)", fontFamily:"var(--font-sans)", color:"var(--foreground)", background:"var(--card)" }}>
              {personOpts.map(o => <option key={o} value={o}>{o}</option>)}
            </select>
            <select aria-label="遅れで絞り込み" value={caseLag} onChange={e=>setCaseLag(e.target.value)}
              style={{ height:28, padding:"0 8px", borderRadius:"var(--radius-md)", border:"1px solid var(--input)",
                fontSize:"var(--text-body-sm)", fontFamily:"var(--font-sans)", color:"var(--foreground)", background:"var(--card)" }}>
              {BV_LAG_OPTS.map(([v, lab]) => <option key={v||"all"} value={v}>{lab}</option>)}
            </select>
            <select aria-label="物件種別で絞り込み" value={casePropKind} onChange={e=>setCasePropKind(e.target.value)}
              style={{ height:28, padding:"0 8px", borderRadius:"var(--radius-md)", border:"1px solid var(--input)",
                fontSize:"var(--text-body-sm)", fontFamily:"var(--font-sans)", color:"var(--foreground)", background:"var(--card)" }}>
              <option value="">物件種別:すべて</option>
              {BV_PROP_KINDS.map(k => <option key={k} value={k}>{k}</option>)}
            </select>
            {(casePerson !== "全員" || caseLag || casePropKind) && (
              <button type="button" onClick={()=>{ setCasePerson("全員"); setCaseLag(""); setCasePropKind(""); }}
                style={{ all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)",
                  color:"var(--brand)", fontWeight:600 }}>絞り込みを解除</button>
            )}
          </React.Fragment>
        )}
        <button type="button" onClick={()=>setFlag(flag==="needsAssignee" ? null : "needsAssignee")} style={{
          all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)", fontWeight:600,
          padding:"4px 10px", borderRadius:"var(--radius-full)", whiteSpace:"nowrap",
          border:"1px solid "+(flag==="needsAssignee"?"var(--warning-border, var(--border))":"var(--border)"),
          background:flag==="needsAssignee"?"var(--warning-muted, var(--brand-muted))":"var(--card)",
          color:flag==="needsAssignee"?"var(--warning, var(--brand))":"var(--muted-foreground)",
        }}>
          未担当
          <span className="mono" style={{ fontSize:"var(--text-caption)", marginLeft:5, opacity:0.85 }}>
            {needsAssigneeLabelN}{needsAssigneeServerN == null ? "・読込分" : ""}
          </span>
        </button>
      </div>

      {mode!=="case" && (
      <div style={{ display:"flex", gap:6, marginTop:12, overflowX:"auto", paddingBottom:4, alignItems:"center" }}>
        {flag && (
          <button onClick={()=>setFlag(null)} style={{
            all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)", fontWeight:700, padding:"5px 11px", borderRadius:"var(--radius-full)", whiteSpace:"nowrap", flexShrink:0,
            color:"var(--warning)", background:"var(--warning-muted)", border:"1px solid var(--warning-border)",
          }}>{flag==="urgent"?"緊急":flag==="delay"?"滞留":flag==="needsAssignee"?"未担当":"期限注意"} ✕</button>
        )}
        {BV_TABS.map(name=>(
          <button key={name} onClick={()=>setTab(name)} style={{
            all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)", fontWeight:tab===name?700:500, padding:"5px 11px", borderRadius:"var(--radius-full)", whiteSpace:"nowrap",
            border:"1px solid "+(tab===name?"var(--brand)":"var(--border)"),
            background:tab===name?"var(--brand-muted)":"var(--card)", color:tab===name?"var(--brand)":"var(--muted-foreground)",
            transition:"all var(--duration-fast) var(--easing)",
          }}>{name}<span className="mono" style={{ fontSize:"var(--text-caption)", color:tab===name?"var(--brand)":"var(--foreground-subtle)", marginLeft:5 }}>{tabCount(name)}</span></button>
        ))}
      </div>
      )}

      {mode==="task" && (
        <div style={{ display:"flex", gap:8, marginTop:12, alignItems:"center", flexWrap:"wrap" }}>
          {/* K18-23: 要対応（自社）/相手待ち/すべて(叩き台 mock:41)。件数は種類タブ・担当・印で絞った後の内訳。 */}
          <div data-todo-views="">
            <BvSeg small value={todoView} onChange={setTodoView}
              items={BV_TODO_VIEWS.map(([k,l])=>[k,
                <React.Fragment>{l}<span className="mono" style={{ fontSize:"var(--text-caption)", marginLeft:5, opacity:0.8 }}>{viewCounts[k]}</span></React.Fragment>,
                k==="mine" ? "自社ボールのTODOと、相手ボールでも滞り・緊急・要注意のもの(督促・確認が要る)"
                  : k==="wait" ? "相手ボールで、滞り・緊急・要注意の無いもの(通常の待ち)" : "すべてのTODO"])} />
          </div>
          <span style={{ fontSize:"var(--text-body-sm)", color:"var(--foreground-subtle)", marginLeft:6 }}>期限順・表示</span>
          <BvSeg small items={[["flat","TODO並び"],["grouped","案件並び"]]} value={taskView} onChange={setTaskView} />
        </div>
      )}

      {mode==="case" && stageFilter && (
        <div style={{ display:"flex", gap:6, marginTop:12, alignItems:"center", flexWrap:"wrap" }}>
          <button onClick={()=>setStageFilter(null)} style={{
            all:"unset", cursor:"pointer", fontFamily:"var(--font-sans)", fontSize:"var(--text-body-sm)", fontWeight:700, padding:"5px 11px", borderRadius:"var(--radius-full)", whiteSpace:"nowrap",
            color:"var(--brand)", background:"var(--brand-muted)", border:"1px solid var(--brand-border)",
          }}>段階：{stageFilter} ✕</button>
          <span style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)" }}>ダッシュボードの段階チップから絞込中</span>
        </div>
      )}

      <div style={{ fontSize:"var(--text-body-sm)", color:"var(--muted-foreground)", marginTop:14 }}>
        {mode==="case"
          ? <React.Fragment><span className="mono">{casesByIntake.length}</span>案件・受付順 ／ 要対応<span className="mono">{totalActive}</span>件<span style={{ color:"var(--foreground-subtle)" }}>・相手待ち<span className="mono">{totalWaiting}</span>件</span>
              {flag==="needsAssignee" && needsAssigneeServerN == null && (
                <span style={{ color:"var(--foreground-subtle)" }}> ・未担当はいま読み込んだ案件のうち、工程に印がある分</span>
              )}
            </React.Fragment>
          : taskView==="flat"
            ? <React.Fragment>{bvTodoViewLabel(todoView)}のTODO <span className="mono">{flatTasks.length}</span>件・期限順（同期限は緊急度順）</React.Fragment>
            : <React.Fragment>{bvTodoViewLabel(todoView)}：<span className="mono">{casesByDue.length}</span>案件・最短期限順／各案件内も期限順</React.Fragment>}
      </div>

      {mode==="case" ? (
        casesByIntake.length===0 ? <BvEmpty label="該当する案件はありません" />
          : <React.Fragment>
              <BvCaseTable cases={casesByIntake} sort={caseSort} setSort={setCaseSort} onOpen={openCase} />
              <BvLoadMore />
            </React.Fragment>
      ) : taskView==="flat" ? (
        flatTasks.length===0 ? <BvEmpty label="該当するTODOはありません" />
          : <AfCard pad={0} style={{ marginTop:12, overflow:"hidden" }}>
              <div style={{ height:1, marginTop:-1 }}></div>
              {flatTasks.map((r,i)=><BvTask key={r.caseId+r.trk+i} r={r} showProp onOpenTask={onOpenTask}
                info={rowInfo.get(bvRowKey(r))} onComplete={completeRow} busy={!!completing[bvRowKey(r)]} />)}
            </AfCard>
      ) : (
        casesByDue.length===0 ? <BvEmpty label="該当する案件はありません" />
          : casesByDue.map(c=>(
              <AfCard key={c.caseId} pad={0} style={{ marginTop:12, overflow:"hidden" }}>
                <BvCaseHead c={c} onOpen={openCase} compact />
                {c.tasks.map((r,i)=><BvTask key={r.trk+i} r={r} onOpenTask={onOpenTask}
                  info={rowInfo.get(bvRowKey(r))} onComplete={completeRow} busy={!!completing[bvRowKey(r)]} />)}
              </AfCard>
            ))
      )}

      <div style={{ fontSize:"var(--text-caption)", color:"var(--foreground-subtle)", marginTop:18, lineHeight:1.7 }}>
        供給：v_board（集約軸=担当/役割・業務種類=タブ・状態マーカー=直交）。案件一覧＝受付順・テーブル。TODO一覧＝期限順（同期限は緊急度高い順）。
      </div>
    </div>
  );
}

Object.assign(window, { Board, BV_TABS, BV_TODO_VIEWS, bvTodoViewMatch, bvCanCompleteRow, bvCompleteRow, bvIsActive, bvDueState, bvDueLabel, bvDueNum, bvRank, bvDaysLeft, bvParseDue, bvToday, bvTodayLabel });
