// Payment — 支払い・精算。
//
// 左ナビ「精算」は未収金一覧(0022 rpc_list_unpaid)。seed(未完了673)では
// 完了案件の未収が消えるので RPC。請求先は空なら空。入金の記録は行を押して
// 案件ごとの精算画面へ(MStore.settlementRows → settlement.update)。
// 案件詳細の「精算画面へ」は従来どおりその案件の明細編集。

function methodTone(m) { return /保険/.test(m||'') ? 'brand' : /自費/.test(m||'') ? 'w-fixture' : 'neutral'; }
function statusTone(s) {
  if (s === '入金済') return 'success';
  if (s === '入金待ち' || s === '請求準備') return 'warning';
  return 'st-estimate';
}
/* ㉝: 精算の保存失敗も同じ赤帯+「もう一度」。 */
function paymentSaveFail(reason, onRetry) {
  if (typeof window.showSaveFail === 'function') {
    window.showSaveFail({ reason: reason || 'この変更はサーバに残っていません。', onRetry });
  }
}

/* 請求書のリンクは、決めた相手先の https だけを開く。
   データに他所のURLが混ざっても踏まない(請求書は金額に直結するため)。

   2026-09-04 訂正: 当初 MakeLeaps だけを許可していたが、池田さん同席の場で
   **実際に使っているのは freee** と判明した(決-16)。MakeLeaps のままだと
   freee のリンクが全部弾かれて開けない。
   MakeLeaps も残すのは、過去に使っていた分のリンクが残っている可能性があるため
   (実データでは invoice_url は 8,783件すべて空なので、今は両方とも空振り)。 */
const INVOICE_HOSTS = ['freee.co.jp', 'secure.freee.co.jp', 'makeleaps.com', 'makeleaps.jp'];

function invoiceHref(url) {
  const raw = typeof url === 'string' ? url.trim() : '';
  if (!/^https:\/\//i.test(raw)) return null;
  try {
    const u = new URL(raw);
    const host = u.hostname.toLowerCase();
    const ok = INVOICE_HOSTS.some((h) => host === h || host.endsWith('.' + h));
    return u.protocol === 'https:' && ok ? u.href : null;
  } catch (_) { return null; }
}

function paymentDueInput(value, today) {
  const raw = String(value == null ? '' : value).trim();
  if (!raw || raw === '-' || raw === '—') return { valid: true, value: null };
  const m = /^(?:(\d{4})\/)?(\d{1,2})\/(\d{1,2})$/.exec(raw);
  if (!m) return { valid: false, value: null };
  const base = today instanceof Date ? today : new Date();
  let year = m[1] ? Number(m[1]) : base.getFullYear();
  const month = Number(m[2]), day = Number(m[3]);
  if (!m[1] && new Date(year, month - 1, day) - base > 183 * 86400000) year -= 1;
  const date = new Date(year, month - 1, day);
  const valid = date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
  return { valid, value: valid ? raw : null };
}

function paymentInvoices(rows, caseData) {
  const tracks = [...((caseData && caseData.visits) || []), ...((caseData && caseData.works) || [])];
  const details = new Map(tracks.map(t => [t.id, t.detail || {}]));
  const grouped = new Map();
  (rows || []).forEach(r => {
    const det = details.get(r.id) || {};
    const invoiceNo = det.invoiceNo || null, invoiceUrl = det.invoiceUrl || null;
    if (!invoiceNo && !invoiceUrl) return;
    const key = invoiceNo ? 'no:' + invoiceNo : 'url:' + invoiceUrl;
    const x = grouped.get(key) || { invoiceNo, invoiceUrls: new Set(),
      payers: new Set(), targets: new Set(), billed: 0 };
    if (invoiceUrl) x.invoiceUrls.add(invoiceUrl);
    if (r.payer && r.payer !== '—') x.payers.add(r.payer);
    if (r.target) x.targets.add(r.target);
    x.billed += r.billed || 0;
    grouped.set(key, x);
  });
  return [...grouped.values()].map(x => {
    const urls = [...x.invoiceUrls];
    return { invoiceNo: x.invoiceNo, invoiceUrl: urls.length === 1 ? urls[0] : null,
      invoiceUrls: urls, urlConflict: urls.length > 1, payers: [...x.payers],
      targets: [...x.targets], billed: x.billed };
  });
}

/* settlementRows() は表示用の payer 名までしか返さない。書き込み層が必要とする
   payerDbId は元トラックの detail から補う（共有 store はページング作業中なので触らない）。 */
function paymentRowsWithPayers(rows, caseData) {
  const tracks = [...((caseData && caseData.visits) || []), ...((caseData && caseData.works) || [])];
  const details = new Map(tracks.map(t => [t.id, t.detail || {}]));
  return (rows || []).map(r => ({ ...r, payerDbId: (details.get(r.id) || {}).payerDbId || null }));
}

function payPartnerFetch(query, limit) {
  const cloud = typeof window !== 'undefined' ? window.MarurouCloud : null;
  if (!cloud || typeof cloud.listPartners !== 'function') {
    return Promise.resolve({ rows: [], offline: true });
  }
  return cloud.listPartners({ query: query || null, limit: limit || 30 });
}

function PaymentPayerEditor({ payer, payerDbId, onSave, onError }) {
  const shown = payer && payer !== '—' ? payer : '';
  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 () => {
      try {
        const result = await payPartnerFetch(query, 30);
        if (!live) return;
        setRows((result && result.rows) || []);
        if (result && result.offline) setError('取引先マスタ未取得');
      } catch (e) {
        if (!live) return;
        const message = (e && e.message) || '取引先を読めませんでした';
        setRows([]);
        setError(message);
        onError && onError(message);
      }
    }, query ? 250 : 0);
    return () => { live = false; clearTimeout(timer); };
  }, [editing, query]);

  if (!editing) return <button type="button" onClick={() => { setQuery(''); setEditing(true); }}
    title="クリックで請求先を変更" data-payer-db-id={payerDbId || ''}
    style={{ all:'unset', cursor:'pointer', color:shown?'var(--foreground)':'var(--foreground-subtle)',
      borderBottom:'1px dashed var(--border-strong)', maxWidth:190, overflow:'hidden', textOverflow:'ellipsis' }}>
    {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 === payerDbId}
        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 === payerDbId?700:500,
          color:'var(--foreground)', background:org.id === payerDbId?'var(--brand-muted)':'transparent' }}>
        {org.name}
      </button>)}
      {shown && <button type="button" onClick={() => { onSave && onSave({ id:null, name:null }); setEditing(false); }}
        style={{ all:'unset', boxSizing:'border-box', display:'block', width:'100%', cursor:'pointer', padding:'6px 8px',
          color:'var(--destructive)', borderTop:'1px solid var(--border)' }}>請求先を解除</button>}
    </div>
  </div>;
}

/* 精算明細の日付の欄(入金期限・入金日)。読めない日付・実在しない日付は保存せず、欄の下に理由を出す。
   H4(9/25): 入金日は検査の無い EdText で、abc・13/1 と打つと書き込み層が「消す」として送り、
   DB の入金日が黙って消えていた(2/30 はデータベースの英語のエラーが赤帯に出た)。
   入金期限と同じ検査(paymentDueInput)を、同じ部品で入金日にも掛ける。空・「—」は「消す」。 */
function PaymentDateEditor({ label, value, onSave, onError }) {
  const shown = value || '';
  const [editing, setEditing] = React.useState(false);
  const [draft, setDraft] = React.useState(shown);
  const [error, setError] = React.useState('');
  const committed = React.useRef(false);
  React.useEffect(() => { setDraft(shown); setError(''); }, [shown]);
  const commit = () => {
    if (committed.current) return;
    const checked = paymentDueInput(draft);
    if (!checked.valid) {
      const message = label + 'は M/D または YYYY/M/D の実在する日付で入力してください';
      setError(message);
      onError && onError(message);
      return;
    }
    committed.current = true;
    setError('');
    setEditing(false);
    if (checked.value !== (shown || null)) onSave && onSave(checked.value);
  };
  if (editing) return <span style={{ display:'inline-flex', flexDirection:'column', gap:3 }}>
    <input value={draft} autoFocus aria-label={label} aria-invalid={error ? 'true' : 'false'}
      onChange={e => { setDraft(e.target.value); setError(''); }} onBlur={commit}
      onKeyDown={e => { if(e.key==='Enter'){ e.preventDefault(); commit(); }
        if(e.key==='Escape'){ committed.current = true; setDraft(shown); setError(''); setEditing(false); } }}
      style={{ boxSizing:'border-box', width:96, minWidth:90, padding:'2px 6px', borderRadius:'var(--radius-sm)',
        border:'1px solid '+(error?'var(--destructive)':'var(--brand)'), boxShadow:'var(--ring-focus)', outline:'none',
        background:'var(--card)', fontFamily:'var(--font-mono)', fontSize:'var(--text-body-sm)', color:'var(--foreground)' }} />
    {error && <span role="alert" style={{ width:180, whiteSpace:'normal', fontSize:'var(--text-caption)', color:'var(--destructive)' }}>{error}</span>}
  </span>;
  return <span onClick={() => { committed.current = false; setEditing(true); }} title="クリックで編集" className="mono"
    style={{ cursor:'text', color:shown?undefined:'var(--foreground-subtle)', borderBottom:'1px dashed transparent',
      display:'inline-flex', alignItems:'baseline', gap:4 }}>
    {shown || '—'}<span style={{ fontSize:'var(--text-caption)', color:'var(--foreground-subtle)' }}>✎</span>
  </span>;
}
function PaymentDueEditor(props) { return <PaymentDateEditor label="入金期限" {...props} />; }

/* ── 精算-2: 保険金の充当(どのトラックにいくら) ───────────────────────
   DB(0039・main合流済み): insurance_allocations。書き込みは
   window.MStore.updateCase → mstore-diff.js が ins.allocations=[{lineId,amount,paidAt?}]
   を insurance.allocate に翻訳する(既存の経路。ここでは新しい経路を作らない)。
   lineId は track_settlement_lines の dbId。
   K-11(0085): allocations[] から行を **落とす** と insurance.deallocate へ翻訳される
   ようになった(物理DELETE。取り消した金額・入金日は activity_log に残る ─
   0085【設計判断1】)。下の AllocationRow の「充当を取り消す」がその導線。

   **0045(main合流済み・統合担当)で塞がれた穴、2つ**:
   1) rpc_seed / server.py が、調査・工事・保険どのトラックにも top-level で
      lineDbId(track_settlement_lines.id。無ければ null)を返すようになった。
      前回のセッションはこれが無かったため「一度も充当されたことが無いトラックへの
      最初の1件はこの画面から作れない」という制約を書いていたが、今はもう嘘。
      候補は c.visits[]/c.works[] の lineDbId から直接作る(下の allocatableTracks)。
      knownLines(既存の充当からの trackId→lineId 逆引き)はもう要らないので削除した。
   2) rpc_seed / server.py は保険トラックにも claimDbId(insurance_claims.id。
      tracks.id である dbId とは別物)を top-level で返すようになり、
      mstore-diff.js の insurance.allocate 翻訳が insuranceClaimId に ins.dbId ではなく
      ins.claimDbId を使うよう直った(前回のセッションが実データで見つけた404の原因)。

   **このセッションで見つけて直した、対になるもう1つの穴(読み取り側)**:
   applyInsuranceAllocations()(下)が rpc_list_insurance_allocations の
   insuranceClaimId(insurance_claims.id)を c.ins[] の中から探すのに、
   書き込み側と同じ間違い(t.dbId=tracks.id で突き合わせる)をしていた。
   tracks.id と insurance_claims.id は別物なので、実データでは常に不一致 →
   DBに充当が実在しても画面には「まだ充当はありません」としか出ない、という
   もう1つの404とは別の(こちらは黙って空に見える)穴だった。t.claimDbId で
   突き合わせるよう直した。tests/e2e/smoke.spec.js:443 はこれまで
   `fixInsClaimDbId()` で t.dbId を claimId に上書きして書き込み側の穴だけを
   ふさいで検証していたが、その上書きは読み取り側の突き合わせも(結果的に)
   一緒に直してしまっていたため、この読み取りの穴はテストに現れていなかった。 */

/** RPCの paidAt("YYYY-MM-DDTHH:MI:SS"・Asia/Tokyo・または null)を画面の日付表記へ。
    年をつけて返す ─ 入金日は半年以上前のこともあるため、paymentDueInput() の
    「年無しは半年先なら去年」という当て推量に頼らせない。 */
function isoToUiDate(iso) {
  if (iso == null || iso === '') return null;
  const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(iso).trim());
  if (!m) return null;
  return Number(m[1]) + '/' + Number(m[2]) + '/' + Number(m[3]);
}

/** rpc_list_insurance_allocations の応答を c.ins[].allocations へ書き戻す。
    **これは利用者の編集ではない**(サーバの読み直し)。store.jsx の mergeCaseSteps と
    同じ理由で __MARUROU_APPLYING_REMOTE__ を立てる ─ 立てずに書き戻すと、
    mstore-adapter.js が包む MStore.update がこの書き戻し自体を差分として拾ってしまう。
    K-11(0085)以降はそれが警告では済まない ─ 読み直しで行が入れ替わっただけなのに
    insurance.allocate / insurance.deallocate を**実際に送る**ことになる。 */
function applyInsuranceAllocations(caseId, rows) {
  if (!window.MStore) return;
  const byClaim = new Map();
  (rows || []).forEach((r) => {
    if (!r || !r.insuranceClaimId || !r.settlementLineId) return;
    const list = byClaim.get(r.insuranceClaimId) || [];
    list.push({
      lineId: r.settlementLineId,
      amount: r.allocatedAmount == null ? null : Number(r.allocatedAmount),
      paidAt: isoToUiDate(r.paidAt),
      trackId: r.trackId || null,
      trackNo: r.trackNo == null ? null : Number(r.trackNo),
      trackName: r.trackName || null,
      workCategory: r.workCategory || null,
    });
    byClaim.set(r.insuranceClaimId, list);
  });
  const prev = window.__MARUROU_APPLYING_REMOTE__;
  window.__MARUROU_APPLYING_REMOTE__ = true;
  try {
    window.MStore.updateCase(caseId, (c) => {
      const list = window.msIns ? window.msIns(c) : [];
      // t.dbId は tracks.id(保険トラック自身のid)で、insuranceClaimId(insurance_claims.id)
      // とは別物 ─ ここを t.dbId で突き合わせていたのが、このセッションで見つけた
      // 読み取り側の穴(冒頭コメント参照)。claimDbId(0045)で突き合わせる。
      list.forEach((t) => { if (t && t.claimDbId) t.allocations = byClaim.get(t.claimDbId) || []; });
    });
  } finally { window.__MARUROU_APPLYING_REMOTE__ = prev; }
}

/** 充当1行(保険×精算明細)を追加・上書きする。 */
function saveInsuranceAllocation(caseId, claimUiId, lineId, patch) {
  window.MStore.updateCase(caseId, (c) => {
    const list = window.msIns ? window.msIns(c) : [];
    const t = list.find((x) => x.id === claimUiId);
    if (!t) return;
    const allocations = Array.isArray(t.allocations) ? t.allocations.map((a) => ({ ...a })) : [];
    const idx = allocations.findIndex((a) => a.lineId === lineId);
    if (idx >= 0) allocations[idx] = { ...allocations[idx], ...patch };
    else allocations.push({ lineId, amount: null, paidAt: null, ...patch });
    t.allocations = allocations;
  });
}

/** 充当1行を取り消す(K-11・0085)。ins.allocations[] から行を落とすと
    mstore-diff.js が insurance.deallocate(物理DELETE)へ翻訳する ─ 取り消した
    金額・入金日は activity_log に残る(0085【設計判断1】)。 */
function removeInsuranceAllocation(caseId, claimUiId, lineId) {
  window.MStore.updateCase(caseId, (c) => {
    const list = window.msIns ? window.msIns(c) : [];
    const t = list.find((x) => x.id === claimUiId);
    if (!t || !Array.isArray(t.allocations)) return;
    t.allocations = t.allocations.filter((a) => a.lineId !== lineId);
  });
}

function allocInputStyle(extra) {
  return { padding: '3px 6px', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
    background: 'var(--card)', color: 'var(--foreground)', ...extra };
}

/** 既にある充当1行の金額・入金日を編集する。ボタンを押すまでは保存されていない
   （他の欄のような blur 自動保存にはしない ─ 金額と入金日を1回の保存にまとめるため）。 */
function AllocationRow({ caseId, claimId, alloc, onToast }) {
  const shownAmount = alloc.amount == null ? '' : String(alloc.amount);
  const shownPaidAt = alloc.paidAt || '';
  const [amount, setAmount] = React.useState(shownAmount);
  const [paidAt, setPaidAt] = React.useState(shownPaidAt);
  const [err, setErr] = React.useState('');
  React.useEffect(() => { setAmount(shownAmount); setPaidAt(shownPaidAt); setErr(''); }, [shownAmount, shownPaidAt]);
  const dirty = amount !== shownAmount || paidAt !== shownPaidAt;

  const save = () => {
    // H3: 数字以外を消してから読まない(-1000 → 1000・1.5 → 15 になっていた)。金額の欄と同じ規則。
    const parsed = window.edParseMoney(amount, '充当額');
    if (!parsed.ok) { setErr(parsed.error); return; }
    const n = parsed.value;
    if (!(n > 0)) { setErr('充当額は1円以上で入力してください'); return; }
    const checked = paymentDueInput(paidAt);
    if (!checked.valid) { setErr('入金日は M/D または YYYY/M/D の実在する日付で入力してください'); return; }
    setErr('');
    try {
      saveInsuranceAllocation(caseId, claimId, alloc.lineId, { amount: n, paidAt: checked.value });
      onToast && onToast('保存しました');
    } catch (e) {
      paymentSaveFail((e && e.message) || '充当を保存できませんでした', save);
      onToast && onToast((e && e.message) || '充当を保存できませんでした');
    }
  };

  const label = (alloc.trackName || alloc.workCategory || 'トラック不明')
    + (alloc.trackNo != null ? '（No.' + alloc.trackNo + '）' : '');

  const remove = () => {
    setErr('');
    try {
      removeInsuranceAllocation(caseId, claimId, alloc.lineId);
      onToast && onToast('充当を取り消しました');
    } catch (e) {
      paymentSaveFail((e && e.message) || '充当の取消に失敗しました', remove);
      onToast && onToast((e && e.message) || '充当の取消に失敗しました');
    }
  };

  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', padding: '6px 0', borderBottom: '1px solid var(--border)' }}>
      <span style={{ flex: 1, minWidth: 120, fontWeight: 500 }}>{label}</span>
      <input value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="充当額" aria-label="充当額"
        className="mono" style={allocInputStyle({ width: 100, textAlign: 'right' })} />
      <input value={paidAt} onChange={(e) => setPaidAt(e.target.value)} placeholder="入金日 任意" aria-label="入金日"
        className="mono" style={allocInputStyle({ width: 100 })} />
      <button type="button" onClick={save} disabled={!dirty}
        style={{ all: 'unset', cursor: dirty ? 'pointer' : 'default', color: dirty ? 'var(--brand)' : 'var(--foreground-subtle)',
          fontWeight: 700, fontSize: 'var(--text-body-sm)', padding: '3px 8px',
          border: '1px solid ' + (dirty ? 'var(--brand)' : 'var(--border)'), borderRadius: 'var(--radius-sm)' }}>保存</button>
      {/* 精算-2(K-11・0085): 充当の取り消し。押した瞬間に保存へ回る(取り消しは
          「途中の入力」が無いので、金額欄のような下書き状態を作らない)。 */}
      <button type="button" onClick={remove} aria-label={'充当を取り消す ' + label}
        style={{ all: 'unset', cursor: 'pointer', color: 'var(--destructive)',
          fontWeight: 700, fontSize: 'var(--text-body-sm)', padding: '3px 8px',
          border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)' }}>充当を取り消す</button>
      <span style={{ fontSize: 'var(--text-caption)', color: dirty ? 'var(--warning)' : 'var(--success)' }}>
        {dirty ? '保存されていません' : '保存しました'}
      </span>
      {err && <span role="alert" style={{ width: '100%', fontSize: 'var(--text-caption)', color: 'var(--destructive)' }}>{err}</span>}
    </div>
  );
}

/** 新しい充当を1件足す。候補は案件の全トラック(調査・工事)のうち精算明細
   (lineDbId)を持つもの全部から、この保険で既に充当済みの分を除いたもの
   (InsuranceAllocationsBlock が渡す)。lineDbId が無いトラック(精算明細その
   ものが無い)は候補に出さない ─ 黙って隠さず、noLineCount で件数を出す
   (05 §5.5)。候補が0件(=充当できるトラックを全部使い切った、または
   この案件にlineDbId付きのトラックが無い)のときだけ、その理由を出す。 */
function NewAllocationForm({ caseId, claim, candidates, noLineCount, onToast }) {
  const [lineId, setLineId] = React.useState((candidates[0] && candidates[0].lineId) || '');
  const [amount, setAmount] = React.useState('');
  const [paidAt, setPaidAt] = React.useState('');
  const [err, setErr] = React.useState('');
  React.useEffect(() => {
    if (!candidates.some((c) => c.lineId === lineId)) setLineId((candidates[0] && candidates[0].lineId) || '');
  }, [candidates]);

  const noLineNote = noLineCount > 0 && (
    <div style={{ fontSize: 'var(--text-caption)', color: 'var(--foreground-subtle)', marginTop: 6, lineHeight: 1.6 }}>
      精算明細が無いトラックには充当できません({noLineCount}件は候補から外れています)。
    </div>
  );

  if (!candidates.length) {
    return <>
      <div style={{ fontSize: 'var(--text-caption)', color: 'var(--foreground-subtle)', marginTop: 6, lineHeight: 1.6 }}>
        この保険に新しく充当できるトラックがありません(充当できるトラックは既に登録済みです)。
      </div>
      {noLineNote}
    </>;
  }

  const save = () => {
    if (!lineId) { setErr('充当先のトラックを選んでください'); return; }
    // H3: 既存の行(AllocationRow)と同じ規則で読む。
    const parsed = window.edParseMoney(amount, '充当額');
    if (!parsed.ok) { setErr(parsed.error); return; }
    const n = parsed.value;
    if (!(n > 0)) { setErr('充当額は1円以上で入力してください'); return; }
    const checked = paymentDueInput(paidAt);
    if (!checked.valid) { setErr('入金日は M/D または YYYY/M/D の実在する日付で入力してください'); return; }
    setErr('');
    try {
      saveInsuranceAllocation(caseId, claim.id, lineId, { amount: n, paidAt: checked.value });
      setAmount(''); setPaidAt('');
      onToast && onToast('保存しました');
    } catch (e) { onToast && onToast(e.message); }
  };

  return (
    <>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', paddingTop: 8, marginTop: 4 }}>
        <select value={lineId} onChange={(e) => setLineId(e.target.value)} aria-label="充当先トラック" style={allocInputStyle({})}>
          {candidates.map((c) => <option key={c.lineId} value={c.lineId}>
            {(c.trackName || c.workCategory || 'トラック') + (c.trackNo != null ? '（No.' + c.trackNo + '）' : '')}
          </option>)}
        </select>
        <input value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="充当額" aria-label="充当額"
          className="mono" style={allocInputStyle({ width: 100, textAlign: 'right' })} />
        <input value={paidAt} onChange={(e) => setPaidAt(e.target.value)} placeholder="入金日 任意" aria-label="入金日"
          className="mono" style={allocInputStyle({ width: 100 })} />
        <button type="button" onClick={save}
          style={{ all: 'unset', cursor: 'pointer', color: 'var(--brand)', fontWeight: 700, fontSize: 'var(--text-body-sm)',
            padding: '3px 8px', border: '1px solid var(--brand)', borderRadius: 'var(--radius-sm)' }}>充当を追加</button>
        <span style={{ fontSize: 'var(--text-caption)', color: 'var(--warning)' }}>保存されていません</span>
        {err && <span role="alert" style={{ width: '100%', fontSize: 'var(--text-caption)', color: 'var(--destructive)' }}>{err}</span>}
      </div>
      {noLineNote}
    </>
  );
}

/** 保険1件ぶんの充当ブロック(合計・行一覧・追加フォーム)。
   allocatableTracks は案件の全トラックのうち lineDbId を持つもの(精算-2仕上げ)。
   noLineCount は lineDbId の無いトラックの件数(候補に出せない理由を出すため)。 */
function InsuranceAllocationsBlock({ caseId, claim, allocatableTracks, noLineCount, onToast }) {
  const allocations = Array.isArray(claim.allocations) ? claim.allocations : [];
  const sum = allocations.reduce((s, a) => s + (Number(a.amount) || 0), 0);
  const approved = claim.detail && claim.detail.approved != null ? claim.detail.approved : null;
  const applied = claim.detail && claim.detail.billed != null ? claim.detail.billed : null;
  const cap = approved != null ? approved : applied;
  const over = cap != null && sum > cap;
  const candidates = allocatableTracks.filter((l) => !allocations.some((a) => a.lineId === l.lineId));

  return (
    <div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--border)' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4, flexWrap: 'wrap' }}>
        <span style={{ fontSize: 'var(--text-caption)', fontWeight: 700, color: 'var(--muted-foreground)' }}>充当(どのトラックにいくら)</span>
        <span className="mono" style={{ marginLeft: 'auto', fontWeight: 700 }}>{yen(sum)}</span>
        {over && <AfChip tone="warning">充当合計が{approved != null ? '認定額' : '申請額'}を超えています</AfChip>}
      </div>
      {allocations.length
        ? allocations.map((a) => <AllocationRow key={a.lineId} caseId={caseId} claimId={claim.id} alloc={a} onToast={onToast} />)
        : <div style={{ fontSize: 'var(--text-body-sm)', color: 'var(--foreground-subtle)' }}>まだ充当はありません。</div>}
      <NewAllocationForm caseId={caseId} claim={claim} candidates={candidates} noLineCount={noLineCount} onToast={onToast} />
    </div>
  );
}

function PKpi({ label, value, sub }) {
  return <AfCard pad={14} style={{ flex: 1, minWidth: 0 }}>
    <div style={{ fontSize: 'var(--text-caption)', fontWeight: 600, letterSpacing: '0.08em', color: 'var(--muted-foreground)', textTransform: 'uppercase' }}>{label}</div>
    <div className="mono" style={{ fontSize: 'var(--text-display-md)', fontWeight: 700, marginTop: 4, letterSpacing: 'var(--tracking-tight)' }}>{value}</div>
    {sub && <div style={{ fontSize: 'var(--text-caption)', color: 'var(--muted-foreground)', marginTop: 4 }}>{sub}</div>}
  </AfCard>;
}

/* 精算・請求の判断フロー（7-6）。判定の順序は決まった手順なので固定、
   件数はいま見えている明細から数える（架空の案件名・金額を出さない）。 */
function SettleFlow({ rows }) {
  const n = re => rows.filter(r => re.test(r.method || '')).length;
  const nodes = [
    { q: '費用が確定しているか', a: rows.filter(r => r.billed != null).length + ' / ' + rows.length + ' 明細', tone: 'neutral' },
    { q: '保険の対象か',         a: '保険精算 ' + n(/保険/) + ' 件', tone: 'brand' },
    { q: '自費で請求するか',     a: '自費精算 ' + n(/自費/) + ' 件', tone: 'w-fixture' },
    { q: '請求先が決まっているか', a: '未設定 ' + rows.filter(r => !r.payer || r.payer === '—').length + ' 件', tone: 'neutral' },
  ];
  return (
    <AfCard>
      <SectionTitle sub="費用が確定したら上から順に判定（7-6）。件数はいま表示している明細から数えたもの">精算・請求の判断フロー</SectionTitle>
      <div style={{ display: 'flex', alignItems: 'stretch', gap: 0 }}>
        {nodes.map((x, i) => (
          <React.Fragment key={i}>
            <div style={{ flex: 1, minWidth: 0, padding: '10px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--border)', background: 'var(--accent)', display: 'flex', flexDirection: 'column', gap: 6 }}>
              <span style={{ fontSize: 'var(--text-caption)', fontWeight: 700, color: 'var(--muted-foreground)', letterSpacing: '0.04em' }}>{x.q}？</span>
              <AfChip tone={x.tone}>{x.a}</AfChip>
            </div>
            {i < nodes.length - 1 && <div style={{ alignSelf: 'center', color: 'var(--border-strong)', fontSize: 16, padding: '0 4px' }}>›</div>}
          </React.Fragment>
        ))}
      </div>
    </AfCard>
  );
}

const PAY_TABS = [
  { id: 'open',  label: '要対応',     hit: r => !r.paid && r.billed != null },
  { id: 'draft', label: '金額未確定', hit: r => r.billed == null },
  { id: 'paid',  label: '入金済',     hit: r => r.paid },
  { id: 'all',   label: 'すべて',     hit: () => true },
];
const PAY_LIMIT = 300;
const UNPAID_TABS = [
  { id: 'すべて',   count: t => t.count },
  { id: '期限超過', count: t => t.overdue },
  { id: '期限なし', count: t => t.noDue },
];

function payUnpaidFetch(query, tab, limit) {
  const cloud = typeof window !== 'undefined' ? window.MarurouCloud : null;
  if (!cloud || typeof cloud.listUnpaid !== 'function') {
    return Promise.resolve({ rows: [], totals: { count: 0, billed: 0, remaining: 0, overdue: 0, noDue: 0, closed: 0 }, truncated: false, offline: true });
  }
  return cloud.listUnpaid({ query: query || null, tab: tab === 'すべて' ? null : tab, limit: limit || 300 });
}

function findSeedCase(caseId) {
  const cases = window.MStore ? window.MStore.get().cases : [];
  return cases.find(c => c.dbId === caseId || c.id === caseId) || null;
}

/* 左ナビ「精算」= 未収金一覧。seed(未完了673)には完了案件の未収が載らないので RPC。
   入金の記録は行を押して案件の精算画面へ(動線は既にある)。請求先は空なら空。 */
function UnpaidList({ onToast, onOpenCase, title }) {
  const [tab, setTab] = React.useState('すべて');
  const [q, setQ] = React.useState('');
  const [rows, setRows] = React.useState(null);
  const [totals, setTotals] = React.useState({ count: 0, billed: 0, remaining: 0, overdue: 0, noDue: 0, closed: 0 });
  const [truncated, setTruncated] = React.useState(false);
  const [err, setErr] = React.useState('');

  const load = React.useCallback(async () => {
    try {
      const r = await payUnpaidFetch(q, tab, 300);
      setRows((r && r.rows) || []);
      setTotals(r && r.totals ? r.totals : { count: 0, billed: 0, remaining: 0, overdue: 0, noDue: 0, closed: 0 });
      setTruncated(!!(r && r.truncated));
      setErr(r && r.offline ? '未収金を読み込めていません（接続の設定がありません）。' : '');
    } catch (e) {
      setRows([]);
      setErr((e && e.message) || '未収金を読めませんでした');
    }
  }, [q, tab]);
  React.useEffect(() => { const t = setTimeout(load, q ? 250 : 0); return () => clearTimeout(t); }, [load]);

  const openRow = (r) => {
    const cs = findSeedCase(r.caseId);
    if (cs && onOpenCase) { onOpenCase(cs.id); return; }
    onToast && onToast(r.closed
      ? '完了案件は盤面に無いので、ここでは金額の確認だけです。入金は未完了案件の精算画面から記録します。'
      : 'この案件はいま盤面に載っていないので、入金の記録は開けません。');
  };

  const th = { padding: '0 12px', height: 32, fontWeight: 600, fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)', textAlign: 'left', whiteSpace: 'nowrap' };
  const td = { padding: '0 12px', whiteSpace: 'nowrap' };
  const today = new Date().toISOString().slice(0, 10);
  const heading = title || '未収金';

  return (
    <div className="mr-page-pad" data-unpaid-list style={{ padding: '18px 24px 32px 20px', maxWidth: 'var(--page-max-width)' }}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 4 }}>
        <h1 style={{ margin: 0, fontSize: 'var(--text-display-md)', fontWeight: 700, letterSpacing: 'var(--tracking-tight)' }}>{heading}</h1>
        <span style={{ fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)' }}>請求額があり、まだ入金日が付いていない明細</span>
      </div>
      <div style={{ fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)', marginBottom: 16 }}>
        請求先は精算明細の宛先です。空なら空と出します（依頼元で埋めません）。入金の記録は行を押して案件の精算画面へ。
      </div>

      <div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
        <PKpi label="未収 残額" value={yen(totals.remaining)} sub={totals.count + ' 件（請求額 ' + yen(totals.billed) + '）'} />
        <PKpi label="期限超過" value={String(totals.overdue)} sub="入金予定日を過ぎている" />
        <PKpi label="期限なし" value={String(totals.noDue)} sub="入金予定日が空" />
        <PKpi label="完了案件" value={String(totals.closed)} sub="盤面に無いので確認のみ" />
      </div>

      <AfCard pad={0} style={{ overflow: 'hidden' }}>
        <div style={{ padding: '11px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
          <h3 style={{ margin: 0, fontSize: 'var(--text-display-sm)', fontWeight: 600, marginRight: 4 }}>未収の明細</h3>
          <div style={{ flex: 1 }} />
          {UNPAID_TABS.map(t => {
            const on = t.id === tab, n = t.count(totals);
            return <button key={t.id} onClick={() => setTab(t.id)} style={{
              all: 'unset', cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-body-sm)',
              fontWeight: on ? 700 : 500, padding: '3px 11px', borderRadius: 'var(--radius-full)',
              border: '1px solid ' + (on ? 'var(--brand)' : 'var(--border)'),
              background: on ? 'var(--brand-muted)' : 'transparent',
              color: on ? 'var(--brand)' : 'var(--muted-foreground)' }}>{t.id} {n}</button>;
          })}
          <AfInput value={q} onChange={e => setQ(e.target.value)} placeholder="案件名・受付番号で絞込..." style={{ width: 220 }} />
        </div>
        {err && <div style={{ padding: 18, fontSize: 'var(--text-body-md)', color: 'var(--destructive)' }}>{err}</div>}
        {rows === null && !err && <div style={{ padding: 18, fontSize: 'var(--text-body-md)', color: 'var(--foreground-subtle)' }}>読み込み中…</div>}
        {rows && (
          <div style={{ overflowX: 'auto' }}>
            <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--text-body-md)' }}>
              <thead><tr style={{ borderBottom: '1px solid var(--border)' }}>
                <th style={th}>案件</th><th style={th}>対象</th><th style={th}>精算方式</th>
                <th style={th}>請求先</th>
                <th style={{ ...th, textAlign: 'right' }}>請求額</th>
                <th style={{ ...th, textAlign: 'right' }}>残額</th>
                <th style={th}>入金予定日</th><th style={th}>担当</th>
              </tr></thead>
              <tbody>
                {rows.map(r => {
                  const over = r.dueOn && r.dueOn < today;
                  return (
                    <tr key={r.id} onClick={() => openRow(r)}
                      style={{ borderBottom: '1px solid var(--border)', height: 38, cursor: 'pointer' }}>
                      <td style={{ ...td, maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis' }}>
                        <span className="code" style={{ fontSize: 'var(--text-caption)', color: 'var(--brand)' }}>{r.caseNo}</span>
                        {r.closed && <span style={{ marginLeft: 6, fontSize: 'var(--text-caption)', color: 'var(--muted-foreground)' }}>完了</span>}
                      </td>
                      <td style={{ ...td, fontWeight: 500, maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.target}</td>
                      <td style={td}><AfChip tone={methodTone(r.method)}>{r.method}</AfChip></td>
                      <td style={{ ...td, color: 'var(--muted-foreground)', maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.billTo || '—'}</td>
                      <td className="mono" style={{ ...td, textAlign: 'right' }}>{yen(r.billed)}</td>
                      <td className="mono" style={{ ...td, textAlign: 'right', fontWeight: 600 }}>{yen(r.remaining)}</td>
                      <td style={{ ...td, color: over ? 'var(--destructive)' : 'var(--muted-foreground)' }}>{r.dueOn || '—'}</td>
                      <td style={{ ...td, color: 'var(--muted-foreground)' }}>{r.lead || '—'}</td>
                    </tr>
                  );
                })}
                {!rows.length && !err && <tr><td colSpan={8} style={{ padding: 20, textAlign: 'center', color: 'var(--muted-foreground)' }}>該当する未収はありません</td></tr>}
              </tbody>
            </table>
          </div>
        )}
        {truncated && (
          <div style={{ padding: '9px 14px', borderTop: '1px solid var(--border)', fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)' }}>
            {totals.count} 件のうち先頭 300 件です。名前で絞ると残りも出ます。
          </div>
        )}
      </AfCard>
    </div>
  );
}

function Payment({ onBack, onToast, caseId, onOpenCase }) {
  if (!caseId) return <UnpaidList onToast={onToast} onOpenCase={onOpenCase} />;


  const store = window.useMStore ? window.useMStore() : null;
  const [tab, setTab] = React.useState('open');
  const caseData = window.MStore ? window.MStore.case(caseId) : null;

  // 精算-2: 案件を開いたら保険金の充当(insurance_allocations)を読み直す。
  // 【ページング1段目】(CaseDetailV25.jsx の rpc_case_steps)と同じ構え ─
  // 失敗しても実データの表示は止めない(案件は既に開けている状態の追加取得)。
  const dbId = caseData && caseData.dbId;
  // 0075(盤面データの第2案)以降、突き合わせに使う t.claimDbId は rpc_seed ではなく
  // 案件詳細の個別取得(rpc_case_detail_context の claims)で後から届く。充当の一覧が
  // 先に届くと applyInsuranceAllocations が claimDbId の無いトラックを素通りして
  // 「充当なし」に見える(9/7 統合担当・E2E 精算画面「保険金の充当額を変更すると…」で再現)。
  // 取った行を控えておき、個別取得が ready になった時点でもう一度当て直す(再取得はしない)。
  const lastAllocRows = React.useRef(null);
  const ctxStatus = (window.MStore && window.MStore.detailContextStatus)
    ? window.MStore.detailContextStatus(caseId).status : 'ready';
  React.useEffect(() => {
    if (!dbId || !window.MarurouCloud || !window.MarurouCloud.listInsuranceAllocations) return;
    let cancelled = false;
    lastAllocRows.current = null;
    window.MarurouCloud.listInsuranceAllocations(dbId).then(rows => {
      if (cancelled) return;
      lastAllocRows.current = rows || [];
      applyInsuranceAllocations(caseId, rows || []);
    }).catch(err => {
      console.warn('[精算] 保険金の充当を取得できませんでした。この画面での充当の入力・変更は保存できない場合があります。', err);
    });
    return () => { cancelled = true; };
  }, [caseId, dbId]);
  React.useEffect(() => {
    if (ctxStatus !== 'ready' || !lastAllocRows.current) return;
    applyInsuranceAllocations(caseId, lastAllocRows.current);
  }, [caseId, ctxStatus]);

  const rows = React.useMemo(() => paymentRowsWithPayers(
    window.MStore ? window.MStore.settlementRows(caseId || null) : [], caseData), [caseId, store]);

  const scoped = rows.filter((PAY_TABS.find(t => t.id === tab) || PAY_TABS[3]).hit);
  const shown = scoped.slice(0, PAY_LIMIT);

  const sum = (list, f) => list.reduce((s, r) => s + (f(r) || 0), 0);
  const total = sum(rows, r => r.billed);
  const ins = sum(rows.filter(r => /保険/.test(r.method || '')), r => r.billed);
  const self = sum(rows.filter(r => /自費/.test(r.method || '')), r => r.billed);
  const paid = sum(rows, r => r.paidAmount);
  const undecided = rows.filter(r => r.billed == null).length;
  const invoices = React.useMemo(() => paymentInvoices(rows, caseData), [rows, caseId, store]);

  const setBilled = (r, v) => {
    try { window.MStore.setBilled(r.caseId, r.id, v); onToast && onToast('請求額を保存しました'); }
    catch (e) { onToast && onToast(e.message); }
  };
  /* 触った欄だけを渡す(9/25)。読み込み(rpc_seed)は入金額・入金日を返さない(detail.paid の印だけ)ので、
     読み込み直後の r.paidAmount / r.paidOn は空。以前はもう一方の欄もその空のまま一緒に送り、
     入金日だけを直すと DB の入金額が 0 になっていた。 */
  const setPaid = (r, patch) => {
    try {
      window.MStore.recordPayment(r.caseId, r.id, patch);
      onToast && onToast('入金を保存しました');
    } catch (e) { onToast && onToast(e.message); }
  };
  const setDue = (r, due) => {
    try {
      window.MStore.updateSettlement(r.caseId, r.id, { due });
      onToast && onToast('入金期限を保存しました');
    } catch (e) { onToast && onToast(e.message); }
  };
  const setPayer = (r, org) => {
    try {
      window.MStore.updateSettlement(r.caseId, r.id,
        { payer: org.name || null, payerDbId: org.id || null });
      onToast && onToast(org.id ? '請求先を保存しました' : '請求先を解除しました');
    } catch (e) { onToast && onToast(e.message); }
  };

  const th = { padding: '0 12px', height: 32, fontWeight: 600, fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)', textAlign: 'left', whiteSpace: 'nowrap' };
  const td = { padding: '0 12px', whiteSpace: 'nowrap' };
  const one = rows[0];
  // ins は配列でも単一オブジェクトでも来る。msIns で揃える(精算-1)。
  const claims = caseId && window.MStore
    ? (window.msIns ? window.msIns(window.MStore.case(caseId) || {}) : [])
    : [];
  // 精算-2仕上げ: 新規の充当先の候補は、この案件の全トラック(調査・工事)のうち
  // 精算明細(lineDbId=track_settlement_lines.id)を持つもの全部(0045でtop-levelに
  // 付くようになった)。「既存の充当からの trackId→lineId 逆引き」(knownLines)は
  // もう要らない ─ どのトラックの lineId も直接分かるため、まだ一度も充当されて
  // いないトラックへの最初の1件もここに出てくる。
  const visitsAll = (caseData && caseData.visits) || [];
  const worksAll = (caseData && caseData.works) || [];
  const allocatableTracks = [
    ...visitsAll.filter(t => t && t.lineDbId).map(t => ({
      lineId: t.lineDbId, trackId: t.dbId || null,
      trackNo: t.no == null ? null : Number(t.no), trackName: t.title || null, workCategory: null })),
    ...worksAll.filter(t => t && t.lineDbId).map(t => ({
      lineId: t.lineDbId, trackId: t.dbId || null, trackNo: null, trackName: null,
      workCategory: (t.room ? t.room + ' ' : '') + (t.work || '工事') })),
  ];
  // lineDbId が無い(精算明細そのものが無い)トラックは候補に出さない。
  // 何件を除いたかは NewAllocationForm が「精算明細が無いトラックには充当できません」
  // と理由つきで出す(黙って隠さない・05 §5.5)。
  const noLineCount = visitsAll.filter(t => t && !t.lineDbId).length
    + worksAll.filter(t => t && !t.lineDbId).length;

  return (
    <div className="mr-page-pad" style={{ padding: '18px 24px 32px 20px', maxWidth: 'var(--page-max-width)' }}>
      {onBack && <button onClick={onBack} style={{ all: 'unset', cursor: 'pointer', color: 'var(--brand)', fontSize: 'var(--text-body-sm)', fontWeight: 600, marginBottom: 12, display: 'inline-block' }}>← 案件詳細</button>}

      <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 4 }}>
        <h1 style={{ margin: 0, fontSize: 'var(--text-display-md)', fontWeight: 700, letterSpacing: 'var(--tracking-tight)' }}>支払い・精算</h1>
        {caseId && <span className="code" style={{ color: 'var(--brand)', fontSize: 'var(--text-body-md)', fontWeight: 600 }}>{caseId}</span>}
        {caseId && one && <span style={{ fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)' }}>{one.prop}</span>}
        {!caseId && <span style={{ fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)' }}>未完了案件の全明細</span>}
      </div>
      <div style={{ fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)', marginBottom: 16 }}>
        精算はトラックごと（細かい）／ 請求は同一請求先で束ねる。保険精算は請求書を基本作らない。
      </div>

      <div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
        <PKpi label="元請金額 合計" value={yen(total)} sub={undecided ? undecided + ' 件は金額未確定' : '全明細の金額が確定'} />
        <PKpi label="保険精算" value={yen(ins)} sub="査定・入金待ちを含む" />
        <PKpi label="自費精算" value={yen(self)} sub="自費で回収する分（請求書は freee で発行）" />
        <PKpi label="入金済" value={yen(paid)} sub={total ? Math.round(paid / total * 100) + '% 回収' : '—'} />
      </div>

      <div style={{ marginBottom: 16 }}><SettleFlow rows={rows} /></div>

      <AfCard pad={0} style={{ overflow: 'hidden', marginBottom: 16 }}>
        <div style={{ padding: '11px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
          <h3 style={{ margin: 0, fontSize: 'var(--text-display-sm)', fontWeight: 600, marginRight: 4 }}>精算（実務トラックごと）</h3>
          <div style={{ flex: 1 }} />
          {PAY_TABS.map(t => {
            const on = t.id === tab, n = rows.filter(t.hit).length;
            return <button key={t.id} onClick={() => setTab(t.id)} style={{
              all: 'unset', cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-body-sm)',
              fontWeight: on ? 700 : 500, padding: '3px 11px', borderRadius: 'var(--radius-full)',
              border: '1px solid ' + (on ? 'var(--brand)' : 'var(--border)'),
              background: on ? 'var(--brand-muted)' : 'transparent',
              color: on ? 'var(--brand)' : 'var(--muted-foreground)' }}>{t.label} {n}</button>;
          })}
        </div>
        <div style={{ overflowX: 'auto' }}>
        <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--text-body-md)' }}>
          <thead><tr style={{ borderBottom: '1px solid var(--border)' }}>
            {!caseId && <th style={th}>案件</th>}
            <th style={th}>対象</th><th style={th}>精算方式</th><th style={th}>請求先（精算者）</th>
            <th style={{ ...th, textAlign: 'right' }}>元請金額</th>
            <th style={th}>入金期限</th><th style={{ ...th, textAlign: 'right' }}>入金額</th><th style={th}>入金日</th><th style={th}>状態</th>
          </tr></thead>
          <tbody>
            {shown.map(r => (
              <tr key={r.caseId + r.id} style={{ borderBottom: '1px solid var(--border)', height: 38 }}>
                {!caseId && <td style={{ ...td, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
                  <span className="code" style={{ fontSize: 'var(--text-caption)', color: 'var(--brand)' }}>{r.caseId}</span>
                  <span style={{ marginLeft: 6, color: 'var(--muted-foreground)', fontSize: 'var(--text-body-sm)' }}>{r.prop}</span>
                </td>}
                <td style={{ ...td, fontWeight: 500, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.target}</td>
                <td style={td}><AfChip tone={methodTone(r.method)}>{r.method}</AfChip></td>
                <td style={{ ...td, maxWidth: 240 }}>
                  <PaymentPayerEditor payer={r.payer} payerDbId={r.payerDbId}
                    onSave={org => setPayer(r, org)} onError={onToast} />
                </td>
                <td style={{ ...td, textAlign: 'right' }}>
                  <window.EdMoney value={r.billed} label="元請金額" onError={onToast} onSave={v => setBilled(r, v)} />
                </td>
                <td style={td}>
                  <PaymentDueEditor value={r.due} onSave={v => setDue(r, v)} onError={onToast} />
                </td>
                <td style={{ ...td, textAlign: 'right' }}>
                  <window.EdMoney value={r.paidAmount} label="入金額" onError={onToast} onSave={v => setPaid(r, { amount: v })} />
                </td>
                <td style={td}>
                  {/* H4: 入金期限と同じ検査の部品。読めない日付で DB の入金日を消さない。 */}
                  <PaymentDateEditor label="入金日" value={r.paidOn} onSave={v => setPaid(r, { on: v })} onError={onToast} />
                </td>
                <td style={td}><AfChip tone={statusTone(r.status)} dot>{r.status}</AfChip></td>
              </tr>
            ))}
            {!shown.length && <tr><td colSpan={caseId ? 8 : 9} style={{ padding: 20, textAlign: 'center', color: 'var(--muted-foreground)' }}>該当する明細はありません</td></tr>}
          </tbody>
        </table>
        </div>
        {scoped.length > PAY_LIMIT && (
          <div style={{ padding: '9px 14px', borderTop: '1px solid var(--border)', fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)' }}>
            {scoped.length} 件のうち先頭 {PAY_LIMIT} 件を表示しています。案件詳細の「精算」から開くと、その案件だけになります。
          </div>
        )}
      </AfCard>

      <div className="mr-stack-1" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
        <AfCard pad={0} style={{ overflow: 'hidden' }}>
          <div style={{ padding: '11px 14px', borderBottom: '1px solid var(--border)' }}>
            <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 }}>
              保険金は精算明細ではなく保険金請求（申請額・認定額）で持つ
            </div>
          </div>
          <div style={{ padding: 14 }}>
            {claims.length
              ? claims.map((claim, i) => (
                <div key={claim.id || claim.name || i} style={{ fontSize: 'var(--text-body-md)',
                  marginBottom: i < claims.length - 1 ? 12 : 0,
                  paddingBottom: i < claims.length - 1 ? 12 : 0,
                  borderBottom: i < claims.length - 1 ? '1px solid var(--border)' : 'none' }}>
                  {claims.length > 1 && (
                    <div style={{ fontSize: 'var(--text-caption)', fontWeight: 600, color: 'var(--muted-foreground)', marginBottom: 4 }}>
                      保険 {i + 1} / {claims.length}
                    </div>
                  )}
                  <div style={{ display: 'flex', justifyContent: 'space-between', padding: '5px 0', borderBottom: '1px solid var(--border)' }}><span style={{ color: 'var(--muted-foreground)' }}>保険会社</span><span style={{ fontWeight: 500 }}>{claim.name || '—'}</span></div>
                  <div style={{ display: 'flex', justifyContent: 'space-between', padding: '5px 0', borderBottom: '1px solid var(--border)' }}><span style={{ color: 'var(--muted-foreground)' }}>加入者</span><span style={{ fontWeight: 500 }}>{claim.holder || '—'}</span></div>
                  <div style={{ display: 'flex', justifyContent: 'space-between', padding: '5px 0', borderBottom: '1px solid var(--border)' }}><span style={{ color: 'var(--muted-foreground)' }}>申請額</span><span className="mono" style={{ fontWeight: 700 }}>{claim.detail && claim.detail.billed != null ? yen(claim.detail.billed) : '—'}</span></div>
                  <div style={{ display: 'flex', justifyContent: 'space-between', padding: '5px 0' }}><span style={{ color: 'var(--muted-foreground)' }}>認定額</span><span className="mono" style={{ fontWeight: 700 }}>{claim.detail && claim.detail.approved != null ? yen(claim.detail.approved) : '—'}</span></div>
                  <InsuranceAllocationsBlock caseId={caseId} claim={claim}
                    allocatableTracks={allocatableTracks} noLineCount={noLineCount} onToast={onToast} />
                </div>
              ))
              : <div style={{ fontSize: 'var(--text-body-md)', color: 'var(--muted-foreground)' }}>
                  {caseId ? 'この案件に保険のトラックはありません。' : '案件詳細の「精算」から開くと、その案件の保険が出ます。'}
                </div>}
          </div>
        </AfCard>

        <AfCard pad={0} style={{ overflow: 'hidden' }}>
          <div style={{ padding: '11px 14px', borderBottom: '1px solid var(--border)' }}>
            <h3 style={{ margin: 0, fontSize: 'var(--text-display-sm)', fontWeight: 600 }}>請求書（束ね）</h3>
          </div>
          <div style={{ padding: 14 }}>
            {invoices.length ? invoices.map((inv, i) => {
              const href = invoiceHref(inv.invoiceUrl);
              return <div key={(inv.invoiceNo || '') + ':' + (inv.invoiceUrl || '') + ':' + i}
                style={{ paddingBottom: i < invoices.length - 1 ? 10 : 0, marginBottom: i < invoices.length - 1 ? 10 : 0,
                  borderBottom: i < invoices.length - 1 ? '1px solid var(--border)' : 'none' }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
                  <span className="mono" style={{ fontWeight: 700 }}>{inv.invoiceNo || '番号未登録'}</span>
                  {href && <a href={href} target="_blank" rel="noreferrer" style={{ color: 'var(--brand)', fontSize: 'var(--text-body-sm)', fontWeight: 600 }}>請求書を開く</a>}
                  {inv.urlConflict && <AfChip tone="warning">同じ番号に異なるリンクがあります</AfChip>}
                  {!inv.urlConflict && inv.invoiceUrl && !href && <AfChip tone="warning">請求書リンクを確認</AfChip>}
                  <span className="mono" style={{ marginLeft: 'auto', fontWeight: 700 }}>{yen(inv.billed)}</span>
                </div>
                <div style={{ marginTop: 4, fontSize: 'var(--text-caption)', color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
                  {inv.payers.length ? inv.payers.join('・') : '請求先未設定'} ／ {inv.targets.join('・') || '対象未設定'}
                </div>
              </div>;
            }) : <AfAlert variant="notice" title="請求書はまだ登録されていません">
              この案件の精算明細には請求書番号・リンクがありません。請求書の発行・変更は freee で行います。
            </AfAlert>}
            <div style={{ marginTop: 10, fontSize: 'var(--text-body-sm)', color: 'var(--muted-foreground)', lineHeight: 1.6 }}>
              まるろうOSでは freee の請求書番号とリンクを表示します。保険精算は保険のやり取りで回収するため請求書を基本作りません。
            </div>
          </div>
        </AfCard>
      </div>
    </div>
  );
}

Object.assign(window, { Payment, UnpaidList, paymentInvoiceHref: invoiceHref, paymentInvoices, paymentDueInput,
  paymentRowsWithPayers, payPartnerFetch });
