// App — login → shell → board / case detail / intake / payment / tasks + dispatch modal + toast.

function Login({ onLogin }) {
  // 接続先(window.MarurouCloud)が設定されていれば本物の認証を行う。
  // 設定が無い開発時は、従来どおり素通りする。
  const cloud = typeof window !== 'undefined' ? window.MarurouCloud : null;
  const [email, setEmail] = React.useState(cloud ? '' : 'dev@marurou.local');
  const [pw, setPw] = React.useState(cloud ? '' : '········');
  const [busy, setBusy] = React.useState(false);
  const [resetBusy, setResetBusy] = React.useState(false);
  const [resetSent, setResetSent] = React.useState(false);
  // Supabase 既定SMTPは1時間に数通。連打すると届かなくなる(今日の本番で実例あり)。
  const [resetCooldownUntil, setResetCooldownUntil] = React.useState(0);
  const [now, setNow] = React.useState(() => Date.now());
  const [err, setErr] = React.useState('');
  const cooling = resetCooldownUntil > now;
  const coolSec = cooling ? Math.max(1, Math.ceil((resetCooldownUntil - now) / 1000)) : 0;

  React.useEffect(() => {
    if (!cooling) return undefined;
    const t = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(t);
  }, [cooling]);

  const submit = async () => {
    if (!cloud) return onLogin();
    setErr(''); setBusy(true);
    try {
      await cloud.login(email.trim(), pw);
      onLogin();
    } catch (e) {
      // 「入力が違う」のか「つながらない」のかを利用者に伝える。
      // ここを一緒くたにすると、現場は原因の分からないまま何度も打ち直す。
      setErr(e && e.message ? e.message : 'ログインできませんでした');
    } finally { setBusy(false); }
  };
  const onKey = e => { if (e.key === 'Enter' && !busy) submit(); };

  // 本人セルフの再設定。anon key だけで送れるので管理者は要らない。
  // marurou-cloud.js の requestPasswordReset(=recover)。宛先の存在は明かさない。
  const requestReset = async () => {
    const addr = email.trim();
    if (!addr) { setErr('メールアドレスを入れてから押してください'); return; }
    if (!cloud || typeof cloud.requestPasswordReset !== 'function') {
      setErr('接続の設定が読めていません'); return;
    }
    if (cooling || resetBusy) return;
    setErr(''); setResetBusy(true);
    try {
      await cloud.requestPasswordReset(addr);
      setResetSent(true);
      setResetCooldownUntil(Date.now() + 60 * 1000);
      setNow(Date.now());
    } catch (e) {
      // 429 等は marurou-cloud.js の日本語化に乗せる(「試行が多すぎます…」)。
      setErr(e && e.message ? e.message : '再設定メールを送れませんでした。時間をおいて試してください');
    } finally { setResetBusy(false); }
  };

  return (
    <div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--background)' }}>
      <AfCard style={{ width: 320, padding: 28 }}>
        <span className="mr-logo mr-logo--lg" role="img" aria-label="Marurou" style={{ marginBottom: 4 }}><span className="mr-logo-mark">M</span><span className="mr-logo-text">Maru<span className="mr-flow">rou</span></span></span>
        <div style={{ fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)', marginTop: 8, marginBottom: 4, lineHeight: 1.55 }}>
          招待メールから初めての方は、メールのリンクでパスワードを決めてから入ってください。
        </div>
        <div style={{ height: 12 }}></div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
          <div>
            <div style={{ fontSize: 'var(--text-caption)', fontWeight: 600, letterSpacing: '0.08em', color: 'var(--muted-foreground)', textTransform: 'uppercase', marginBottom: 4 }}>メールアドレス</div>
            <AfInput value={email} onChange={e => setEmail(e.target.value)} onKeyDown={onKey}
                     autoComplete="username" style={{ width: '100%' }} />
          </div>
          <div>
            <div style={{ fontSize: 'var(--text-caption)', fontWeight: 600, letterSpacing: '0.08em', color: 'var(--muted-foreground)', textTransform: 'uppercase', marginBottom: 4 }}>パスワード</div>
            <AfInput type="password" value={pw} onChange={e => setPw(e.target.value)} onKeyDown={onKey}
                     autoComplete="current-password" style={{ width: '100%' }} />
          </div>
          {err && <div style={{ fontSize: 'var(--text-body-sm)', color: 'var(--destructive)', lineHeight: 1.6 }}>{err}</div>}
          <AfButton variant="primary" onClick={submit} disabled={busy}
                    style={{ width: '100%', height: 34, marginTop: 6, opacity: busy ? 0.6 : 1 }}>
            {busy ? 'ログイン中…' : 'ログイン'}
          </AfButton>
          <button type="button" onClick={requestReset} disabled={resetBusy || cooling}
            style={{ all: 'unset', fontSize: 'var(--text-body-sm)',
                     color: (resetBusy || cooling) ? 'var(--muted-foreground)' : 'var(--brand)',
                     cursor: (resetBusy || cooling) ? 'default' : 'pointer',
                     textAlign: 'center', marginTop: 2, opacity: (resetBusy || cooling) ? 0.7 : 1 }}>
            {resetBusy ? '送信中…' : cooling ? `再送は ${coolSec} 秒後` : 'パスワードを忘れた方'}
          </button>
          <div style={{ fontSize: 'var(--text-caption)', color: 'var(--muted-foreground)', lineHeight: 1.6, textAlign: 'center' }}>
            上のメール欄に自分のアドレスを入れてから押してください。届かないときは迷惑メールも確認し、それでも無いときは施主（大島さん）に伝えてください。
          </div>
          {resetSent && <div style={{ fontSize: 'var(--text-body-sm)', color: 'var(--success)', lineHeight: 1.6 }}>
            メールを送りました（登録がある場合）。迷惑メールもご確認ください。何度も押すと届かなくなることがあります。それでも届かないときは施主（大島さん）へ。
          </div>}
        </div>
      </AfCard>
    </div>
  );
}

function Toast({ msg }) {
  if (!msg) return null;
  return <div style={{
    position: 'fixed', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 60,
    background: 'var(--foreground)', color: 'var(--card)', padding: '9px 16px', borderRadius: 'var(--radius-md)',
    fontSize: 'var(--text-body-md)', fontWeight: 500, boxShadow: 'var(--shadow-lg)', animation: 'mrpop var(--duration-base) var(--easing)',
  }}>{msg}</div>;
}

// "YYYY/MM/DD"(モーダルの既定表示)や "YYYY-MM-DD" を DB の date 列向けに
// "YYYY-MM-DD" へ揃える。Postgresの日付パーサの表記ゆれ(DateStyle設定)に
// 賭けない ─ 形が違えば null を返し、呼び出し側が保存を諦めて正直に伝える。
function toIsoDate(s) {
  const m = /^(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/.exec(String(s || '').trim());
  if (!m) return null;
  const mm = m[2].padStart(2, '0'), dd = m[3].padStart(2, '0');
  return m[1] + '-' + mm + '-' + dd;
}

function DispatchModal({ caseName, caseId, caseKey, onClose, onConfirm }) {
  const [vendors, setVendors] = React.useState(null);
  // K18-18: 依頼者規定の帯(取引条件だけ)。caseKey は店の案件ID(caseId は DB の id)。
  const kase = caseKey && window.MStore ? window.MStore.case(caseKey) : null;
  const [sel, setSel] = React.useState(null);
  const [date, setDate] = React.useState(() => {
    const d = new Date();
    return d.getFullYear() + "/" + String(d.getMonth()+1).padStart(2,"0") + "/" + String(d.getDate()).padStart(2,"0");
  });
  // 案件詳細-25: 直近に選んだ候補があれば見せる(リロードでも残るようになったため、
  // 「前にも手配していたのを知らずにまた手配する」二重手配のリスクを減らす)。
  const [past, setPast] = React.useState(null);
  React.useEffect(() => {
    const run = window.pvFetch ? window.pvFetch("", "業者", 50) : Promise.resolve({ rows: [] });
    run.then(r => {
      const list = (r && r.rows) || [];
      setVendors(list);
      if (list[0]) setSel(list[0].id);
    }).catch(() => setVendors([]));
  }, []);
  React.useEffect(() => {
    const cloud = typeof window !== 'undefined' ? window.MarurouCloud : null;
    if (!caseId || !cloud || typeof cloud.listCaseDispatches !== 'function') { setPast([]); return; }
    let alive = true;
    cloud.listCaseDispatches(caseId, { limit: 3 })
      .then(r => { if (alive) setPast((r && r.rows) || []); })
      .catch(() => { if (alive) setPast([]); });
    return () => { alive = false; };
  }, [caseId]);
  const picked = (vendors || []).find(p => p.id === sel);
  const note = p => {
    const skill = (p.vendorSkills || [])[0];
    const area = (p.vendorAreas || [])[0];
    const speed = p.vendorCanEmergency ? "緊急可" : null;
    return [skill, area, speed].filter(Boolean).join(" · ") || "発注実績あり";
  };
  return (
    <div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 50, background: 'rgba(20,22,28,0.35)', display: 'flex', alignItems: 'center', justifyContent: 'center', animation: 'mrfade var(--duration-fast) var(--easing)' }}>
      {/* K18-18: 業者が 50 社並ぶと画面より高くなり、見出し・帯・ボタンが画面の外に出て押せなかった。
          高さを画面に収め、業者の一覧の部分だけをスクロールさせる(見出し・取引条件の帯・ボタンは常に見える)。 */}
      <div onClick={e => e.stopPropagation()} style={{ width: 'var(--modal-w-md)', maxWidth: 'calc(100vw - 48px)', maxHeight: 'calc(100vh - 48px)', display: 'flex', flexDirection: 'column', background: 'var(--card)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-lg)', border: '1px solid var(--border)', animation: 'mrpop var(--duration-base) var(--easing)' }}>
        <div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center' }}>
          <div>
            <h3 style={{ margin: 0, fontSize: 'var(--text-display-sm)', fontWeight: 600 }}>下請手配</h3>
            <div style={{ fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)', marginTop: 2 }}>{caseName}</div>
          </div>
          <div style={{ flex: 1 }} />
          <button onClick={onClose} style={{ all: 'unset', cursor: 'pointer', color: 'var(--muted-foreground)', fontSize: 16, padding: 4 }}>✕</button>
        </div>
        {/* K18-18: 業者手配の画面には 取引条件 を常に出す(仕様 04:130)。スクロールの外に置く。値が無ければ何も出さない。 */}
        <CaseClientRulesBand where="dispatch" caseId={caseKey} clientDbId={(kase && kase.clientDbId) || null}
          clientName={kase ? kase.client : ''} fields={['tradeTerms']}
          style={{ margin: '12px 16px 0', flexShrink: 0, maxHeight: '30vh', overflowY: 'auto' }} />
        <div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 14, overflowY: 'auto', minHeight: 0, flex: '1 1 auto' }}>
          <div>
            <div style={{ fontSize: 'var(--text-caption)', fontWeight: 600, letterSpacing: '0.06em', color: 'var(--muted-foreground)', textTransform: 'uppercase', marginBottom: 6 }}>業者を選択</div>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
              {vendors===null && <div style={{ fontSize: 'var(--text-body-sm)', color: 'var(--foreground-subtle)' }}>読み込み中…</div>}
              {vendors && vendors.length===0 && <div style={{ fontSize: 'var(--text-body-sm)', color: 'var(--foreground-subtle)' }}>発注実績のある業者がまだいません。</div>}
              {(vendors||[]).map(p => {
                const active = p.id === sel;
                return (
                  <button key={p.id} onClick={() => setSel(p.id)} style={{
                    all: 'unset', cursor: 'pointer', boxSizing: 'border-box', display: 'flex', alignItems: 'center', gap: 10, width: '100%',
                    padding: '9px 12px', borderRadius: 'var(--radius-md)', border: '1px solid ' + (active ? 'var(--brand)' : 'var(--border)'),
                    background: active ? 'var(--brand-muted)' : 'var(--card)',
                  }}>
                    <span style={{ width: 15, height: 15, borderRadius: 999, border: '2px solid ' + (active ? 'var(--brand)' : 'var(--border-strong)'), display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{active && <span style={{ width: 7, height: 7, borderRadius: 999, background: 'var(--brand)' }} />}</span>
                    <span style={{ fontSize: 'var(--text-body-md)', fontWeight: 600, flex: 1 }}>{p.name}</span>
                    <span style={{ fontSize: 'var(--text-caption)', color: 'var(--muted-foreground)' }}>{note(p)}</span>
                  </button>
                );
              })}
            </div>
          </div>
          <div>
            <div style={{ fontSize: 'var(--text-caption)', fontWeight: 600, letterSpacing: '0.06em', color: 'var(--muted-foreground)', textTransform: 'uppercase', marginBottom: 6 }}>着工日</div>
            <AfInput mono value={date} onChange={e => setDate(e.target.value)} style={{ width: 160 }} />
          </div>
          {past && past.length > 0 && (
            <AfAlert variant="notice" title="すでに手配ずみの候補があります">
              {past.map(d => (d.vendorOrgName || '(業者不明)') + '（着工 ' + (d.plannedStartOn || '未定') + '）').join('、')}
              。二重に手配していないか確認してください。
            </AfAlert>
          )}
          <AfAlert variant="info" title="手配">選んだ業者と着工日は保存されます。発注書（範囲・金額の確定）の作成はまだこの画面からはできません。</AfAlert>
        </div>
        <div style={{ padding: '12px 16px', borderTop: '1px solid var(--border)', display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
          <AfButton onClick={onClose}>キャンセル</AfButton>
          <AfButton variant="primary" disabled={!picked} onClick={() => picked && onConfirm(picked, date)}>候補を選ぶ</AfButton>
        </div>
      </div>
    </div>
  );
}

function Placeholder({ title }) {
  return (
    <div style={{ padding: '24px 24px 32px 20px' }}>
      <AfCard style={{ padding: 40, textAlign: 'center', color: 'var(--muted-foreground)' }}>
        <div style={{ fontSize: 'var(--text-display-sm)', fontWeight: 600, color: 'var(--foreground)' }}>{title}</div>
        <div style={{ fontSize: 'var(--text-body-md)', marginTop: 6 }}>この画面はまだ作っていません。ダッシュボード・受付・案件詳細・支払い・精算・TODOをご確認ください。</div>
      </AfCard>
    </div>
  );
}

const TITLES = { dashboard: 'ダッシュボード', daily: '日次レポート', receipts: '入金レポート', cases: '案件一覧', tasks: 'TODO一覧', clients: '取引先一覧', intake: '受付', payment: '支払い・精算', survey: '調査一覧', work: '工事一覧', ins: '保険一覧', settle: '精算一覧', admin: '管理' };
const TRACK_NAV = { survey: 'survey', work: 'work', ins: 'ins', settle: 'settle' };

function App() {
  const store = window.useMStore ? window.useMStore() : null;
  // ログイン済みなら入り直させない。認証の状態は MarurouCloud が
  // localStorage に持っているので、再読込しても続く(毎朝入力させない)。
  const [authed, setAuthed] = React.useState(
    () => !!(typeof window !== 'undefined' && window.MarurouCloud && window.MarurouCloud.isLoggedIn())
  );
  const [nav, setNav] = React.useState('dashboard');
  const [sidebarOpen, setSidebarOpen] = React.useState(false);
  const [openCase, setOpenCase] = React.useState(null); // case id
  const [paymentReturn, setPaymentReturn] = React.useState(null);
  const [paymentFromList, setPaymentFromList] = React.useState(false);
  const [modal, setModal] = React.useState(false);
  const [toast, setToast] = React.useState('');
  const [navLabel, setNavLabel] = React.useState('');
  const [boardOpt, setBoardOpt] = React.useState({});
  const [openTodo, setOpenTodo] = React.useState(null);
  const [openPartner, setOpenPartner] = React.useState(null);
  const [taskModal, setTaskModal] = React.useState(false);
  const [contact, setContact] = React.useState(null);
  const [q, setQ] = React.useState('');
  const [openTrack, setOpenTrack] = React.useState(null);
  const [caseOpt, setCaseOpt] = React.useState(null);

  const todoQueue = React.useMemo(() => {
    const rows = [...(window.BV_ROWS || [])];
    return rows.sort((x, y) => window.bvDueNum(x.due) - window.bvDueNum(y.due) || window.bvRank(x) - window.bvRank(y));
  }, [store]);

  React.useEffect(() => { if (!toast) return; const t = setTimeout(() => setToast(''), 2400); return () => clearTimeout(t); }, [toast]);
  React.useEffect(() => {
    const onKey = e => {
      if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
        e.preventDefault();
        const el = document.getElementById('mr-topnav-q');
        if (el) el.focus();
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);
  // 盤面データの第2案(0075): claimDbId/lineDbId(精算-2)を rpc_seed から外し、
  // 案件を開いたときの個別取得(rpc_case_detail_context の 'claims')へ移した。
  // 精算画面(Payment.jsx。触らない ─ cursorの束の範囲)へは
  // (a) 案件詳細の「精算画面へ」(CaseDetailV25が既に自分の場でhydrate済み)と
  // (b) Payment.jsx内蔵UnpaidListの「行を押す」(hydrateを一度も経ていない)
  // の2つの入口があり、(b)は充当の候補作り(allocatableTracks)がlineDbId/
  // claimDbIdの合流を一度も経ないまま描画される。paymentReturn(caseId)が
  // 変わるたびにここでensureCaseDetailContextを呼び、store側でhydrateの開始を
  // 保証する(重複呼び出しはensureCaseDetailContext自身がstatus/requestで
  // 束ねるので害はない)。完了を待って画面をブロックはしない ─ 0066の
  // building/partiesも同様に非ブロッキングで、mergeCaseDetailContextの反映後は
  // store更新でPayment.jsxが自然に再描画される。
  React.useEffect(() => {
    if (!paymentReturn || !window.MStore || !window.MStore.ensureCaseDetailContext) return;
    const c = window.MStore.case(paymentReturn);
    const dbId = c && c.dbId;
    if (!dbId) return;
    window.MStore.ensureCaseDetailContext(paymentReturn, dbId).catch(() => {});
  }, [paymentReturn]);
  // H1(9/25 検証の指摘): いま開いている案件を店に知らせる。起動時の差し替え(MStore.replaceCasesFromSeed)は、
  // 差し替えの前に控えから開いた案件が直近 N 件から外れていても、開いている画面から消さない。
  const openTodoCase = openTodo && openTodo.caseId;
  React.useEffect(() => {
    if (!window.MStore || typeof window.MStore.setViewingCaseIds !== 'function') return;
    window.MStore.setViewingCaseIds([openCase, paymentReturn, openTodoCase]);
  }, [openCase, paymentReturn, openTodoCase]);

  // ㉟ 左ナビ件数の「全体/自分」切替(hooksは early return の前に置く)
  const [countScope, setCountScope] = React.useState(() =>
    (typeof window.readNavCountScope === 'function' ? window.readNavCountScope() : 'all'));
  const mineCountsHook = store && store.counts && store.counts.mine;
  const mineReadyHook = !!(mineCountsHook && typeof mineCountsHook.cases === 'number');
  React.useEffect(() => {
    if (countScope === 'mine' && !mineReadyHook) setCountScope('all');
  }, [countScope, mineReadyHook]);

  if (!authed) return <Login onLogin={() => setAuthed(true)} />;

  const current = openCase ? (window.MStore ? window.MStore.case(openCase) : null) : null;
  const sidebarActive = nav === 'payment' && paymentReturn ? 'cases' : (openCase && nav === 'cases') ? 'cases' : nav;

  function goCase(id, opt) { setOpenCase(id); setCaseOpt(opt || null); setNav('cases'); setOpenTodo(null); setOpenPartner(null); setOpenTrack(null); setQ(''); }
  function goCaseByName(name) { const cs = window.MStore ? window.MStore.get().cases : []; const hit = cs.find(c => c.name === name) || cs[0]; if (hit) goCase(hit.id); }
  // ページング2段目(決-1・0033)。検索(横断検索)は直近N件に入らない古い案件も
  // 見つける(rpc_case_search)。見つけた案件は開くとき初めて1件だけ本体を取る
  // (MStore.ensureCaseLoaded)。dbId が無い(=名前だけの旧来経路)場合は
  // 従来どおり名前で探す。
  async function goCaseByDbId(dbId, name) {
    if (!dbId) return goCaseByName(name);
    const cs = window.MStore ? window.MStore.get().cases : [];
    const hit = cs.find(c => c.dbId === dbId);
    if (hit) { goCase(hit.id); return; }
    try {
      const found = window.MStore ? await window.MStore.ensureCaseLoaded(dbId) : null;
      if (found) { goCase(found.id); return; }
      setToast('案件が見つかりませんでした');
    } catch (e) { setToast((e && e.message) || '案件を開けませんでした'); }
  }

  // ページング2段目(決-1・0033)。件数バッジは「読み込んだ分だけ」で数えない ─
  // rpc_open_counts が数えたサーバの全件数を出す。まだ届いていない(counts===null)
  // 間は読み込み中として、件数を出さない(0件のように見せない)。
  // 読み込んだ分が全件より少ないときは、見出しに「直近N件を表示」と正直に添える。
  // ㉟: 左ナビの「全体/自分」切替に合わせて、見出し件数も mine を使う。
  const counts = window.MStore ? window.MStore.counts() : null;
  const mineCounts = window.MStore && window.MStore.mine ? window.MStore.mine() : null;
  const mineReady = !!(mineCounts && typeof mineCounts.cases === 'number');
  const useMine = countScope === 'mine' && mineReady;
  const badgeCases = useMine ? mineCounts.cases : (counts ? counts.cases : null);
  const badgeTodos = useMine ? mineCounts.todos : (counts ? counts.todos : null);
  const loadedCases = window.MStore ? window.MStore.get().cases.length : 0;
  const loadedTodos = window.BV_ROWS ? window.BV_ROWS.length : 0;
  const partialUnit = (total, loaded) => {
    if (useMine) return '件（自分）';
    return total != null && total > loaded ? `件（直近${loaded}件を表示）` : '件';
  };

  let title = TITLES[nav] || navLabel || '案件';
  let count = null, countUnit = '件', crumb = null, newLabel = '＋ 新規受付';
  if (q) { title = '検索'; count = null; }
  else if (nav === 'tasks' && openTodo) { title = openTodo.step; crumb = 'TODO一覧'; }
  else if (nav === 'clients' && openPartner) { title = openPartner.name; crumb = '取引先一覧'; }
  else if (TRACK_NAV[nav] && openTrack) { title = openTrack.kind === 'work' ? openTrack.target + ' ' + openTrack.work : openTrack.kind === 'ins' ? openTrack.vendor : openTrack.target; crumb = TITLES[nav]; }
  else if (TRACK_NAV[nav]) { count = (window.TR_ROWS || []).filter(r => r.kind === TRACK_NAV[nav]).length; }
  else if (nav === 'cases' && current) { title = current.name; crumb = '案件一覧'; }
  else if (nav === 'dashboard') { count = null; }
  else if (nav === 'daily' || nav === 'receipts') { count = null; }
  else if (nav === 'cases') { count = badgeCases; countUnit = partialUnit(badgeCases, loadedCases); }
  else if (nav === 'tasks') { count = badgeTodos; countUnit = partialUnit(badgeTodos, loadedTodos); newLabel = '＋ TODO追加'; }
  else if (nav === 'payment') { title = '支払い・精算'; }
  else if (nav === 'admin') { count = null; }

  let body;
  if (q) body = <SearchResults q={q} clear={() => setQ('')}
    onOpenCase={x => goCaseByDbId(x.dbId, x.prop)}
    onOpenTodo={t => { setQ(''); setNav('tasks'); setOpenTodo(t); }}
    onOpenPartner={p => { setQ(''); setNav('clients'); setOpenPartner(p); }} />;
  else if (nav === 'tasks' && openTodo) body = <TodoDetail key={openTodo.trk + openTodo.step} todo={openTodo} queue={todoQueue}
    onBack={() => setOpenTodo(null)} onOpenCase={t => goCaseByName(t.prop)}
    onContact={t => setContact(t)} onToast={setToast} onNext={t => setOpenTodo(t)}
    onOpenTrack={trk => { const navKey = Object.keys(TRACK_NAV).find(k => TRACK_NAV[k] === trk.kind); if (navKey) { setOpenTodo(null); setNav(navKey); setOpenTrack(trk); } }} />;
  else if (nav === 'clients' && openPartner) body = <PartnerDetail partner={openPartner}
    onBack={() => setOpenPartner(null)} onContact={t => setContact(t)}
    onOpenCase={x => goCaseByDbId(x.dbId, x.prop)} onToast={setToast} />;
  else if (TRACK_NAV[nav] && openTrack) body = <TrackDetail track={openTrack} onBack={() => setOpenTrack(null)}
    onOpenCase={r => goCaseByName(r.prop)} onContact={t => setContact(t)} onToast={setToast} />;
  else if (TRACK_NAV[nav]) body = <TracksList kind={TRACK_NAV[nav]} initialFilter={boardOpt.filter} onOpen={r => setOpenTrack(r)} onOpenCase={r => goCaseByName(r.prop)} />;
  else if (nav === 'intake') body = <Intake onCreate={payload => { const id = window.MStore.addCase(payload); setToast(id + ' を登録しました'); return id; }} onDone={id => goCase(id)} onToast={setToast} />;
  else if (nav === 'dashboard') body = <DashboardV6 go={(view, opt) => { setBoardOpt(opt || {}); setOpenTrack(null); setNav(view); }} openTodo={t => { setNav('tasks'); setOpenTodo(t); }} onContact={t => setContact(t)} onToast={setToast}
    onOpenCase={x => {
      if (x && x.dbId) return goCaseByDbId(x.dbId, x.prop);
      if (x && x.caseId && window.MStore && window.MStore.case(x.caseId)) return goCase(x.caseId);
      return goCaseByName(x && x.prop);
    }} />;
  else if (nav === 'daily') body = <DailyReport onToast={setToast} />;
  else if (nav === 'receipts') body = <UnpaidList title="入金レポート" onToast={setToast} onOpenCase={id => { setPaymentReturn(id); setPaymentFromList(true); setNav('payment'); }} />;
  else if (nav === 'clients') body = <PartnersV6 onToast={setToast} onOpen={p => setOpenPartner(p)} />;
  else if (nav === 'tasks') body = <Board onOpen={c => goCase(c.id)} onOpenTask={t => setOpenTodo(t)} view="tasks" initialMode="task" initialTab={boardOpt.tab} initialFilter={boardOpt.filter} onToast={setToast} />;
  else if (nav === 'payment') body = <Payment caseId={paymentReturn} onOpenCase={id => { setPaymentReturn(id); setPaymentFromList(true); }} onBack={paymentReturn ? () => { if (paymentFromList) { setPaymentReturn(null); setPaymentFromList(false); } else { setNav('cases'); setOpenCase(paymentReturn); setPaymentReturn(null); } } : null} onToast={setToast} />;
  else if (nav === 'admin') body = <AdminScreen onToast={setToast} onOpenNeedsAssignee={() => {
    setBoardOpt({ filter: 'needsAssignee' }); setOpenCase(null); setOpenTodo(null); setOpenPartner(null); setOpenTrack(null); setNav('cases');
  }} />;
  else if (nav === 'cases' && current) body = <CaseDetail key={openCase} caseId={openCase} c={current} initialStage={caseOpt ? caseOpt.stage : null} initialPanel={caseOpt ? caseOpt.panelSel : null} onBack={() => setOpenCase(null)} onDispatch={() => setModal(true)} onPayment={() => { setPaymentReturn(current.id); setPaymentFromList(false); setNav('payment'); }} onToast={setToast} onAddTask={() => setTaskModal(true)} onContact={t => setContact(t)}
    onJump={kind => { const hit = (window.TR_ROWS || []).find(r => r.kind === kind && r.caseId === openCase); setOpenCase(null); setNav(kind); setOpenTrack(hit || null); }} />;
  else if (nav === 'cases') body = <Board onOpen={(c, opt) => goCase(c.id, opt)} view="cases" initialMode="case" initialStage={boardOpt.stage} initialFilter={boardOpt.filter} />;
  else body = <Placeholder title={navLabel || TITLES[nav] || nav} />;

  const fullBleed = nav === 'intake';

  return (
    <div style={{ display: 'flex', height: '100vh', overflow: 'hidden', background: 'var(--background)' }}>
      <div className={"mr-sidebar-overlay" + (sidebarOpen ? " mr-sidebar-overlay--open" : "")} onClick={() => setSidebarOpen(false)} />
      <Sidebar active={sidebarActive} sidebarOpen={sidebarOpen} onSidebarClose={() => setSidebarOpen(false)}
        taskCount={badgeTodos != null ? badgeTodos : loadedTodos}
        caseCount={badgeCases}
        countScope={countScope}
        mineReady={mineReady}
        onCountScope={s => { setCountScope(s); if (typeof window.writeNavCountScope === 'function') window.writeNavCountScope(s); }}
        onNavigate={(id, label) => { setNav(id); setNavLabel(label || ''); setOpenCase(null); setPaymentReturn(null); setPaymentFromList(false); setBoardOpt({}); setOpenTodo(null); setOpenPartner(null); setOpenTrack(null); setQ(''); }} />
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
        <TopNav title={title} count={count} countUnit={countUnit} crumb={crumb} newLabel={newLabel} q={q} onQ={setQ}
          onNew={() => nav === 'tasks' ? setTaskModal(true) : setNav('intake')}
          onHamburger={() => setSidebarOpen(o => !o)} />
        <div style={{ flex: 1, overflowY: fullBleed ? 'hidden' : 'auto', minHeight: 0, minWidth: 0, display: fullBleed ? 'flex' : 'block' }}>
          {body}
        </div>
      </div>
      {/* 案件詳細から開いたときは、その案件を選んだ状態で開く(店の案件は name。property はモックの列名で、
          実データでは空になり、先頭の別の案件が選ばれていた ─ K18-21 の見出しの ＋TODO)。 */}
      {taskModal && <TaskAddModal defaultCase={current ? (current.name || current.property || null) : null} onClose={() => setTaskModal(false)}
        onSave={t => {
          setTaskModal(false);
          const cs = window.MStore ? window.MStore.get().cases : [];
          const hit = cs.find(c => c.name === t.caseName);
          // K18-49: 期限はモーダル(MrDatePicker)が店の表記(M/D、年がずれる日だけ YYYY/M/D)で返す。年を落とさない。
          const md = t.due;
          if (!hit) { setToast('案件「' + t.caseName + '」が見つかりません。一覧から選んでください'); return; }
          const users = (window.MStore.masters && window.MStore.masters().users) || [];
          const uid = t.ownerDbId || (users.find(u => u.name === t.owner || u.displayName === t.owner) || {}).id || null;
          // H6(9/25 本番の不具合): 相手の欄の文字をそのまま手番(ball・8 語だけの列)に入れて送っていたため、
          // 会社名を入れるとサーバに断られ、DB に無い行が画面に残った。手番は種類、名前は督促先(dunning)へ
          // (規則は Screens.jsx scTodoCounterparty。モーダルが決めた値を優先し、無ければここで同じ規則で決める)。
          const cp = t.ball ? { ball: t.ball, dunning: t.dunning || null }
            : (window.scTodoCounterparty ? window.scTodoCounterparty(t.to, hit) : { ball: '自社', dunning: null });
          window.MStore.addTodo(hit.id, { step: t.title, ball: cp.ball, btype: t.btype || 'その他', due: md, dunning: cp.dunning, owner: t.owner || null, assigneeUserId: uid, trkId: null, railStep: null });
          const toNote = cp.dunning && cp.dunning !== cp.ball ? '督促先「' + cp.dunning + '」・手番「' + cp.ball + '」' : '';
          const ownerNote = !uid && t.owner ? '担当は名簿に無いので案件の本担当のままです' : '';
          const notes = [toNote, ownerNote].filter(Boolean).join('／');
          setToast('TODOを追加しました' + (notes ? '（' + notes + '）' : ''));
        }} />}
      {contact && <ContactModal target={contact} onClose={() => setContact(null)} onToast={setToast} />}
      {modal && <DispatchModal caseName={current ? (current.name || current.property || '(案件不明)') : '(案件不明)'} caseId={current ? current.dbId : null} caseKey={current ? current.id : null} onClose={() => setModal(false)}
        onConfirm={(vendor, date) => {
          setModal(false);
          // 案件詳細-25: 選んだ業者・着工日を保存する。dbId(サーバの案件ID)と
          // version(楽観ロックの版)が無いとまだ保存できない ─ その場合は
          // 「保存しました」と嘘をつかず、正直にできなかったと伝える(掟5.5)。
          const iso = toIsoDate(date);
          const ok = current && current.dbId && iso && window.MarurouWrite
            && window.MarurouWrite.logDispatch({
                 caseId: current.dbId, expectedVersion: current.version,
                 vendorOrgId: vendor.id, plannedStartOn: iso,
               });
          setToast(ok
            ? (vendor.name + ' を手配しました（着工 ' + date + '）')
            : '手配を保存できませんでした（着工日の形式か、案件の同期を確かめてください）');
          if (!ok && window.showSaveFail) {
            window.showSaveFail({
              reason: '手配を保存できませんでした（着工日の形式か、案件の同期を確かめてください）',
            });
          }
        }} />}
      <Toast msg={toast} />
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
